mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-08 11:47:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa829df265 | ||
|
|
e84a0a121a | ||
|
|
087c107685 | ||
|
|
af7a1e8f69 | ||
|
|
cbbd8b7955 | ||
|
|
edc737df4a | ||
|
|
8050e11a41 | ||
|
|
bdf55009b5 | ||
|
|
a5a28c9d62 | ||
|
|
70909a40dc | ||
|
|
a922cfdf40 | ||
|
|
bc806bada0 | ||
|
|
36207bbab3 | ||
|
|
f4dee94785 | ||
|
|
3d563a2dcc | ||
|
|
67b476eaa0 | ||
|
|
a0adf75b53 | ||
|
|
014beaf71f | ||
|
|
74a028acaa | ||
|
|
66bf8b9de9 | ||
|
|
635780a4cf | ||
|
|
82fb235de9 | ||
|
|
336a01db01 | ||
|
|
a9df4add33 | ||
|
|
129486ce89 | ||
|
|
3e7191adc7 | ||
|
|
4cd784787e | ||
|
|
5503a9d768 | ||
|
|
094e207c0b | ||
|
|
09be09bd1b | ||
|
|
19a867488b | ||
|
|
9bfdaa9632 | ||
|
|
eca013e780 | ||
|
|
5f814a55eb | ||
|
|
f980600a2c | ||
|
|
75c25fefe4 | ||
|
|
7538491f32 | ||
|
|
5268e0c375 | ||
|
|
e1449c9b1e | ||
|
|
c7f6815014 | ||
|
|
5b2c620bf5 | ||
|
|
72927943c2 | ||
|
|
b02aedb579 | ||
|
|
3ab22a9796 | ||
|
|
47980e1f86 | ||
|
|
673f8f3b06 | ||
|
|
52325e7ae8 | ||
|
|
ebb1d9ff4c | ||
|
|
3fbd0e1286 | ||
|
|
0c0b72cd87 | ||
|
|
c22efa2c0f | ||
|
|
bfaab98eff | ||
|
|
bc72aad30c | ||
|
|
31ad21f69d | ||
|
|
1ab18f5f60 | ||
|
|
77cb6bf78e | ||
|
|
1c32149c2b | ||
|
|
06356f249a | ||
|
|
c4b4fa6971 | ||
|
|
5c5d1bd946 | ||
|
|
23ecdf56ee | ||
|
|
1729f84dbb | ||
|
|
178b7ee1d8 | ||
|
|
5ff1a4f813 | ||
|
|
272241bed6 | ||
|
|
f39751da17 | ||
|
|
612ec05b19 | ||
|
|
e4dde1e6c8 | ||
|
|
c2f9392fa8 | ||
|
|
e965cc6c59 | ||
|
|
e0c780c998 | ||
|
|
f870612f92 | ||
|
|
2497e3a03e | ||
|
|
1431fd73a9 | ||
|
|
c01e83f844 | ||
|
|
04a7103ae8 | ||
|
|
df04be9dec | ||
|
|
1520647659 | ||
|
|
5956c83041 | ||
|
|
b1452dabc3 | ||
|
|
bf017ac28c | ||
|
|
798602c56f | ||
|
|
a0045156c1 | ||
|
|
8402010a2a | ||
|
|
59ab316358 | ||
|
|
5c61ebd741 | ||
|
|
b56e55bee2 | ||
|
|
cad813e28b | ||
|
|
0e6523e0ae | ||
|
|
0489be0350 | ||
|
|
bf78563746 | ||
|
|
7aecda4259 | ||
|
|
a0ef5c3d95 | ||
|
|
d4248200d1 | ||
|
|
aecffc4584 | ||
|
|
bb593fe8d1 | ||
|
|
819193fe03 | ||
|
|
76e036fb42 | ||
|
|
5e1ac3f9cc | ||
|
|
326e48c663 | ||
|
|
c376af9a95 | ||
|
|
c6e5e37f7f | ||
|
|
df9702997d | ||
|
|
edf0d32508 | ||
|
|
5f7df75c85 | ||
|
|
1839eebb20 | ||
|
|
75f1d753b1 | ||
|
|
cc40d5e494 | ||
|
|
5ef3f3e11e | ||
|
|
03dab18223 | ||
|
|
492d2e9ae3 | ||
|
|
b6875591f0 | ||
|
|
29a9cb3873 | ||
|
|
1f38ba8abf | ||
|
|
8b166312c6 | ||
|
|
f816ccf3e4 | ||
|
|
1eddccbe63 | ||
|
|
84c6f6fed0 | ||
|
|
d36369b321 | ||
|
|
5d0ed3af68 | ||
|
|
059b817b03 | ||
|
|
0c66a2ede9 | ||
|
|
b38e298ddc | ||
|
|
e9e56182b8 | ||
|
|
819a0960be | ||
|
|
0b03be5d02 | ||
|
|
7cb58c7912 | ||
|
|
10c1cc2a60 | ||
|
|
49e8164b6f | ||
|
|
a030853d6b | ||
|
|
1d9b41c2a4 | ||
|
|
1e81c86f65 | ||
|
|
5ce147ef83 | ||
|
|
7e89a96ffc | ||
|
|
8c1b754180 | ||
|
|
0cecaad686 | ||
|
|
63dc8fc988 | ||
|
|
6a166b23fb | ||
|
|
ed20356af9 | ||
|
|
316099b42f | ||
|
|
71e8d8eea1 | ||
|
|
cb8c1b3a3e | ||
|
|
d4b5a48b79 | ||
|
|
f0d344099a | ||
|
|
a60299efe6 | ||
|
|
e97c68257d | ||
|
|
afd970c672 | ||
|
|
4a138bb42f | ||
|
|
3b33ee2c43 | ||
|
|
88243825eb | ||
|
|
0ca01c338d | ||
|
|
d23b87d2ad | ||
|
|
955c5ca9b2 | ||
|
|
0934701d75 | ||
|
|
cb01b9b3b1 | ||
|
|
fc973d4f5f | ||
|
|
2aa6d8c078 | ||
|
|
bc297330ad | ||
|
|
b959d878aa | ||
|
|
cc50f183c0 | ||
|
|
bf9b85331b | ||
|
|
6288bc543e | ||
|
|
d000a8ee19 | ||
|
|
12cbc7e07d | ||
|
|
4df08d383a | ||
|
|
a42c332cf4 | ||
|
|
240454efa8 | ||
|
|
099b01af14 | ||
|
|
af163df6ca | ||
|
|
0efe50b57a | ||
|
|
a0ca574974 | ||
|
|
d240ab12ec | ||
|
|
cc90740de4 | ||
|
|
6fa65238db | ||
|
|
c827b9bb8d | ||
|
|
6fba3d61f3 | ||
|
|
294608d95c | ||
|
|
0d13200d58 | ||
|
|
e6e8ba73e0 | ||
|
|
4825acc2dd | ||
|
|
2e49f0ddb5 | ||
|
|
d27445a128 | ||
|
|
b9aa51cd00 | ||
|
|
5beaad0498 | ||
|
|
e6f8c6fae3 | ||
|
|
1e2c9450cf | ||
|
|
b80cf1b22d | ||
|
|
c11268c6b4 | ||
|
|
14c56e4052 | ||
|
|
bbb401ef87 | ||
|
|
9ea66ddaee | ||
|
|
e8d215e5eb | ||
|
|
2b3325ed0d | ||
|
|
c813d6ee98 | ||
|
|
f472334f09 | ||
|
|
4ac94d1ff2 | ||
|
|
742e58d2e8 | ||
|
|
d0cf459450 | ||
|
|
625c0f2330 | ||
|
|
337cb3169a | ||
|
|
63778135c6 | ||
|
|
383d768f46 | ||
|
|
78a044697a | ||
|
|
3a3ff42b2f | ||
|
|
14ec4804f4 | ||
|
|
537f53d856 | ||
|
|
6a6e1ce330 | ||
|
|
4187712c8d | ||
|
|
381cf307f7 | ||
|
|
d7a794e557 | ||
|
|
524e1ced50 | ||
|
|
2b7214e3dd | ||
|
|
325af3a491 | ||
|
|
ac19e9e80b | ||
|
|
e49f410544 | ||
|
|
9383a0456e | ||
|
|
5ca43895f2 | ||
|
|
986dda5eaa | ||
|
|
2db63a8edd | ||
|
|
ad59032693 | ||
|
|
e11186da99 | ||
|
|
e90850f16d | ||
|
|
d93d325913 | ||
|
|
bb0ece9ee4 | ||
|
|
209fd3aa4c | ||
|
|
a122a6917b | ||
|
|
9afcb09885 | ||
|
|
1999307dfa | ||
|
|
a297715b8a | ||
|
|
6daaf33dbc | ||
|
|
bb55fb40a3 | ||
|
|
d7a6ef0302 | ||
|
|
77bbc1b0a0 | ||
|
|
4409fb6801 | ||
|
|
b212c47cad | ||
|
|
8fd3b91858 | ||
|
|
87e2ea0ad3 | ||
|
|
870b596d3e | ||
|
|
54485c0825 | ||
|
|
0f4cfac3b0 | ||
|
|
79bdbe6d76 | ||
|
|
178715499f | ||
|
|
69c4f9f0d1 | ||
|
|
cacff3c71b | ||
|
|
252decb3ad | ||
|
|
854cb280a0 | ||
|
|
824ff10dd9 | ||
|
|
0aa9cb0019 | ||
|
|
eea9667987 | ||
|
|
4d8075927f | ||
|
|
4a200c6862 | ||
|
|
bc69d6fc22 | ||
|
|
5d2aad045d | ||
|
|
f0eddacca7 | ||
|
|
cf5903e5be | ||
|
|
12e1afeeaa | ||
|
|
2cceabb10e | ||
|
|
41de40e6cb | ||
|
|
219abe0126 | ||
|
|
d4a18aa0a5 | ||
|
|
031627d4a7 | ||
|
|
fe92737066 | ||
|
|
28e5ed3be2 | ||
|
|
d52037c398 | ||
|
|
f806af9632 | ||
|
|
562b563477 | ||
|
|
d51f816355 | ||
|
|
b35276c7a1 | ||
|
|
3129221531 | ||
|
|
59066b7139 | ||
|
|
818510d1b0 | ||
|
|
85191d2984 | ||
|
|
1652f63770 | ||
|
|
80e88cb303 | ||
|
|
5af29b6bed | ||
|
|
5cf16133d8 | ||
|
|
12d499a901 | ||
|
|
b6e0f4b6ab | ||
|
|
325be98bc0 | ||
|
|
739f1a273a | ||
|
|
3c35e63bc8 | ||
|
|
2eb12a26de | ||
|
|
71e2cbb271 | ||
|
|
edc73629be | ||
|
|
c5b7f836c1 | ||
|
|
ee94c6f3d6 | ||
|
|
69ab7f1c3f | ||
|
|
c09a08b7b6 | ||
|
|
99b52103a7 | ||
|
|
2a4442b5f4 | ||
|
|
7c21fc6d25 | ||
|
|
6e4f4bb67d | ||
|
|
ac4186ac25 | ||
|
|
f278b3314b | ||
|
|
dcaa42d21d | ||
|
|
7b0bd603a6 | ||
|
|
c04b24f722 | ||
|
|
8240d29eae | ||
|
|
877912e6e8 | ||
|
|
a9af8a71c6 | ||
|
|
26e603d94d | ||
|
|
2257cdbb9d | ||
|
|
fde3c98b98 | ||
|
|
5b8e6eaeac | ||
|
|
a0d1c59281 | ||
|
|
a5a20dc2c9 | ||
|
|
198d399803 | ||
|
|
abdc14c555 | ||
|
|
a5b1df0d29 | ||
|
|
f8bd2cee03 | ||
|
|
6ca78b04a4 | ||
|
|
958a31a306 | ||
|
|
90856b29a6 | ||
|
|
ac30cb0469 | ||
|
|
813b5eeb6e | ||
|
|
d49ba270cc | ||
|
|
31eb9b5e4b | ||
|
|
f36e302c93 | ||
|
|
895e47d513 | ||
|
|
44db619c0c | ||
|
|
d7c757ed57 | ||
|
|
cba29d94ba | ||
|
|
dc556d61b6 | ||
|
|
9ac1f64056 | ||
|
|
b88da2e993 | ||
|
|
2fb48bfd0c | ||
|
|
fdb259ebbd | ||
|
|
91c00d0970 | ||
|
|
3a61cae163 | ||
|
|
5664551bd2 | ||
|
|
4e6bba54b5 | ||
|
|
8f56046a11 | ||
|
|
a01fece235 | ||
|
|
791425670b | ||
|
|
a40e9e7cf7 | ||
|
|
e36db71534 | ||
|
|
da23a7ddb0 | ||
|
|
16e6a6f659 | ||
|
|
742d2b140a | ||
|
|
befd1a4c3c | ||
|
|
e859af0296 | ||
|
|
4a5c81b5e6 | ||
|
|
eebc96f439 | ||
|
|
065118cf6c | ||
|
|
8ea95ee812 | ||
|
|
c144c20c53 |
No files matched your search
@@ -66,10 +66,20 @@ jobs:
|
||||
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}",
|
||||
"-p:SelfContained=true")
|
||||
|
||||
dotnet publish "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "LibationCli/LibationCli.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj" $PUBLISH_ARGS
|
||||
# PowerShell does not stop at a native command that fails, and the step is judged by the exit code
|
||||
# of the last one, so a publish that fails anywhere but the end passes as a green build. That is not
|
||||
# hypothetical: LibationWinForms failed to compile for a while and every run stayed green, uploading
|
||||
# a Libation-Classic zip with no Libation.exe in it. Check each one.
|
||||
$projects = @(
|
||||
"Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj",
|
||||
"LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj",
|
||||
"LibationCli/LibationCli.csproj",
|
||||
"Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj")
|
||||
|
||||
foreach ($project in $projects) {
|
||||
dotnet publish "$project" $PUBLISH_ARGS
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed for $project" }
|
||||
}
|
||||
|
||||
- name: Zip artifact
|
||||
id: zip
|
||||
@@ -81,6 +91,11 @@ jobs:
|
||||
"WindowsConfigApp.deps.json")
|
||||
|
||||
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
|
||||
|
||||
# Both UIs publish as Libation.exe. upload-artifact's if-no-files-found only proves the zip exists,
|
||||
# which it does whether or not the app is inside it, so say outright that it has to be.
|
||||
if (-not (Test-Path "Libation.exe")) { throw "Libation.exe is missing from the publish output" }
|
||||
|
||||
$artifact="${{ matrix.artifact_stem }}.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
|
||||
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
||||
Compress-Archive -Path * -DestinationPath "$artifact"
|
||||
|
||||
@@ -94,7 +94,19 @@ export default defineConfig({
|
||||
text: "Audiobookshelf Auto-Upload",
|
||||
link: "/docs/features/audiobookshelf",
|
||||
},
|
||||
{
|
||||
text: "Daily Download Limit",
|
||||
link: "/docs/features/daily-download-limit",
|
||||
},
|
||||
{ text: "Naming Templates", link: "/docs/features/naming-templates" },
|
||||
{
|
||||
text: "Parallel Downloads",
|
||||
link: "/docs/features/parallel-downloads",
|
||||
},
|
||||
{
|
||||
text: "Retrying Refused Downloads",
|
||||
link: "/docs/features/retrying-refused-downloads",
|
||||
},
|
||||
{
|
||||
text: "Searching & Filtering",
|
||||
link: "/docs/features/searching-and-filtering",
|
||||
@@ -103,6 +115,7 @@ export default defineConfig({
|
||||
text: "Easy guide to searching",
|
||||
link: "/docs/features/lucene",
|
||||
},
|
||||
{ text: "Trash Bin", link: "/docs/features/trash-bin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -111,6 +124,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: "Advanced Topics", link: "/docs/advanced/advanced" },
|
||||
{ text: "Command Line Interface", link: "/docs/advanced/command-line-interface" },
|
||||
{ text: "Device registration", link: "/docs/advanced/device-registration" },
|
||||
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" },
|
||||
{ text: "Spatial Audio & DRM", link: "/docs/advanced/spatial-audio" },
|
||||
],
|
||||
@@ -124,6 +138,7 @@ export default defineConfig({
|
||||
link: "/docs/development/getting-started",
|
||||
},
|
||||
{ text: "Contribute", link: "/docs/development/contribute" },
|
||||
{ text: "Testing Changes", link: "/docs/development/testing" },
|
||||
{ text: "Website & Docs", link: "/docs/development/website" },
|
||||
{ text: "Linux Setup (Nix)", link: "/docs/development/nix-linux-setup" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
Libation is a cross-platform .NET desktop/CLI app for downloading and de-DRMing Audible
|
||||
audiobooks. The core product is the Avalonia GUI (`LibationAvalonia`, assembly name `Libation`)
|
||||
plus a headless CLI (`LibationCli`) that shares the same config and SQLite library database.
|
||||
|
||||
### Toolchain / environment
|
||||
- Requires the **.NET 10 SDK** pinned by `global.json` (`10.0.101`). It is preinstalled at
|
||||
`~/.dotnet`; `~/.bashrc` exports `DOTNET_ROOT` and adds `~/.dotnet` (and `~/.dotnet/tools`) to
|
||||
`PATH`. New non-login shells may not have it — use `dotnet` after a login shell, or call
|
||||
`~/.dotnet/dotnet` directly.
|
||||
- The startup update script runs `dotnet tool restore` (restores `dotnet-ef`) and
|
||||
`dotnet restore Source/Libation.slnx`. No other setup is needed.
|
||||
- The solution file is the new XML format: `Source/Libation.slnx` (there is no classic `.sln`).
|
||||
|
||||
### Building — everything compiles on Linux, only some of it runs
|
||||
- `dotnet build Source/Libation.slnx` succeeds on Linux with 0 errors. So do
|
||||
`LibationWinForms`, `HangoverWinForms` and the `LoadByOS/{Windows,MacOS}ConfigApp` helpers,
|
||||
individually: they target `net10.0-windows7.0` but set `EnableWindowsTargeting`, which is what makes
|
||||
a non-Windows SDK build them. **Always compile-check a change to the WinForms projects** — an earlier
|
||||
version of this file said they could not be built here, and a whole PR's worth of WinForms edits went
|
||||
out unverified on the strength of that.
|
||||
- Compiling is not running. WinForms has no Linux runtime, so behaviour and layout in
|
||||
`LibationWinForms` / `HangoverWinForms` still need a human on Windows. Say so explicitly rather than
|
||||
implying a WinForms change was tested.
|
||||
- Build the runnable cross-platform apps directly:
|
||||
- `dotnet build Source/LibationAvalonia/LibationAvalonia.csproj`
|
||||
- `dotnet build Source/LibationCli/LibationCli.csproj`
|
||||
- There is no dedicated lint step; the repo's `.editorconfig` is minimal and CI
|
||||
(`.github/workflows/validate.yml`) only builds and tests. The compiler/analyzer warnings from a
|
||||
normal build serve as the static-analysis check. (A known transitive `NU1903` SQLite
|
||||
vulnerability warning is expected and harmless.)
|
||||
|
||||
### Testing
|
||||
- Test projects live under `Source/_Tests/` and use MSTest on **Microsoft.Testing.Platform**
|
||||
(configured via `global.json` `test.runner`). Because of this runner, `dotnet test` requires
|
||||
`--project` for a single project (a positional project path is rejected):
|
||||
`dotnet test --project Source/_Tests/FileManager.Tests/FileManager.Tests.csproj`
|
||||
- CI runs `dotnet test` from `Source/` on every platform, Linux included, and it works here too: the
|
||||
restore covers the whole solution but only the test projects and their references are compiled, so
|
||||
the Windows-only projects never break the run. Naming one project is simply quicker.
|
||||
- **Manual UI testing:** `dotnet run Scripts/seed-demo-library.cs` fills the library with fake books
|
||||
covering every Liberate-column icon and prints the expected result for each row
|
||||
(`-- --clean` removes them). Read `docs/development/testing.md` before seeding library state by
|
||||
hand: the yellow lamp is an `.aaxc` file on disk rather than a stored status, `AudioExists` is a
|
||||
database check so green/error need no files, and a podcast's series is keyed off the parent
|
||||
book's own ASIN or the grid silently drops the parent row.
|
||||
- **GNOME Keyring / OS secret store:** Libation's default `TokenStorageMethod` is `Encrypted`,
|
||||
and the AES-GCM master key is stored via the OS secret store (`OsSecretStore` /
|
||||
`IdentityTokenStorageWiring`). On Linux that is GNOME Keyring (Secret Service), which **blocks
|
||||
indefinitely** here: the login keyring is locked with a password nobody has, and the D-Bus call
|
||||
hangs even with no prompt on screen. Probing availability does not help - the probe is the
|
||||
blocking call.
|
||||
Every test project should finish in about a second. If one runs for minutes, assume something
|
||||
reached the OS secret store; do not sit through it and do not re-run it hoping for a different
|
||||
result. `ResolveSecretStore` short-circuits on `LIBATION_MASTER_KEY_FILE`, an existing
|
||||
`libation-master.key` under the Libation files dir, or `LIBATION_MASTER_KEY`, so setting one of
|
||||
those keeps a test off the OS store entirely.
|
||||
The tests that deliberately exercise the real store are opt-in via
|
||||
`LIBATION_TEST_OS_SECRET_STORE=1` and are skipped otherwise. Leave them skipped on Linux; they
|
||||
will hang if enabled. Always run tests under `timeout` so a regression here cannot stall a session.
|
||||
|
||||
### Running the apps
|
||||
- GUI: a display is available on `DISPLAY=:1`. Run with
|
||||
`cd Source/LibationAvalonia && dotnet run`. First launch shows a Welcome/walkthrough and creates
|
||||
config under `~/.local/share/Libation/` (`Settings.json`, `AccountsSettings.json`,
|
||||
`LibationContext.db`). The same keyring note above applies when the GUI/CLI first encrypts
|
||||
account tokens.
|
||||
- CLI: `cd Source/LibationCli && dotnet run -- <command>` (e.g. `version`, `get-setting`,
|
||||
`list-accounts`, `scan`, `liberate`). The CLI reads the same `~/.local/share/Libation/` config
|
||||
and database as the GUI.
|
||||
- Actually scanning/downloading a library requires signing into a real Audible account, so full
|
||||
end-to-end liberation cannot be exercised without credentials.
|
||||
@@ -2,4 +2,12 @@
|
||||
<PropertyGroup>
|
||||
<EnableMSTestRunner>true</EnableMSTestRunner>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The SDK supplies Microsoft.SourceLink.Common and the git tasks, which is enough to find the
|
||||
repository but not to turn its URL into fetchable source URLs. Without a host provider every
|
||||
project warns that the generated source link is empty and ships PDBs that cannot locate their
|
||||
sources. -->
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.301" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -40,13 +40,14 @@ We welcome contributions!
|
||||
|
||||
- **[Development Getting Started](https://getlibation.com/docs/development/getting-started)**: Setup your environment.
|
||||
- **[Contribute](https://getlibation.com/docs/development/contribute)**: How to contribute code.
|
||||
- **[Testing Changes](https://getlibation.com/docs/development/testing)**: Run the tests and seed a demo library for UI testing.
|
||||
- **[Website & Docs](https://getlibation.com/docs/development/website)**: How to run and improve the documentation.
|
||||
- **[Linux Setup (Nix)](https://getlibation.com/docs/development/nix-linux-setup)**: Nix-based environment setup.
|
||||
|
||||
## Community & Support
|
||||
|
||||
- **[Issues](https://github.com/rmcrackan/Libation/issues)**: Report bugs or request features.
|
||||
- **[PayPal](https://paypal.me/mcrackan?locale.x=en_us)**: Support the project if you find it useful.
|
||||
- **[Donate](https://getlibation.com/donate)**: Support the project if you find it useful.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Scripts
|
||||
|
||||
Developer utilities. None of these ship in a Libation install - they exist only in a source checkout.
|
||||
|
||||
| Script | Purpose | Documented in |
|
||||
|--------|---------|---------------|
|
||||
| `seed-demo-accounts.cs` | Seed fake Audible accounts, to reach what is gated on having one without signing in | [Testing Changes](https://getlibation.com/docs/development/testing) |
|
||||
| `seed-demo-library.cs` | Seed a library covering every Liberate-column icon, for manual UI testing | [Testing Changes](https://getlibation.com/docs/development/testing) |
|
||||
| `seed-download-history.cs` | Seed completed downloads so the daily download limit can be tested without downloading | [Testing Changes](https://getlibation.com/docs/development/testing) |
|
||||
| `Bundle_Debian.sh` | Build the Linux `.deb` package | Used by `.github/workflows/build-linux.yml` |
|
||||
| `Bundle_Redhat.sh` | Build the Linux RPM package | Used by `.github/workflows/build-linux.yml` |
|
||||
| `Bundle_MacOS.sh` | Build the macOS app bundle | Used by `.github/workflows/build-mac.yml` |
|
||||
| `Windows/` | Windows installer (Inno Setup) | [Windows/README.md](Windows/README.md) |
|
||||
|
||||
Usage for the testing scripts lives in the docs rather than here, so there is only one copy to keep current.
|
||||
@@ -0,0 +1,236 @@
|
||||
#:package AudibleApi@11.0.3.1
|
||||
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using AudibleApi.Cryptography;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
// Adds fake Audible accounts to AccountsSettings.json, so the parts of Libation that only appear once an
|
||||
// account exists can be tested without signing in to Audible.
|
||||
//
|
||||
// dotnet run Scripts/seed-demo-accounts.cs [--count 1] [--clean] [path-to-Libation-files-folder]
|
||||
//
|
||||
// Run Libation at least once first so AccountsSettings.json exists, and close it before seeding: the file is
|
||||
// read at startup and written back on save, so a running app would overwrite whatever this wrote.
|
||||
//
|
||||
// The accounts carry structurally valid but meaningless tokens. Libation will count them, list them and
|
||||
// enable everything that is gated on having an account, but any scan or download attempt fails at Audible.
|
||||
//
|
||||
// Real accounts in the file are left alone. --clean removes only the accounts this script added, which are
|
||||
// the ones whose AccountId matches demo*@example.com.
|
||||
//
|
||||
// Keep the package version above in step with AudibleApi in Source/AudibleUtilities/AudibleUtilities.csproj,
|
||||
// so the JSON this writes is the shape the app expects to read.
|
||||
|
||||
// Fixture material. A syntactically valid RSA key and ADP token so the identity parses; neither authenticates.
|
||||
const string SamplePrivateKey = @"
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpgIBAAKCAQEA5nPbGSVDmlEH2tJa6kz/P2HI8IeirhfPHdmi+X/nsb9i3WNf
|
||||
tmEdZxfK26IValQDXvBH17a1gr0HD6pYse1XsV2w0HxiW1RW+ZnjL8/fzPdkSOb+
|
||||
4xKlqRopCueBSdDGgAF06spZ3IeHLfEFOJX4dO1Y73pFBUkA0k53LT12L2Tjay/r
|
||||
buZHJqIzxmwja7/nkiWL0Xo7UySHtQACYsKEatu6yHBS+cPTlGR/qeUpeJTHwDLP
|
||||
7ZQ7kWzJGY1mfInYekjlZLsMsWswso3pg1vPyHgxzM2BWhY8m6mlXQ9G/USxBTib
|
||||
MNuMtpR73XsgamneFCc+Uv1cxw7ofZ41YOOAbQIDAQABAoIBAQDIre8HkKm0Aggj
|
||||
B7df/TjxCsgenR6PF/Cmf9UqC7XJ1W3UeCrq+NrP4aonZJfdhdeBnyAQuuyJMu6p
|
||||
N6ARISuSKpJEm2xTN7idluJ9yjmLlYtg6LbhKmXUQhGniz3M999DrQERTLDAF80h
|
||||
tpbjVcWMnPsrX4AnQBFVEjs5zCHU1hD+X463EmUHBWyT975jbZ8Fy7/fTzkdzLnn
|
||||
qE5lROALr2MCAAwQRFbRE6dd52vnXaBrVcAtRzjATts3WG3+SNi2Fm/OrYqQcY9e
|
||||
lBexNviT8VcldOAMrO10E2u0d+tvxFzwB3ABMvaVamrEZky4XSfB6aLzpD0JJj1s
|
||||
UHnIiVwJAoGBAPl8nLll/J9rud/N2HiAX2YkP0MC0HW4yM3KxLtXKyXrP5qBpaci
|
||||
wTDUmSWEEE3GUJMM1Z4d9tl9Lz2MhU2KqkEvLI3kQ7aUu33PYUBGMVcUzhFQ49lU
|
||||
Nzz8YB183iqo31o/DKk2Cr5gI7SykQZ0gn/urZkEJeErLzlhPXcyeY5jAoGBAOx4
|
||||
CGucVdv5MbdXZP8jVzxuvUlSp7BIQJ2phQXDFBNApFKnZn7yBYBx7dqzleymGm+R
|
||||
INZAurg3SNw4nvbQc3Z2dJ8I+n5ErjFCKp1IedVxx1eMEfecTwrQZuUwLISIyjqF
|
||||
czSJNwcNqzCx67z397/Cg5K/0pu6uIe0r7xozcbvAoGBAOOvZ9CDVPOg+rdXQvFm
|
||||
Jqou9lUPonNtOkUlgjl+qfAnK5q0KxvHSgxoWYO1bLOuAybQlbuBmSCPcKd5MMa9
|
||||
f/eRN9YetfVQ83Mz6YshBDJ22EFRUz+p7eeIY6dFp/PCvmO8Gq/qlA996dglBtmf
|
||||
RuG+T0vQT0mZgbWaGuBHfkwFAoGBAMOLg1MRxgKRMKavk6pU3EfyP3+J5XemWCDI
|
||||
1WLtbgV5uClNmzmxBBGypQHs7jbzKPtHpULn5kB+HzdVb0clG8ZDsK7u6s5OF0pO
|
||||
sBS+oVl7rF/eSeFcFhUYP26ZhsbWo3z/bERuj926VO2AxDPRTsP5o3pQPGZhY0V9
|
||||
irGgbUJrAoGBAOseS3J4BqYM4R3Hr7cRAhvzSjIkeTcDF1zTOa4FZDHBxZ6g2PNq
|
||||
8ekhtfn1zPczsPTF1vNuqEISKLxaPkVPiw0mtaZQjVwpF/IOxMNjWVLp6oJf8Mm2
|
||||
BxlXqPnQ4mG66oqSFQgDEmFdMhRb2of6xL1gYYL62C80G2T7QtmPfSab
|
||||
-----END RSA PRIVATE KEY-----
|
||||
";
|
||||
|
||||
const string SampleAdpToken = "{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}";
|
||||
|
||||
const string DemoAccountSuffix = "@example.com";
|
||||
const string DemoAccountPrefix = "demo";
|
||||
|
||||
var clean = args.Contains("--clean", StringComparer.OrdinalIgnoreCase);
|
||||
var count = IntArg("--count") ?? 1;
|
||||
var pathArg = args.FirstOrDefault(a => !a.StartsWith("--") && !int.TryParse(a, out _));
|
||||
|
||||
if (count < 1)
|
||||
{
|
||||
Console.Error.WriteLine("--count must be at least 1.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (FindLibationFiles(pathArg) is not string libationFiles)
|
||||
{
|
||||
Console.Error.WriteLine("Could not find a Libation accounts file.");
|
||||
Console.Error.WriteLine("Run Libation once to create it, then pass its folder, e.g.:");
|
||||
Console.Error.WriteLine(@" dotnet run Scripts/seed-demo-accounts.cs C:\Users\me\AppData\Local\Libation");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var accountsPath = Path.Combine(libationFiles, "AccountsSettings.json");
|
||||
Console.WriteLine($"Accounts file: {accountsPath}");
|
||||
|
||||
var root = JObject.Parse(File.ReadAllText(accountsPath));
|
||||
if (root["Accounts"] is not JArray accounts)
|
||||
{
|
||||
accounts = [];
|
||||
root["Accounts"] = accounts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this script wrote the account, decided from its id rather than a marker property.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A property would be tidier but does not survive: AudibleUtilities.Account serializes exactly five members
|
||||
/// and has no [JsonExtensionData], so Newtonsoft ignores an extra property on the way in and never writes it
|
||||
/// back out. Verified by adding one and renaming the account in the app - the save dropped it. Anything that
|
||||
/// dirties an account does the same, including a token refresh during a scan, and --clean would then leave
|
||||
/// demo accounts behind with no way to find them. Giving the shipping Account class a demo flag to serve a
|
||||
/// dev script is not a trade worth making.
|
||||
///
|
||||
/// The id is the right anchor instead: it is immutable, always persisted, and example.com is reserved by
|
||||
/// RFC 2606, so no real Audible login can collide with it.
|
||||
/// </remarks>
|
||||
static bool IsDemo(JToken account)
|
||||
=> account["AccountId"]?.Value<string>() is string id
|
||||
&& id.StartsWith(DemoAccountPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& id.EndsWith(DemoAccountSuffix, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (clean)
|
||||
{
|
||||
var removed = accounts.Where(IsDemo).ToList();
|
||||
foreach (var account in removed)
|
||||
account.Remove();
|
||||
|
||||
File.WriteAllText(accountsPath, root.ToString(Formatting.Indented));
|
||||
Console.WriteLine($"Removed {removed.Count} demo account(s). {accounts.Count} account(s) remain.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var realAccounts = accounts.Count(a => !IsDemo(a));
|
||||
if (realAccounts > 0)
|
||||
Console.WriteLine($"Leaving {realAccounts} real account(s) untouched.");
|
||||
|
||||
var added = 0;
|
||||
foreach (var accountId in DemoAccountIds(count))
|
||||
{
|
||||
if (accounts.Any(a => string.Equals(a["AccountId"]?.Value<string>(), accountId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Console.WriteLine($" {accountId} already present, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
accounts.Add(BuildAccount(accountId));
|
||||
added++;
|
||||
}
|
||||
|
||||
File.WriteAllText(accountsPath, root.ToString(Formatting.Indented));
|
||||
|
||||
Console.WriteLine($"Added {added} demo account(s). Libation will now see {accounts.Count} account(s).");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Start Libation. What the account count changes:");
|
||||
Console.WriteLine(" 1 account Import > Scan Library, Import > Remove Library Books");
|
||||
Console.WriteLine(" 2 or more Import > Scan Library of All / Some Accounts, and the matching Remove items");
|
||||
Console.WriteLine(" any an empty library offers 'Scan Library' rather than 'Add Account'");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Do not scan or download: these accounts have meaningless tokens and Audible will refuse them.");
|
||||
return 0;
|
||||
|
||||
/// <summary>
|
||||
/// The first account matches the one seed-demo-library.cs assigns its books to, so seeded books and a
|
||||
/// seeded account describe the same library rather than two unrelated ones.
|
||||
/// </summary>
|
||||
static IEnumerable<string> DemoAccountIds(int count)
|
||||
{
|
||||
yield return $"{DemoAccountPrefix}{DemoAccountSuffix}";
|
||||
|
||||
for (var n = 2; n <= count; n++)
|
||||
yield return $"{DemoAccountPrefix}{n}{DemoAccountSuffix}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An account entry carrying a registered-looking identity. The values are fixture material, the same shape
|
||||
/// AudibleApi writes after a real registration, so the file parses and the account loads.
|
||||
/// </summary>
|
||||
static JObject BuildAccount(string accountId)
|
||||
{
|
||||
var identity = new Identity(Localization.Get("us"));
|
||||
identity.Update(
|
||||
new PrivateKey(SamplePrivateKey),
|
||||
new AdpToken(SampleAdpToken),
|
||||
new AccessToken($"Atna|_DEMO_ACCESS_{accountId}", new DateTime(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc)),
|
||||
new RefreshToken($"Atnr|_DEMO_REFRESH_{accountId}"),
|
||||
[new KeyValuePair<string, Dinah.Core.Security.SecretString>("session-id", "demo-cookie-value")],
|
||||
deviceSerialNumber: "demo-device-serial",
|
||||
deviceType: "demo-device-type",
|
||||
amazonAccountId: "demo-amazon-account",
|
||||
deviceName: "Demo Device",
|
||||
storeAuthenticationCookie: "demo-store-auth-cookie");
|
||||
|
||||
// Write the tokens as plaintext rather than reaching for the OS secret store, which this script has no
|
||||
// business touching. Libation reads either, whatever its own Token storage preference is set to.
|
||||
IdentityTokenStorage.Configure(TokenStorageMethod.Plaintext, protector: null);
|
||||
|
||||
return new JObject
|
||||
{
|
||||
["AccountId"] = accountId,
|
||||
["AccountName"] = $"Demo ({accountId})",
|
||||
["LibraryScan"] = true,
|
||||
["DecryptKey"] = "",
|
||||
["IdentityTokens"] = JObject.Parse(JsonConvert.SerializeObject(identity, Identity.GetJsonSerializerSettings()))
|
||||
};
|
||||
}
|
||||
|
||||
int? IntArg(string name)
|
||||
{
|
||||
var i = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
return i >= 0 && i + 1 < args.Length && int.TryParse(args[i + 1], out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>Locate the Libation files folder the same way Libation itself does.</summary>
|
||||
static string? FindLibationFiles(string? explicitPath)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
|
||||
if (explicitPath is not null)
|
||||
candidates.Add(explicitPath);
|
||||
|
||||
foreach (var folder in KnownLibationFolders())
|
||||
{
|
||||
candidates.Add(folder);
|
||||
|
||||
// appsettings.json may redirect the files folder somewhere else entirely.
|
||||
var appSettings = Path.Combine(folder, "appsettings.json");
|
||||
if (!File.Exists(appSettings))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
if (System.Text.Json.JsonDocument.Parse(File.ReadAllText(appSettings)).RootElement
|
||||
.TryGetProperty("LibationFiles", out var redirect) && redirect.GetString() is string path)
|
||||
candidates.Add(path);
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { }
|
||||
}
|
||||
|
||||
return candidates.FirstOrDefault(c => File.Exists(Path.Combine(c, "AccountsSettings.json")));
|
||||
}
|
||||
|
||||
static IEnumerable<string> KnownLibationFolders()
|
||||
{
|
||||
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation");
|
||||
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation");
|
||||
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Libation");
|
||||
yield return Path.Combine(Path.GetTempPath(), $"Libation-{Environment.UserName}");
|
||||
yield return AppContext.BaseDirectory;
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
#:package Microsoft.Data.Sqlite@10.0.7
|
||||
|
||||
using Microsoft.Data.Sqlite;
|
||||
using System.Text.Json;
|
||||
|
||||
// Seeds a Libation library with one book per Liberate-column state, plus a few titles that
|
||||
// subtitle removal changes, for manual UI testing.
|
||||
//
|
||||
// dotnet run Scripts/seed-demo-library.cs [--clean] [path-to-Libation-files-folder]
|
||||
//
|
||||
// Run Libation at least once first so the database exists, and close it before seeding.
|
||||
// Pass --clean to delete previously seeded demo books instead of adding them.
|
||||
//
|
||||
// Reproducing every icon by hand is tedious, and two of them are not database state at all:
|
||||
// the yellow lamp means "a partial download is sitting on disk", so this writes placeholder
|
||||
// .aaxc files as well.
|
||||
//
|
||||
// Writing rows directly also leaves Libation's search index stale, which makes every filter on the
|
||||
// seeded books come back empty, so this deletes the index and lets Libation rebuild it.
|
||||
|
||||
const int NotLiberated = 0, Liberated = 1, Error = 2;
|
||||
const int Product = 1, Episode = 2, Parent = 4;
|
||||
const string AsinPrefix = "DEMO";
|
||||
|
||||
var clean = args.Contains("--clean", StringComparer.OrdinalIgnoreCase);
|
||||
var pathArg = args.FirstOrDefault(a => !a.StartsWith("--"));
|
||||
|
||||
if (FindLibationFiles(pathArg) is not string libationFiles)
|
||||
{
|
||||
Console.Error.WriteLine("Could not find a Libation database.");
|
||||
Console.Error.WriteLine("Run Libation once to create it, then pass its folder, e.g.:");
|
||||
Console.Error.WriteLine(@" dotnet run Scripts/seed-demo-library.cs C:\Users\me\AppData\Local\Libation");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var dbPath = Path.Combine(libationFiles, "LibationContext.db");
|
||||
Console.WriteLine($"Database: {dbPath}");
|
||||
|
||||
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ToString());
|
||||
connection.Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
if (clean)
|
||||
{
|
||||
// Books cascade to UserDefinedItem, LibraryBooks, Supplement, BookContributor and SeriesBook,
|
||||
// but the Series and Contributors rows those pointed at are left behind.
|
||||
var removed = Execute($"delete from Books where AudibleProductId like '{AsinPrefix}%'");
|
||||
Execute($"delete from Series where AudibleSeriesId like '{AsinPrefix}%' and SeriesId not in (select SeriesId from SeriesBook)");
|
||||
Execute("delete from Contributors where AudibleContributorId = 'DEMOAUTH' and ContributorId not in (select ContributorId from BookContributor)");
|
||||
transaction.Commit();
|
||||
|
||||
foreach (var partial in Directory.Exists(DownloadsInProgress())
|
||||
? Directory.EnumerateFiles(DownloadsInProgress(), $"{AsinPrefix}*.aaxc")
|
||||
: [])
|
||||
File.Delete(partial);
|
||||
|
||||
Console.WriteLine($"Removed {removed} demo book(s) and their placeholder downloads.");
|
||||
InvalidateSearchIndex();
|
||||
return 0;
|
||||
}
|
||||
|
||||
var books = BuildDemoBooks();
|
||||
var contributorId = GetOrCreateId(
|
||||
"select ContributorId from Contributors where AudibleContributorId = $id",
|
||||
"insert into Contributors (Name, AudibleContributorId) values ('Demo Author', $id)",
|
||||
"DEMOAUTH");
|
||||
|
||||
var added = 0;
|
||||
var partials = new List<string>();
|
||||
|
||||
foreach (var book in books)
|
||||
{
|
||||
if (Scalar("select count(*) from Books where AudibleProductId = $asin", ("$asin", book.Asin)) > 0)
|
||||
{
|
||||
Console.WriteLine($" {book.Asin} already present, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
Execute(
|
||||
"""
|
||||
insert into Books
|
||||
(AudibleProductId, ContentType, Description, IsAbridged, IsSpatial, LengthInMinutes,
|
||||
Locale, Rating_OverallRating, Rating_PerformanceRating, Rating_StoryRating, Subtitle, Title)
|
||||
values ($asin, $contentType, 'Seeded by seed-demo-library.cs', 0, 0, 600, 'us', 0, 0, 0, $subtitle, $title)
|
||||
""",
|
||||
("$asin", book.Asin), ("$contentType", book.ContentType), ("$title", book.Title), ("$subtitle", book.Subtitle));
|
||||
|
||||
var bookId = Scalar("select BookId from Books where AudibleProductId = $asin", ("$asin", book.Asin));
|
||||
|
||||
// PartialDownload is never persisted - the BookStatus setter rewrites it to NotLiberated,
|
||||
// because it is derived from a file on disk instead.
|
||||
Execute(
|
||||
"""
|
||||
insert into UserDefinedItem
|
||||
(BookId, BookStatus, IsFinished, PdfStatus, Rating_OverallRating,
|
||||
Rating_PerformanceRating, Rating_StoryRating, Tags)
|
||||
values ($bookId, $bookStatus, 0, $pdfStatus, 0, 0, 0, '')
|
||||
""",
|
||||
("$bookId", bookId), ("$bookStatus", book.BookStatus), ("$pdfStatus", (object?)book.PdfStatus ?? DBNull.Value));
|
||||
|
||||
Execute(
|
||||
"""
|
||||
insert into LibraryBooks (BookId, AbsentFromLastScan, Account, DateAdded, IncludedUntil, IsAudiblePlus, IsDeleted)
|
||||
values ($bookId, $isAbsent, 'demo@example.com', '2026-08-13 00:00:00', $includedUntil, $isPlus, $isDeleted)
|
||||
""",
|
||||
("$bookId", bookId),
|
||||
("$isAbsent", book.IsAbsent ? 1 : 0),
|
||||
("$includedUntil", book.IsPlus ? "2027-01-31 00:00:00" : (object)DBNull.Value),
|
||||
("$isPlus", book.IsPlus ? 1 : 0),
|
||||
("$isDeleted", book.IsDeleted ? 1 : 0));
|
||||
|
||||
Execute(
|
||||
"insert into BookContributor (BookId, ContributorId, Role, [Order]) values ($bookId, $contributorId, 1, 0)",
|
||||
("$bookId", bookId), ("$contributorId", contributorId));
|
||||
|
||||
// The PDF glyph itself keys off UserDefinedItem.PdfStatus, but Book.HasPdf - which drives the
|
||||
// context menu and the "Has PDF" detail - needs an actual supplement.
|
||||
if (book.PdfStatus is not null)
|
||||
Execute(
|
||||
"insert into Supplement (BookId, Url) values ($bookId, 'https://example.com/demo.pdf')",
|
||||
("$bookId", bookId));
|
||||
|
||||
if (book.SeriesAsin is not null)
|
||||
{
|
||||
var seriesId = GetOrCreateId(
|
||||
"select SeriesId from Series where AudibleSeriesId = $id",
|
||||
"insert into Series (AudibleSeriesId, Name) values ($id, 'Demo Series')",
|
||||
book.SeriesAsin);
|
||||
|
||||
Execute(
|
||||
"insert into SeriesBook (SeriesId, BookId, [Order]) values ($seriesId, $bookId, $order)",
|
||||
("$seriesId", seriesId), ("$bookId", bookId), ("$order", (object?)book.SeriesOrder ?? DBNull.Value));
|
||||
}
|
||||
|
||||
if (book.NeedsPartialDownload)
|
||||
partials.Add(book.Asin);
|
||||
|
||||
added++;
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
Console.WriteLine($"Added {added} book(s).");
|
||||
InvalidateSearchIndex();
|
||||
|
||||
if (partials.Count > 0)
|
||||
{
|
||||
var inProgress = DownloadsInProgress();
|
||||
Directory.CreateDirectory(inProgress);
|
||||
foreach (var asin in partials)
|
||||
File.WriteAllText(Path.Combine(inProgress, $"{asin}.aaxc"), "not a real download");
|
||||
|
||||
Console.WriteLine($"Wrote {partials.Count} placeholder download(s) to {inProgress}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Filtering covers these books. Try HasSubtitle , TitleHasColon , or Trashed for no matches.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Start Libation and sort by Title. Expected Liberate column, top to bottom:");
|
||||
foreach (var book in books.Where(b => !b.IsDeleted))
|
||||
Console.WriteLine($" {book.Title,-46} {book.Expectation}");
|
||||
|
||||
var trashed = books.Where(b => b.IsDeleted).ToList();
|
||||
if (trashed.Count > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"{trashed.Count} seeded book(s) are in the trash, so they are NOT in the grid:");
|
||||
foreach (var book in trashed)
|
||||
Console.WriteLine($" {book.Title,-46} {book.Expectation}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" - the status bar should end with a clickable \"N in trash\"");
|
||||
Console.WriteLine(" - Settings > Trash Bin should carry the same count");
|
||||
Console.WriteLine(" - filtering for Trashed should find nothing and offer to open the trash bin");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Do not click the stoplights: these books are not real and cannot be downloaded.");
|
||||
return 0;
|
||||
|
||||
static List<DemoBook> BuildDemoBooks()
|
||||
{
|
||||
var books = new List<DemoBook>();
|
||||
var n = 0;
|
||||
|
||||
// The whole lamp/PDF/Plus matrix, so every stoplight rendering is on screen at once.
|
||||
foreach (var (lamp, bookStatus, needsPartial) in new[]
|
||||
{
|
||||
("Green", Liberated, false),
|
||||
("Yellow", NotLiberated, true),
|
||||
("Red", NotLiberated, false),
|
||||
})
|
||||
foreach (var (pdfLabel, pdfStatus) in new (string, int?)[]
|
||||
{
|
||||
("no PDF", null),
|
||||
("PDF done", Liberated),
|
||||
("PDF todo", NotLiberated),
|
||||
})
|
||||
foreach (var isPlus in new[] { false, true })
|
||||
{
|
||||
var owner = isPlus ? "PLUS" : "purchased";
|
||||
books.Add(new DemoBook(
|
||||
Asin: $"{AsinPrefix}{++n:000}",
|
||||
Title: $"{n:00} {lamp} | {pdfLabel} | {owner}",
|
||||
ContentType: Product,
|
||||
BookStatus: bookStatus,
|
||||
PdfStatus: pdfStatus,
|
||||
IsPlus: isPlus,
|
||||
NeedsPartialDownload: needsPartial,
|
||||
Expectation: $"{lamp} lamp, {pdfLabel}, {(isPlus ? "orange PLUS badge" : "NO badge")}"));
|
||||
}
|
||||
|
||||
// The error icon replaces the stoplight entirely, and ignores both PDF and Plus.
|
||||
books.Add(new DemoBook(
|
||||
Asin: $"{AsinPrefix}{++n:000}",
|
||||
Title: $"{n:00} Error | purchased",
|
||||
ContentType: Product,
|
||||
BookStatus: Error,
|
||||
PdfStatus: null,
|
||||
IsPlus: false,
|
||||
NeedsPartialDownload: false,
|
||||
Expectation: "red error sign, no stoplight"));
|
||||
|
||||
books.Add(new DemoBook(
|
||||
Asin: $"{AsinPrefix}{++n:000}",
|
||||
Title: $"{n:00} Error | PLUS",
|
||||
ContentType: Product,
|
||||
BookStatus: Error,
|
||||
PdfStatus: null,
|
||||
IsPlus: true,
|
||||
NeedsPartialDownload: false,
|
||||
Expectation: "red error sign, still NO badge"));
|
||||
|
||||
// A podcast: the parent row shows a plus/minus expander rather than a stoplight, and its
|
||||
// episodes show ordinary stoplights. Libation identifies a podcast's series by the parent
|
||||
// book's own ASIN, and hides any series whose children it cannot match, so the episodes have
|
||||
// to point at that same ASIN or the parent row silently disappears.
|
||||
var seriesAsin = $"{AsinPrefix}{++n:000}";
|
||||
books.Add(new DemoBook(
|
||||
Asin: seriesAsin,
|
||||
Title: $"{n:00} Demo Series (parent)",
|
||||
ContentType: Parent,
|
||||
BookStatus: NotLiberated,
|
||||
PdfStatus: null,
|
||||
IsPlus: false,
|
||||
NeedsPartialDownload: false,
|
||||
Expectation: "square plus/minus expander, never a badge",
|
||||
SeriesAsin: seriesAsin));
|
||||
|
||||
foreach (var (episode, bookStatus, isPlus) in new[]
|
||||
{
|
||||
(1, Liberated, false),
|
||||
(2, NotLiberated, true),
|
||||
})
|
||||
books.Add(new DemoBook(
|
||||
Asin: $"{AsinPrefix}{++n:000}",
|
||||
Title: $"{n:00} Demo Series - episode {episode}",
|
||||
ContentType: Episode,
|
||||
BookStatus: bookStatus,
|
||||
PdfStatus: null,
|
||||
IsPlus: isPlus,
|
||||
NeedsPartialDownload: false,
|
||||
Expectation: $"{(bookStatus == Liberated ? "green" : "red")} lamp, {(isPlus ? "orange PLUS badge" : "NO badge")}",
|
||||
SeriesAsin: seriesAsin,
|
||||
SeriesOrder: episode.ToString()));
|
||||
|
||||
// Books missing from the last scan. These keep their ordinary icon - there is no "unavailable"
|
||||
// glyph - and the grid dims and disables the row around it instead, so they are the rows to
|
||||
// check when an icon change might read badly under that treatment.
|
||||
//
|
||||
// Being absent is not enough on its own: a book which is fully liberated with nothing left to
|
||||
// fetch stays available, because there is nothing the row would do if you clicked it.
|
||||
foreach (var (label, bookStatus, pdfStatus, isPlus, expectation) in new (string, int, int?, bool, string)[]
|
||||
{
|
||||
("not downloaded | purchased", NotLiberated, null, false, "red lamp, dimmed and unclickable"),
|
||||
("not downloaded | PLUS", NotLiberated, null, true, "red lamp with badge, dimmed and unclickable"),
|
||||
("downloaded | purchased", Liberated, null, false, "green lamp, NOT dimmed - nothing left to fetch"),
|
||||
("downloaded, PDF todo | purchased", Liberated, NotLiberated, false, "green lamp with PDF arrow, dimmed again - the PDF is outstanding"),
|
||||
})
|
||||
books.Add(new DemoBook(
|
||||
Asin: $"{AsinPrefix}{++n:000}",
|
||||
Title: $"{n:00} Absent | {label}",
|
||||
ContentType: Product,
|
||||
BookStatus: bookStatus,
|
||||
PdfStatus: pdfStatus,
|
||||
IsPlus: isPlus,
|
||||
NeedsPartialDownload: false,
|
||||
Expectation: expectation,
|
||||
IsAbsent: true));
|
||||
|
||||
// Books in the trash. Removal is a soft delete, and a trashed book is filtered out of the grid, the
|
||||
// search index and every status count at once, so nothing on screen distinguishes one from a book that
|
||||
// was never imported. These rows are how that is checked: they must stay out of the grid while the
|
||||
// status bar, the Settings > Trash Bin menu item and the empty-search hint all account for them.
|
||||
foreach (var (label, bookStatus, isPlus, expectation) in new (string, int, bool, string)[]
|
||||
{
|
||||
("purchased", NotLiberated, false, "in the trash bin only, red lamp there"),
|
||||
("PLUS", Liberated, true, "in the trash bin only, green lamp with badge there"),
|
||||
})
|
||||
books.Add(new DemoBook(
|
||||
Asin: $"{AsinPrefix}{++n:000}",
|
||||
Title: $"{n:00} Trashed | {label}",
|
||||
ContentType: Product,
|
||||
BookStatus: bookStatus,
|
||||
PdfStatus: null,
|
||||
IsPlus: isPlus,
|
||||
NeedsPartialDownload: false,
|
||||
Expectation: expectation,
|
||||
IsDeleted: true));
|
||||
|
||||
// A trashed episode whose parent is still in the library. The trash bin asks for every parent, not just
|
||||
// deleted ones, so this should appear nested under the series there while the series keeps its other
|
||||
// episodes in the main grid.
|
||||
books.Add(new DemoBook(
|
||||
Asin: $"{AsinPrefix}{++n:000}",
|
||||
Title: $"{n:00} Demo Series - episode 3 (trashed)",
|
||||
ContentType: Episode,
|
||||
BookStatus: NotLiberated,
|
||||
PdfStatus: null,
|
||||
IsPlus: false,
|
||||
NeedsPartialDownload: false,
|
||||
Expectation: "nested under Demo Series in the trash bin, absent from the grid",
|
||||
SeriesAsin: seriesAsin,
|
||||
SeriesOrder: "3",
|
||||
IsDeleted: true));
|
||||
|
||||
// Titles that subtitle removal changes. <title short> stops at the first colon, so it cuts a colon in
|
||||
// Audible's own title just as readily as it drops Audible's subtitle field, and the grid's Title column
|
||||
// shows both the same way. The HasSubtitle and TitleHasColon filters are the only way to tell them apart.
|
||||
foreach (var (title, subtitle, match) in new[]
|
||||
{
|
||||
("A Book Series Omnibus", "Volume One", "HasSubtitle"),
|
||||
("A Book Series Omnibus", "Volume Two", "HasSubtitle"),
|
||||
("Star Trek: The Next Generation", "", "TitleHasColon"),
|
||||
("Dune: Book One", "The Graphic Novel", "both HasSubtitle and TitleHasColon"),
|
||||
})
|
||||
books.Add(new DemoBook(
|
||||
Asin: $"{AsinPrefix}{++n:000}",
|
||||
Title: title,
|
||||
ContentType: Product,
|
||||
BookStatus: NotLiberated,
|
||||
PdfStatus: null,
|
||||
IsPlus: false,
|
||||
NeedsPartialDownload: false,
|
||||
Expectation: $"red lamp; filter match: {match}",
|
||||
Subtitle: subtitle));
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
/// <summary>Locate the Libation files folder the same way Libation itself does.</summary>
|
||||
static string? FindLibationFiles(string? explicitPath)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
|
||||
if (explicitPath is not null)
|
||||
candidates.Add(explicitPath);
|
||||
|
||||
foreach (var folder in KnownLibationFolders())
|
||||
{
|
||||
candidates.Add(folder);
|
||||
|
||||
// appsettings.json may redirect the files folder somewhere else entirely.
|
||||
var appSettings = Path.Combine(folder, "appsettings.json");
|
||||
if (!File.Exists(appSettings))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
if (JsonDocument.Parse(File.ReadAllText(appSettings)).RootElement
|
||||
.TryGetProperty("LibationFiles", out var redirect) && redirect.GetString() is string path)
|
||||
candidates.Add(path);
|
||||
}
|
||||
catch (JsonException) { }
|
||||
}
|
||||
|
||||
return candidates.FirstOrDefault(c => File.Exists(Path.Combine(c, "LibationContext.db")));
|
||||
}
|
||||
|
||||
static IEnumerable<string> KnownLibationFolders()
|
||||
{
|
||||
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation");
|
||||
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation");
|
||||
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Libation");
|
||||
yield return Path.Combine(Path.GetTempPath(), $"Libation-{Environment.UserName}");
|
||||
yield return AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
/// <summary>Where Libation looks for resumable downloads, honouring the InProgress setting.</summary>
|
||||
string DownloadsInProgress()
|
||||
{
|
||||
var inProgress = Path.Combine(Path.GetTempPath(), $"Libation-{Environment.UserName}");
|
||||
|
||||
var settings = Path.Combine(libationFiles, "Settings.json");
|
||||
if (File.Exists(settings))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (JsonDocument.Parse(File.ReadAllText(settings)).RootElement
|
||||
.TryGetProperty("InProgress", out var configured)
|
||||
&& configured.GetString() is string path && !string.IsNullOrWhiteSpace(path))
|
||||
inProgress = path;
|
||||
}
|
||||
catch (JsonException) { }
|
||||
}
|
||||
|
||||
return Path.Combine(inProgress, "DownloadsInProgress");
|
||||
}
|
||||
|
||||
/// <summary>Delete the Lucene index so Libation rebuilds it from the database.</summary>
|
||||
/// <remarks>
|
||||
/// This script writes rows straight to SQLite, which leaves the index untouched, so every filter on the
|
||||
/// main grid came back empty no matter what had been seeded. Libation treats a missing index as a full
|
||||
/// re-index rather than an error, so deleting the folder is enough: the first search rebuilds it from the
|
||||
/// library, and the rebuild excludes trashed books the same way the grid does.
|
||||
/// </remarks>
|
||||
void InvalidateSearchIndex()
|
||||
{
|
||||
var searchEngine = Path.Combine(libationFiles, "SearchEngine");
|
||||
if (!Directory.Exists(searchEngine))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(searchEngine, recursive: true);
|
||||
Console.WriteLine("Deleted the search index. Libation rebuilds it on the first search or filter.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Console.Error.WriteLine($"Could not delete the search index at {searchEngine}: {ex.Message}");
|
||||
Console.Error.WriteLine("Close Libation and delete that folder by hand, or filtering will not find the seeded books.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Look up a row's id by its Audible id, inserting it first if it isn't there yet.</summary>
|
||||
/// <remarks>
|
||||
/// Neither Contributors.AudibleContributorId nor Series.AudibleSeriesId is unique, so an upsert
|
||||
/// would happily insert a duplicate every time this script runs.
|
||||
/// </remarks>
|
||||
int GetOrCreateId(string selectSql, string insertSql, string audibleId)
|
||||
{
|
||||
using (var select = Command(selectSql, [("$id", audibleId)]))
|
||||
if (select.ExecuteScalar() is object existing and not DBNull)
|
||||
return Convert.ToInt32(existing);
|
||||
|
||||
Execute(insertSql, ("$id", audibleId));
|
||||
return Scalar("select last_insert_rowid()");
|
||||
}
|
||||
|
||||
int Scalar(string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
using var command = Command(sql, parameters);
|
||||
return Convert.ToInt32(command.ExecuteScalar());
|
||||
}
|
||||
|
||||
int Execute(string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
using var command = Command(sql, parameters);
|
||||
return command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
SqliteCommand Command(string sql, (string Name, object Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
foreach (var (name, value) in parameters)
|
||||
command.Parameters.AddWithValue(name, value);
|
||||
return command;
|
||||
}
|
||||
|
||||
record DemoBook(
|
||||
string Asin,
|
||||
string Title,
|
||||
int ContentType,
|
||||
int BookStatus,
|
||||
int? PdfStatus,
|
||||
bool IsPlus,
|
||||
bool NeedsPartialDownload,
|
||||
string Expectation,
|
||||
string? SeriesAsin = null,
|
||||
string? SeriesOrder = null,
|
||||
bool IsAbsent = false,
|
||||
string Subtitle = "",
|
||||
bool IsDeleted = false);
|
||||
@@ -0,0 +1,116 @@
|
||||
#:package Microsoft.Data.Sqlite@10.0.7
|
||||
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
// Seeds Libation's DownloadHistory table with fake completed downloads, for manual testing of the
|
||||
// daily download limit without downloading anything.
|
||||
//
|
||||
// dotnet run Scripts/seed-download-history.cs [--count 50] [--age-seconds 7200] [--owned]
|
||||
// [--mb 300] [--clean] [path-to-Libation-files-folder]
|
||||
//
|
||||
// The limit uses a rolling 24 hour window, so --age-seconds controls how soon the rows fall out of it:
|
||||
//
|
||||
// --age-seconds 7200 two hours ago: the limit stays reached for the next 22 hours
|
||||
// --age-seconds 86325 just under 24 hours ago: a paused queue resumes itself about a minute from now
|
||||
//
|
||||
// Pass --clean to delete all rows. Libation can be running; each row is just a row.
|
||||
|
||||
const string FakeAsinPrefix = "FAKE";
|
||||
|
||||
var clean = args.Contains("--clean", StringComparer.OrdinalIgnoreCase);
|
||||
var owned = args.Contains("--owned", StringComparer.OrdinalIgnoreCase);
|
||||
var count = IntArg("--count") ?? 50;
|
||||
var ageSeconds = IntArg("--age-seconds") ?? 7200;
|
||||
var megabytes = IntArg("--mb") ?? 300;
|
||||
var pathArg = args.FirstOrDefault(a => !a.StartsWith("--") && !int.TryParse(a, out _));
|
||||
|
||||
if (FindLibationFiles(pathArg) is not string libationFiles)
|
||||
{
|
||||
Console.Error.WriteLine("Could not find a Libation database.");
|
||||
Console.Error.WriteLine("Run Libation once to create it, then pass its folder, e.g.:");
|
||||
Console.Error.WriteLine(@" dotnet run Scripts/seed-download-history.cs C:\Users\me\AppData\Local\Libation");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var dbPath = Path.Combine(libationFiles, "LibationContext.db");
|
||||
Console.WriteLine($"Database: {dbPath}");
|
||||
|
||||
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ToString());
|
||||
connection.Open();
|
||||
|
||||
if (!TableExists())
|
||||
{
|
||||
Console.Error.WriteLine("This database has no DownloadHistory table yet. Start Libation once so it applies migrations, then retry.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (clean)
|
||||
{
|
||||
Console.WriteLine($"Removed {Execute("delete from DownloadHistory")} download history row(s).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Stored as UTC ticks so the rolling window is exact on every provider and across DST changes.
|
||||
var completedAt = DateTimeOffset.Now.AddSeconds(-ageSeconds);
|
||||
var bytes = megabytes * 1024L * 1024;
|
||||
|
||||
using var transaction = connection.BeginTransaction();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
Execute(
|
||||
"""
|
||||
insert into DownloadHistory (CompletedAtUtcTicks, AudibleProductId, IsAudiblePlus, Bytes)
|
||||
values ($ticks, $asin, $isPlus, $bytes)
|
||||
""",
|
||||
("$ticks", completedAt.AddSeconds(i).UtcTicks),
|
||||
("$asin", $"{FakeAsinPrefix}{i:0000}"),
|
||||
("$isPlus", owned ? 0 : 1),
|
||||
("$bytes", bytes));
|
||||
}
|
||||
transaction.Commit();
|
||||
|
||||
Console.WriteLine(
|
||||
$"Inserted {count} {(owned ? "purchased" : "Audible Plus")} download(s) of {megabytes} MB each, " +
|
||||
$"completed {TimeSpan.FromSeconds(ageSeconds):g} ago.");
|
||||
Console.WriteLine($"Total rows: {Scalar("select count(*) from DownloadHistory")}");
|
||||
Console.WriteLine($"The oldest leaves the 24 hour window at {completedAt.AddHours(24):yyyy-MM-dd HH:mm:ss}");
|
||||
return 0;
|
||||
|
||||
int? IntArg(string name)
|
||||
{
|
||||
var index = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
return index >= 0 && index + 1 < args.Length && int.TryParse(args[index + 1], out var value) ? value : null;
|
||||
}
|
||||
|
||||
bool TableExists()
|
||||
=> Scalar("select count(*) from sqlite_master where type = 'table' and name = 'DownloadHistory'") > 0;
|
||||
|
||||
int Execute(string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
foreach (var (name, value) in parameters)
|
||||
command.Parameters.AddWithValue(name, value);
|
||||
return command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
long Scalar(string sql)
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
return Convert.ToInt64(command.ExecuteScalar());
|
||||
}
|
||||
|
||||
static string? FindLibationFiles(string? explicitPath)
|
||||
{
|
||||
if (explicitPath is not null)
|
||||
return File.Exists(Path.Combine(explicitPath, "LibationContext.db")) ? explicitPath : null;
|
||||
|
||||
string[] candidates =
|
||||
[
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"),
|
||||
];
|
||||
|
||||
return candidates.FirstOrDefault(c => File.Exists(Path.Combine(c, "LibationContext.db")));
|
||||
}
|
||||
@@ -21,4 +21,8 @@
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -179,13 +179,17 @@ public class NetworkFileStream : Stream, IUpdatable
|
||||
DownloadTask = Task.Run(() => DownloadLoopInternal(client, response), _cancellationSource.Token);
|
||||
}
|
||||
|
||||
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
|
||||
// avoid infinite retry loops if the server consistently fails for a particular title
|
||||
private const int MAX_TLS_RETRIES = 5;
|
||||
|
||||
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
|
||||
{
|
||||
try
|
||||
{
|
||||
long startPosition = WritePosition;
|
||||
var startPosition = WritePosition;
|
||||
|
||||
while (WritePosition < ContentLength && !IsCancelled)
|
||||
var tlsRetryCount = 0;
|
||||
while (WritePosition < ContentLength && !IsCancelled)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -206,8 +210,22 @@ public class NetworkFileStream : Stream, IUpdatable
|
||||
blockResponse = await RequestNextByteRangeAsync(client);
|
||||
|
||||
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException e)
|
||||
when (e.InnerException is System.ComponentModel.Win32Exception { NativeErrorCode: -2146893008 }
|
||||
&& WritePosition != startPosition
|
||||
&& WritePosition < ContentLength && !IsCancelled
|
||||
&& ++tlsRetryCount <= MAX_TLS_RETRIES)
|
||||
{
|
||||
Serilog.Log.Logger.Warning($"TLS decryption failure at position 0x{WritePosition:X10}. Reconnecting and resuming download.");
|
||||
|
||||
_writeFile.Position = startPosition = WritePosition;
|
||||
blockResponse.Dispose();
|
||||
blockResponse = await RequestNextByteRangeAsync(client);
|
||||
|
||||
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>13.7.5</Version>
|
||||
<Version>14.2.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
@@ -16,6 +16,9 @@
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
|
||||
@@ -94,6 +94,7 @@ public static class LibationScaffolding
|
||||
{
|
||||
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
|
||||
}
|
||||
config.ValidateEnumSettings();
|
||||
DeleteOpenSqliteFiles(config);
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
IdentityTokenStorageWiring.Apply(config);
|
||||
@@ -102,9 +103,6 @@ public static class LibationScaffolding
|
||||
// migrations go below here
|
||||
//
|
||||
|
||||
Migrations.migrate_to_v6_6_9(config);
|
||||
Migrations.migrate_to_v11_5_0(config);
|
||||
Migrations.migrate_to_v11_6_5(config);
|
||||
Migrations.migrate_to_v12_0_1(config);
|
||||
}
|
||||
|
||||
@@ -115,29 +113,74 @@ public static class LibationScaffolding
|
||||
/// </summary>
|
||||
private static void DeleteOpenSqliteFiles(Configuration config)
|
||||
{
|
||||
var walFile = SqliteStorage.DatabasePath + "-wal";
|
||||
var shmFile = SqliteStorage.DatabasePath + "-shm";
|
||||
var dbFile = SqliteStorage.DatabasePath;
|
||||
var walFile = dbFile + "-wal";
|
||||
var shmFile = dbFile + "-shm";
|
||||
|
||||
// If another Libation process currently has the database open, its WAL/SHM are live state.
|
||||
// Deleting them would corrupt that process's database (and ours), so leave everything alone.
|
||||
// See issue #1931.
|
||||
if (IsFileLockedByAnotherProcess(dbFile))
|
||||
{
|
||||
Log.Logger.Information("Skipping SQLite WAL/SHM cleanup: the database appears to be open in another process.");
|
||||
return;
|
||||
}
|
||||
|
||||
// A non-empty WAL from a previous run that ended abruptly can hold committed-but-uncheckpointed
|
||||
// transactions. Deleting it would silently discard that data; instead, leave it in place and let
|
||||
// SQLite recover it when the database is next opened. Only remove an already-checkpointed WAL.
|
||||
if (File.Exists(walFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
FileManager.FileUtility.SaferDelete(walFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {WalFile}", walFile);
|
||||
}
|
||||
if (WalHoldsUnrecoveredTransactions(walFile))
|
||||
Log.Logger.Information("Leaving SQLite WAL in place so SQLite can recover it on open: {WalFile}", walFile);
|
||||
else
|
||||
FileManager.FileUtility.TrySaferDelete(walFile);
|
||||
}
|
||||
|
||||
// The SHM (shared-memory index) is rebuilt from the WAL/database, so it is safe to remove once
|
||||
// no other process holds the database.
|
||||
if (File.Exists(shmFile))
|
||||
FileManager.FileUtility.TrySaferDelete(shmFile);
|
||||
}
|
||||
|
||||
/// <summary>True if <paramref name="path"/> exists and cannot be opened exclusively, i.e. another process holds it.</summary>
|
||||
private static bool IsFileLockedByAnotherProcess(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
FileManager.FileUtility.SaferDelete(shmFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {ShmFile}", shmFile);
|
||||
}
|
||||
using var _ = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
||||
return false;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Can't prove another process holds it, but we also can't safely touch it. Be conservative.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if the WAL may contain committed transactions SQLite still needs to recover. A WAL longer
|
||||
/// than its 32-byte header may hold frames; treat any such file as unrecovered so it is preserved.
|
||||
/// </summary>
|
||||
private static bool WalHoldsUnrecoveredTransactions(string walFile)
|
||||
{
|
||||
const long walHeaderSize = 32;
|
||||
try
|
||||
{
|
||||
return new FileInfo(walFile).Length > walHeaderSize;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// If we can't measure it, assume it matters and keep it rather than risk data loss.
|
||||
Log.Logger.Warning(ex, "Could not inspect SQLite WAL file; leaving it in place: {WalFile}", walFile);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
static bool migrationsRun = false;
|
||||
@@ -163,65 +206,28 @@ public static class LibationScaffolding
|
||||
}
|
||||
|
||||
private static void ensureSerilogConfig(Configuration config)
|
||||
{
|
||||
if (config.GetObject("Serilog") is JObject serilog)
|
||||
=> config.EnsureSerilogConfig();
|
||||
|
||||
/// <summary>
|
||||
/// Hands everything <see cref="StartupLog"/> collected before this point to Serilog, and points it at
|
||||
/// Serilog from here on. Startup runs long before logging exists, so without this those diagnostics are
|
||||
/// simply dropped. This is also the only place in Libation that maps a startup entry onto a Serilog level.
|
||||
/// </summary>
|
||||
private static void replayStartupLog()
|
||||
=> StartupLog.ReplayTo(entry =>
|
||||
{
|
||||
bool fileChanged = false;
|
||||
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
|
||||
var level = entry.Level switch
|
||||
{
|
||||
zipFileSink["Name"] = "File";
|
||||
fileChanged = true;
|
||||
}
|
||||
var hooks = typeof(FileSinkHook).AssemblyQualifiedName;
|
||||
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
|
||||
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
|
||||
{
|
||||
fileSinkArgs["hooks"] = hooks;
|
||||
fileChanged = true;
|
||||
}
|
||||
StartupLogLevel.Debug => Serilog.Events.LogEventLevel.Debug,
|
||||
StartupLogLevel.Warning => Serilog.Events.LogEventLevel.Warning,
|
||||
StartupLogLevel.Error => Serilog.Events.LogEventLevel.Error,
|
||||
_ => Serilog.Events.LogEventLevel.Information,
|
||||
};
|
||||
|
||||
if (fileChanged)
|
||||
config.SetNonString(serilog.DeepClone(), "Serilog");
|
||||
return;
|
||||
}
|
||||
|
||||
var serilogObj = new JObject
|
||||
{
|
||||
{ "MinimumLevel", "Information" },
|
||||
{ "WriteTo", new JArray
|
||||
{
|
||||
// ABOUT SINKS
|
||||
// Only File sink is currently used. By user request (June 2024) others packages are included for experimental use.
|
||||
|
||||
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||
new JObject
|
||||
{
|
||||
{ "Name", "File" },
|
||||
{ "Args",
|
||||
new JObject
|
||||
{
|
||||
// for this sink to work, a path must be provided. we override this below
|
||||
{ "path", Path.Combine(config.LibationFiles.Location, "Log.log") },
|
||||
{ "rollingInterval", "Month" },
|
||||
// Serilog template formatting examples
|
||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
|
||||
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
||||
// {Properties:j} needed for expanded exception logging
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" },
|
||||
{ "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
|
||||
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
||||
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
||||
};
|
||||
config.SetNonString(serilogObj, "Serilog");
|
||||
}
|
||||
// The message is already rendered: startup cannot build a Serilog template without
|
||||
// referencing Serilog, which is the whole point of StartupLog. See issue #2001.
|
||||
Log.Logger.Write(level, entry.Exception, "[startup {StartupTimestamp:HH:mm:ss.fff}] {StartupMessage}", entry.Timestamp, entry.Message);
|
||||
});
|
||||
|
||||
// to restore original: Console.SetOut(origOut);
|
||||
private static TextWriter origOut { get; } = Console.Out;
|
||||
@@ -229,6 +235,7 @@ public static class LibationScaffolding
|
||||
private static void configureLogging(Configuration config)
|
||||
{
|
||||
config.ConfigureLogging();
|
||||
replayStartupLog();
|
||||
Log.Information(
|
||||
"Paths: LibationFiles={LibationFiles} AppsettingsJson={AppsettingsJson} SQLiteDb={SqliteDb}",
|
||||
config.LibationFiles.Location,
|
||||
@@ -304,11 +311,20 @@ public static class LibationScaffolding
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
|
||||
// -1 means the count could not be taken. Listing a directory no longer throws when it stops being
|
||||
// readable partway through, so an incomplete walk has to be asked about rather than caught: a count
|
||||
// that silently means "as many as I managed to read" is worse than no count in a bug report.
|
||||
static int fileCount(FileManager.LongPath? longPath)
|
||||
{
|
||||
if (longPath is null)
|
||||
return -1;
|
||||
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
|
||||
|
||||
var complete = true;
|
||||
try
|
||||
{
|
||||
var count = FileManager.FileUtility.SaferEnumerateFiles(longPath, onIncomplete: _ => complete = false).Count();
|
||||
return complete ? count : -1;
|
||||
}
|
||||
catch { return -1; }
|
||||
}
|
||||
|
||||
@@ -320,6 +336,11 @@ public static class LibationScaffolding
|
||||
Configuration.OS,
|
||||
Environment.OSVersion,
|
||||
InteropFactory.InteropFunctionsType,
|
||||
// A file Windows refused to load, or a half-applied upgrade, is usually explained by one
|
||||
// of these three, and none of them is visible anywhere else in a bug report.
|
||||
InstallFolder = Configuration.ProcessDirectory,
|
||||
ApplicationControl = ApplicationControlPolicy.GetState(),
|
||||
InstallFolderCloudSync = CloudSyncedFolders.GetSyncStatus(Configuration.ProcessDirectory),
|
||||
Mode = mode,
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
@@ -329,7 +350,12 @@ public static class LibationScaffolding
|
||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
||||
|
||||
config.AutoScan,
|
||||
config.BetaOptIn,
|
||||
// These silently exclude titles from every scan, so a log without them can't explain a missing book
|
||||
config.ImportEpisodes,
|
||||
config.ImportPlusTitles,
|
||||
config.DownloadEpisodes,
|
||||
// Off means no startup upgrade prompt, which is otherwise indistinguishable from a broken check
|
||||
config.CheckForUpgradesAtStartup,
|
||||
config.UseCoverAsFolderIcon,
|
||||
config.LibationFiles,
|
||||
AudibleFileStorage.BooksDirectory,
|
||||
@@ -352,10 +378,10 @@ public static class LibationScaffolding
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
{
|
||||
LibraryCommands.LibrarySizeChanged += (_, libraryBooks)
|
||||
=> SearchEngineCommands.FullReIndex(libraryBooks);
|
||||
=> SearchEngineCommands.OnLibrarySizeChanged(libraryBooks);
|
||||
|
||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
|
||||
=> SearchEngineCommands.UpdateBooks(books);
|
||||
=> SearchEngineCommands.OnBookUserDefinedItemCommitted(books);
|
||||
}
|
||||
|
||||
public static VersionCheckResult GetLatestRelease()
|
||||
@@ -463,59 +489,6 @@ public static class LibationScaffolding
|
||||
|
||||
internal static class Migrations
|
||||
{
|
||||
public static void migrate_to_v6_6_9(Configuration config)
|
||||
{
|
||||
var writeToPath = $"Serilog.WriteTo";
|
||||
|
||||
// remove WriteTo[].Name == Console
|
||||
{
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1))
|
||||
{
|
||||
for (var i = length1 - 1; i >= 0; i--)
|
||||
{
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value);
|
||||
|
||||
if (exists && value == "Console")
|
||||
UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add Serilog.Exceptions -- WithExceptionDetails
|
||||
{
|
||||
// outputTemplate should contain "{Properties:j}"
|
||||
{
|
||||
// re-calculate. previous loop may have changed the length
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2))
|
||||
{
|
||||
var propertyName = "outputTemplate";
|
||||
for (var i = 0; i < length2; i++)
|
||||
{
|
||||
var jsonPath = $"{writeToPath}[{i}].Args";
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value);
|
||||
|
||||
var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}";
|
||||
|
||||
if (exists && value != newValue)
|
||||
UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serilog.Using must include "Serilog.Exceptions"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions");
|
||||
|
||||
// Serilog.Enrich must include "WithExceptionDetails"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
||||
}
|
||||
}
|
||||
|
||||
class FilterState_6_6_9
|
||||
{
|
||||
public bool UseDefault { get; set; }
|
||||
public List<string> Filters { get; set; } = [];
|
||||
}
|
||||
|
||||
public static void migrate_to_v12_0_1(Configuration config)
|
||||
{
|
||||
//Migrate from version 1 file cache to the dictionary-based version 2 cache
|
||||
@@ -588,59 +561,4 @@ internal static class Migrations
|
||||
catch { /* eat */ }
|
||||
}
|
||||
}
|
||||
|
||||
public static void migrate_to_v11_6_5(Configuration config)
|
||||
{
|
||||
//Settings migration for unsupported sample rates (#1116)
|
||||
if (config.MaxSampleRate < AAXClean.SampleRate.Hz_8000)
|
||||
config.MaxSampleRate = AAXClean.SampleRate.Hz_8000;
|
||||
else if (config.MaxSampleRate > AAXClean.SampleRate.Hz_48000)
|
||||
config.MaxSampleRate = AAXClean.SampleRate.Hz_48000;
|
||||
}
|
||||
|
||||
public static void migrate_to_v11_5_0(Configuration config)
|
||||
{
|
||||
// Read file, but convert old format to new (with Name field) as necessary.
|
||||
if (!File.Exists(QuickFilters.JsonFile))
|
||||
{
|
||||
QuickFilters.InMemoryState = new();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (JsonConvert.DeserializeObject<QuickFilters.FilterState>(File.ReadAllText(QuickFilters.JsonFile))
|
||||
is QuickFilters.FilterState inMemState)
|
||||
{
|
||||
QuickFilters.InMemoryState = inMemState;
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Eat
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (JsonConvert.DeserializeObject<FilterState_6_6_9>(File.ReadAllText(QuickFilters.JsonFile))
|
||||
is FilterState_6_6_9 inMemState)
|
||||
{
|
||||
// Copy old structure to new.
|
||||
QuickFilters.InMemoryState = new()
|
||||
{
|
||||
UseDefault = inMemState.UseDefault
|
||||
};
|
||||
foreach (var oldFilter in inMemState.Filters)
|
||||
QuickFilters.InMemoryState.Filters.Add(new QuickFilters.NamedFilter(oldFilter, null));
|
||||
|
||||
return;
|
||||
}
|
||||
Debug.Assert(false, "Should not get here, QuickFilters.json deserialization issue");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Eat
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Dinah.Core;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// What to tell the user about titles a run left alone because the last library scan did not find them.
|
||||
/// <para>
|
||||
/// Audible will not license a title it no longer lists for the account - a returned title, or one that has
|
||||
/// left the Plus catalog - so attempting one collects a refusal every run and downloads nothing. Both hosts
|
||||
/// say it in these words so that the same skip does not read as two different things.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AbsentFromLastScanUserMessage
|
||||
{
|
||||
/// <summary>The reason, short enough to head a list of skip reasons.</summary>
|
||||
public const string Label = "Absent from your last library scan";
|
||||
|
||||
/// <summary>What to do about it.</summary>
|
||||
public const string Advice = "run Scan, or `libationcli scan`, then try again";
|
||||
|
||||
/// <summary>The line a CLI run prints in place of attempting them.</summary>
|
||||
public static string BuildCliSkippedSummary(int count)
|
||||
=> $"Skipped {"title".PluralizeWithCount(count)} absent from your last library scan. "
|
||||
+ $"Audible will not license a title it no longer lists, so {Advice}. To attempt them anyway: libationcli liberate --force.";
|
||||
}
|
||||
@@ -6,10 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0">
|
||||
<PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0">
|
||||
<PackageReference Include="ClosedXML" Version="0.105.1">
|
||||
<PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
@@ -21,6 +18,10 @@
|
||||
<ProjectReference Include="..\DataLayer.Sqlite\DataLayer.Sqlite.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -60,7 +60,7 @@ public class BulkSetDownloadStatus
|
||||
|
||||
if (books2change.Any())
|
||||
actionSets.Add((
|
||||
$"{"book".PluralizeWithCount(books2change.Count)} to 'Not Downloaded'",
|
||||
$"{"book".PluralizeWithCount(books2change.Count)} to 'Download Pending'",
|
||||
LiberatedStatus.NotLiberated,
|
||||
books2change));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>Column header for csv and xlsx export. Mirrors the CsvHelper attribute it replaced.</summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
internal sealed class NameAttribute(params string[] names) : Attribute
|
||||
{
|
||||
public string[] Names { get; } = names;
|
||||
}
|
||||
|
||||
/// <summary>Excludes a property from csv export. Mirrors the CsvHelper attribute it replaced.</summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
internal sealed class IgnoreAttribute : Attribute { }
|
||||
|
||||
/// <summary>
|
||||
/// Minimal write-only CSV serializer, output-compatible with the way this project used
|
||||
/// CsvHelper: the delimiter is the culture's list separator, records end with CRLF,
|
||||
/// fields containing the delimiter, quotes, newlines, or leading/trailing spaces are
|
||||
/// quoted per RFC 4180, and values are formatted with the culture. Columns are the
|
||||
/// public instance properties of each record's runtime type, in declaration order,
|
||||
/// honoring <see cref="NameAttribute"/> and <see cref="IgnoreAttribute"/>.
|
||||
/// </summary>
|
||||
internal sealed class CsvWriter(TextWriter writer, CultureInfo culture) : IDisposable
|
||||
{
|
||||
private readonly string delimiter = culture.TextInfo.ListSeparator;
|
||||
private readonly Dictionary<Type, List<(string Header, PropertyInfo Property)>> columnCache = new();
|
||||
private bool rowHasFields;
|
||||
|
||||
public void WriteHeader(Type type)
|
||||
{
|
||||
foreach (var (header, _) in getColumns(type))
|
||||
writeField(header);
|
||||
}
|
||||
|
||||
public void NextRecord()
|
||||
{
|
||||
writer.Write("\r\n");
|
||||
rowHasFields = false;
|
||||
}
|
||||
|
||||
public void WriteRecords<T>(IEnumerable<T> records) where T : notnull
|
||||
{
|
||||
foreach (var record in records)
|
||||
{
|
||||
foreach (var (_, property) in getColumns(record.GetType()))
|
||||
writeField(toString(property.GetValue(record)));
|
||||
NextRecord();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => writer.Dispose();
|
||||
|
||||
private void writeField(string field)
|
||||
{
|
||||
if (rowHasFields)
|
||||
writer.Write(delimiter);
|
||||
writer.Write(escape(field));
|
||||
rowHasFields = true;
|
||||
}
|
||||
|
||||
private string escape(string field)
|
||||
{
|
||||
if (field.Length == 0)
|
||||
return field;
|
||||
|
||||
var needsQuoting
|
||||
= field.Contains('"')
|
||||
|| field[0] == ' '
|
||||
|| field[^1] == ' '
|
||||
|| field.Contains(delimiter)
|
||||
|| field.Contains('\r')
|
||||
|| field.Contains('\n');
|
||||
|
||||
return needsQuoting ? $"\"{field.Replace("\"", "\"\"")}\"" : field;
|
||||
}
|
||||
|
||||
private string toString(object? value)
|
||||
=> value switch
|
||||
{
|
||||
null => "",
|
||||
string s => s,
|
||||
IFormattable formattable => formattable.ToString(null, culture),
|
||||
_ => value.ToString() ?? ""
|
||||
};
|
||||
|
||||
private List<(string Header, PropertyInfo Property)> getColumns(Type type)
|
||||
{
|
||||
if (!columnCache.TryGetValue(type, out var columns))
|
||||
columnCache[type] = columns = type
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.GetMethod is not null
|
||||
&& p.GetIndexParameters().Length == 0
|
||||
&& p.GetCustomAttribute<IgnoreAttribute>() is null)
|
||||
.Select(p => (p.GetCustomAttribute<NameAttribute>()?.Names.FirstOrDefault() ?? p.Name, p))
|
||||
.ToList();
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,23 @@ public static class DbContexts
|
||||
return context.GetDeletedLibraryBooks();
|
||||
}
|
||||
|
||||
/// <summary>How many books belong in the search index. A row count only; no entities are loaded.</summary>
|
||||
public static int GetIndexableBookCount()
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetIndexableBookCount();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many <see cref="LibraryBook"/> rows are in the trash. A row count only; no entities are loaded,
|
||||
/// so this is cheap enough to refresh whenever the library changes.
|
||||
/// </summary>
|
||||
public static int GetTrashedBookCount()
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibraryBookCountsByTrashFlag().InTrash;
|
||||
}
|
||||
|
||||
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
|
||||
{
|
||||
using var context = GetContext();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>One title Libation is waiting on before attempting it again, and why.</summary>
|
||||
public sealed record DeferredDownload(
|
||||
string Account,
|
||||
string AudibleProductId,
|
||||
DownloadFailureKind Kind,
|
||||
int ConsecutiveFailures,
|
||||
DateTimeOffset LastFailedAt,
|
||||
DateTimeOffset RetryAfter,
|
||||
string? Reason)
|
||||
{
|
||||
/// <summary>The label used in the log, the CLI summary and the GUI's skipped-titles breakdown.</summary>
|
||||
public string KindLabel => Kind switch
|
||||
{
|
||||
DownloadFailureKind.LicenseDenied => "Audible denied a download license",
|
||||
DownloadFailureKind.AssetUnavailable => "Audible has no downloadable audio yet",
|
||||
DownloadFailureKind.ServiceInterruption => "A possible Audible service interruption",
|
||||
_ => "A previous failure"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The titles a bulk or automatic download run should leave alone for now, looked up by library book.
|
||||
/// <para>
|
||||
/// Read once per run: a run that takes hours must not have its own failures start suppressing the titles
|
||||
/// still ahead of it in the same pass.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DownloadDeferrals
|
||||
{
|
||||
/// <summary>No title is deferred. Used by targeted and forced runs, which must attempt what was asked.</summary>
|
||||
public static DownloadDeferrals None { get; } = new([]);
|
||||
|
||||
private readonly Dictionary<(string Account, string AudibleProductId), DeferredDownload> byBook;
|
||||
|
||||
private DownloadDeferrals(IEnumerable<DeferredDownload> deferred)
|
||||
=> byBook = deferred.ToDictionary(d => (d.Account, d.AudibleProductId));
|
||||
|
||||
public static DownloadDeferrals Create(IEnumerable<DeferredDownload> deferred) => new(deferred);
|
||||
|
||||
/// <summary>Reads the store. Never throws: a failure here must not stop a download run.</summary>
|
||||
public static DownloadDeferrals Load(DateTimeOffset now)
|
||||
=> Create(DownloadAttemptFailureStore.GetDeferred(now));
|
||||
|
||||
public int Count => byBook.Count;
|
||||
public bool Any => byBook.Count > 0;
|
||||
|
||||
public DeferredDownload? Find(LibraryBook libraryBook)
|
||||
=> libraryBook.Account is { } account
|
||||
&& byBook.TryGetValue((account, libraryBook.Book.AudibleProductId), out var deferred)
|
||||
? deferred
|
||||
: null;
|
||||
|
||||
public bool IsDeferred(LibraryBook libraryBook) => Find(libraryBook) is not null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>What to tell the user about titles a run held back, instead of the full warning per title per run.</summary>
|
||||
public static class DeferredDownloadUserMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// A compact breakdown for the log, eg:
|
||||
/// "Audible denied a download license: 3, Audible has no downloadable audio yet: 1".
|
||||
/// </summary>
|
||||
public static string BuildLogBreakdown(IEnumerable<DeferredDownload> deferred)
|
||||
{
|
||||
var breakdown = string.Join(", ", GroupByKind(deferred).Select(g => $"{g.First().KindLabel}: {g.Count()}"));
|
||||
return breakdown is "" ? "none" : breakdown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The lines a CLI run prints in place of a full warning per title. Says how many were held back, why,
|
||||
/// when the soonest will be attempted again, and how to override.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> BuildCliSkippedLines(IReadOnlyCollection<DeferredDownload> skipped, DateTimeOffset now)
|
||||
{
|
||||
if (skipped.Count == 0)
|
||||
yield break;
|
||||
|
||||
yield return $"Skipped {"title".PluralizeWithCount(skipped.Count)} that recently failed to download. Libation will try again by itself.";
|
||||
|
||||
foreach (var group in GroupByKind(skipped))
|
||||
yield return $" {group.First().KindLabel}: {group.Count()} (next attempt {DescribeWhen(group.Min(d => d.RetryAfter), now)})";
|
||||
|
||||
yield return " To try one now: libationcli liberate <ASIN>. For all of them: libationcli liberate --force.";
|
||||
}
|
||||
|
||||
/// <summary>"in about 3 hours" / "in about 12 days (9/14/2026)" - a summary should not need a clock to read.</summary>
|
||||
public static string DescribeWhen(DateTimeOffset when, DateTimeOffset now)
|
||||
{
|
||||
var wait = when - now;
|
||||
|
||||
if (wait <= TimeSpan.Zero)
|
||||
return "on the next run";
|
||||
if (wait < TimeSpan.FromHours(1))
|
||||
return $"in about {"minute".PluralizeWithCount(Math.Max(1, (int)wait.TotalMinutes))}";
|
||||
|
||||
// Rounded to hours first, so a wait of exactly one day does not read as 23 or 24 hours depending on
|
||||
// how long the run took to reach this line.
|
||||
var hours = (int)Math.Round(wait.TotalHours);
|
||||
return hours < 24
|
||||
? $"in about {"hour".PluralizeWithCount(hours)}"
|
||||
: $"in about {"day".PluralizeWithCount((int)Math.Round(hours / 24d))} ({when.ToLocalTime():d})";
|
||||
}
|
||||
|
||||
private static IEnumerable<IGrouping<DownloadFailureKind, DeferredDownload>> GroupByKind(IEnumerable<DeferredDownload> deferred)
|
||||
=> deferred.GroupBy(d => d.Kind).OrderBy(g => g.Key);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes the record of refused download attempts that keeps a scheduled run from asking Audible
|
||||
/// for the same license every time.
|
||||
/// <para>
|
||||
/// Every method swallows its own errors. This is bookkeeping that makes downloading quieter; it must never
|
||||
/// be the reason a download fails, and a broken query must leave downloading exactly as it was before this
|
||||
/// existed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DownloadAttemptFailureStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a failed attempt, extending the wait before the title is attempted again. A failure of a
|
||||
/// different kind than last time restarts the count: Audible changed its mind about why, so the previous
|
||||
/// wait no longer describes the situation.
|
||||
/// </summary>
|
||||
public static void Record(LibraryBook libraryBook, DownloadFailureKind kind, string? reason, DateTimeOffset? failedAt = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(libraryBook);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Account) || string.IsNullOrWhiteSpace(libraryBook.Book.AudibleProductId))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var when = failedAt ?? DateTimeOffset.Now;
|
||||
var account = libraryBook.Account;
|
||||
var productId = libraryBook.Book.AudibleProductId;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
var existing = context.DownloadAttemptFailures
|
||||
.SingleOrDefault(f => f.Account == account && f.AudibleProductId == productId);
|
||||
|
||||
var consecutiveFailures = existing is null || existing.Kind != kind ? 1 : existing.ConsecutiveFailures + 1;
|
||||
var retryAfter = DownloadRetryBackoff.GetRetryAfter(kind, consecutiveFailures, when);
|
||||
|
||||
if (existing is null)
|
||||
context.DownloadAttemptFailures.Add(new DownloadAttemptFailure(productId, account, kind, consecutiveFailures, when, retryAfter, Truncate(reason)));
|
||||
else
|
||||
existing.Record(kind, consecutiveFailures, when, retryAfter, Truncate(reason));
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
Serilog.Log.Logger.Information(
|
||||
"Not attempting {audibleProductId} again until {retryAfter}. {@DebugInfo}",
|
||||
productId,
|
||||
retryAfter.ToLocalTime(),
|
||||
new { Title = libraryBook.Book.TitleWithSubtitle, Account = account.ToMask(), kind, consecutiveFailures, reason });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(
|
||||
ex,
|
||||
"Failed to record a refused download attempt. The title will be attempted again on the next run. {@DebugInfo}",
|
||||
new { libraryBook.Book.AudibleProductId, Title = libraryBook.Book.TitleWithSubtitle, kind });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets any record for this title, so it is attempted again at the next opportunity. Called when a
|
||||
/// download succeeds and when the user asks for the title explicitly.
|
||||
/// </summary>
|
||||
public static void Clear(LibraryBook libraryBook)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(libraryBook);
|
||||
Clear(libraryBook.Account, libraryBook.Book.AudibleProductId);
|
||||
}
|
||||
|
||||
public static void Clear(string? account, string? audibleProductId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(audibleProductId))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
// ExecuteDelete rather than a load-then-remove so the common case (nothing recorded) is one
|
||||
// statement. Called after every successful download.
|
||||
var deleted = context.DownloadAttemptFailures
|
||||
.Where(f => f.Account == account && f.AudibleProductId == audibleProductId)
|
||||
.ExecuteDelete();
|
||||
|
||||
if (deleted > 0)
|
||||
Serilog.Log.Logger.Debug("Cleared the recorded download failure for {audibleProductId}", audibleProductId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to clear the recorded download failure for {audibleProductId}", audibleProductId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Titles whose wait has not elapsed. Empty when the query fails, so downloading carries on.</summary>
|
||||
public static IReadOnlyList<DeferredDownload> GetDeferred(DateTimeOffset now)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ticks = now.UtcTicks;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
return Project(context.DownloadAttemptFailures.AsNoTracking().Where(f => f.RetryAfterUtcTicks > ticks));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Failing open is the safer default: a broken query must not stop titles from being downloaded.
|
||||
Serilog.Log.Logger.Error(ex, "Failed to read recorded download failures. Treating every title as ready to attempt.");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The current wait for one title, or null when it is ready to be attempted. Null when the query fails.</summary>
|
||||
public static DeferredDownload? Find(LibraryBook libraryBook, DateTimeOffset now)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(libraryBook);
|
||||
|
||||
var account = libraryBook.Account;
|
||||
var productId = libraryBook.Book.AudibleProductId;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(productId))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var ticks = now.UtcTicks;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
return Project(
|
||||
context.DownloadAttemptFailures
|
||||
.AsNoTracking()
|
||||
.Where(f => f.Account == account && f.AudibleProductId == productId && f.RetryAfterUtcTicks > ticks))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to read the recorded download failure for {audibleProductId}", productId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<DeferredDownload> Project(IQueryable<DownloadAttemptFailure> query)
|
||||
=> query
|
||||
// Materialise the columns first: the record's constructor is not translatable to SQL.
|
||||
.Select(f => new
|
||||
{
|
||||
f.Account,
|
||||
f.AudibleProductId,
|
||||
f.Kind,
|
||||
f.ConsecutiveFailures,
|
||||
f.LastFailedAtUtcTicks,
|
||||
f.RetryAfterUtcTicks,
|
||||
f.Reason
|
||||
})
|
||||
.ToList()
|
||||
.Select(f => new DeferredDownload(
|
||||
f.Account,
|
||||
f.AudibleProductId,
|
||||
f.Kind,
|
||||
f.ConsecutiveFailures,
|
||||
new DateTimeOffset(f.LastFailedAtUtcTicks, TimeSpan.Zero),
|
||||
new DateTimeOffset(f.RetryAfterUtcTicks, TimeSpan.Zero),
|
||||
f.Reason))
|
||||
.ToList();
|
||||
|
||||
/// <summary>Audible's messages can run long; the full text is already in the log.</summary>
|
||||
private static string? Truncate(string? reason)
|
||||
=> reason is null || reason.Length <= 400 ? reason : reason[..400];
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes the record of successful audiobook downloads backing the daily download limit.
|
||||
/// <para>
|
||||
/// Caches nothing: every read is a fresh query, so a queue paused for hours sees entries age out of the
|
||||
/// rolling window, and downloads performed by a concurrently running CLI or second container are counted too.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DownloadHistoryStore
|
||||
{
|
||||
/// <summary>Kept a little beyond the 24 hour window so the table stays small without losing anything in use.</summary>
|
||||
private static readonly TimeSpan RetentionPeriod = TimeSpan.FromHours(48);
|
||||
|
||||
/// <summary>
|
||||
/// Records a finished download. Never throws: bookkeeping must not fail a download that already succeeded.
|
||||
/// </summary>
|
||||
public static void Record(string? audibleProductId, bool isAudiblePlus, long bytes, DateTimeOffset? completedAt = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var when = completedAt ?? DateTimeOffset.Now;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
context.DownloadHistory.Add(new DownloadHistory(when, audibleProductId, isAudiblePlus, bytes));
|
||||
|
||||
var cutoff = (when - RetentionPeriod).UtcTicks;
|
||||
var expired = context.DownloadHistory.Where(dh => dh.CompletedAtUtcTicks < cutoff);
|
||||
context.DownloadHistory.RemoveRange(expired);
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
Serilog.Log.Logger.Debug(
|
||||
"Recorded download for the daily download limit. {@DebugInfo}",
|
||||
new { audibleProductId, isAudiblePlus, bytes, completedAt = when });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(
|
||||
ex,
|
||||
"Failed to record a completed download for the daily download limit. The download itself succeeded. {@DebugInfo}",
|
||||
new { audibleProductId, isAudiblePlus, bytes });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Downloads completed at or after <paramref name="since"/>. Empty when the query fails.</summary>
|
||||
public static IReadOnlyList<DownloadHistoryEntry> GetSince(DateTimeOffset since)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ticks = since.UtcTicks;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
return context.DownloadHistory
|
||||
.AsNoTracking()
|
||||
.Where(dh => dh.CompletedAtUtcTicks >= ticks)
|
||||
.OrderBy(dh => dh.CompletedAtUtcTicks)
|
||||
.Select(dh => new { dh.CompletedAtUtcTicks, dh.AudibleProductId, dh.IsAudiblePlus, dh.Bytes })
|
||||
.ToList()
|
||||
.Select(dh => new DownloadHistoryEntry(
|
||||
new DateTimeOffset(dh.CompletedAtUtcTicks, TimeSpan.Zero),
|
||||
dh.AudibleProductId,
|
||||
dh.IsAudiblePlus,
|
||||
dh.Bytes))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Failing open is the safer default: a broken query must not block downloading.
|
||||
Serilog.Log.Logger.Error(ex, "Failed to read download history. Treating the last 24 hours as empty.");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The rolling window the daily download limit uses.</summary>
|
||||
public static IReadOnlyList<DownloadHistoryEntry> GetCurrentWindow(DateTimeOffset now)
|
||||
=> GetSince(now - DailyDownloadLimit.Window);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using DataLayer;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// How long to leave a title alone after a download attempt Audible refused, so a scheduled run stops asking
|
||||
/// for the same license every time.
|
||||
/// <para>
|
||||
/// The wait doubles with each failure in a row, up to a cap, and every kind of failure has a finite cap: a
|
||||
/// title held back is always attempted again eventually. Audible never distinguishes "you will never have
|
||||
/// rights to this" from "not right now", so nothing here may be permanent.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DownloadRetryBackoff
|
||||
{
|
||||
private static readonly Dictionary<DownloadFailureKind, (TimeSpan First, TimeSpan Max)> schedule = new()
|
||||
{
|
||||
// An eligibility refusal changes only when the account or the catalog changes. A day matches the
|
||||
// advice Libation already gives for a Plus title ("try again in 1 to 2 days"), which is the most
|
||||
// common refusal that clears by itself.
|
||||
[DownloadFailureKind.LicenseDenied] = (TimeSpan.FromDays(1), TimeSpan.FromDays(30)),
|
||||
|
||||
// A preorder becomes downloadable on its release date, which nobody can predict from the error, so
|
||||
// keep checking within a week.
|
||||
[DownloadFailureKind.AssetUnavailable] = (TimeSpan.FromHours(6), TimeSpan.FromDays(7)),
|
||||
|
||||
// Short: an outage that has passed should not delay a title any longer than it has to.
|
||||
[DownloadFailureKind.ServiceInterruption] = (TimeSpan.FromHours(1), TimeSpan.FromHours(12)),
|
||||
};
|
||||
|
||||
/// <summary>How long to wait after the <paramref name="consecutiveFailures"/>th failure in a row.</summary>
|
||||
public static TimeSpan GetWait(DownloadFailureKind kind, int consecutiveFailures)
|
||||
{
|
||||
var (first, max) = schedule.TryGetValue(kind, out var found)
|
||||
? found
|
||||
: schedule[DownloadFailureKind.ServiceInterruption];
|
||||
|
||||
// Doubled one step at a time and stopped at the cap: first * 2^n overflows a TimeSpan long before a
|
||||
// plausible failure count, let alone the absurd ones a corrupt row could hold.
|
||||
var doublings = Math.Max(0, consecutiveFailures - 1);
|
||||
var wait = first;
|
||||
for (var i = 0; i < doublings && wait < max; i++)
|
||||
wait += wait;
|
||||
|
||||
return wait > max ? max : wait;
|
||||
}
|
||||
|
||||
/// <summary>When a title becomes eligible for another automatic attempt.</summary>
|
||||
public static DateTimeOffset GetRetryAfter(DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset failedAt)
|
||||
=> failedAt + GetWait(kind, consecutiveFailures);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
@@ -11,6 +11,7 @@ using Serilog;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -62,33 +63,31 @@ public static class LibraryCommands
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin: true);
|
||||
var scan = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin: true);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var libraryItems = scan.Items;
|
||||
var totalCount = libraryItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
return existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
var existing = existingLibrary?.ToList() ?? [];
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
EnsureScanCanIdentifyInactiveBooks(totalCount, existing.Count, scan.FailedAccounts);
|
||||
|
||||
var inactive = existing.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||
|
||||
Log.Logger.Information(
|
||||
"Books absent from the library scan. {@DebugInfo}",
|
||||
new { InactiveCount = inactive.Count, CandidateCount = existing.Count, ScannedCount = totalCount });
|
||||
|
||||
return inactive;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception lfEx) when (AuthenticationExceptionHelper.IsAuthenticationFailure(lfEx))
|
||||
{
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Authentication failed.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error scanning library");
|
||||
throw;
|
||||
@@ -107,6 +106,22 @@ public static class LibraryCommands
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuse to call anything "no longer in your library" from a scan that could not see the whole library.
|
||||
/// Importing tolerates a partial scan because it only adds and updates; removal cannot, because a book
|
||||
/// missing from one bad scan is indistinguishable from a book the user no longer owns.
|
||||
/// </summary>
|
||||
/// <exception cref="LibraryScanIncompleteException">The scan cannot support that decision.</exception>
|
||||
public static void EnsureScanCanIdentifyInactiveBooks(int scannedItemCount, int existingBookCount, IReadOnlyCollection<string>? failedAccounts)
|
||||
{
|
||||
if (failedAccounts?.Count > 0)
|
||||
throw LibraryScanIncompleteException.ForFailedAccounts(failedAccounts);
|
||||
|
||||
// An account whose every book vanished at once is a broken scan, not an emptied library.
|
||||
if (scannedItemCount == 0 && existingBookCount > 0)
|
||||
throw LibraryScanIncompleteException.ForEmptyScan(existingBookCount);
|
||||
}
|
||||
|
||||
#region FULL LIBRARY scan and import
|
||||
public static Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
|
||||
=> ImportAccountAsync(accounts, allowInteractiveLogin: true);
|
||||
@@ -136,10 +151,14 @@ public static class LibraryCommands
|
||||
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
|
||||
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
|
||||
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||
| LibraryOptions.ResponseGroupOptions.IsFinished
|
||||
//product_details is the only group that returns the copyright line, which Libation
|
||||
//needs for Widevine downloads: those files arrive without an embedded cprt tag.
|
||||
| LibraryOptions.ResponseGroupOptions.ProductDetails,
|
||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||
};
|
||||
var importItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin);
|
||||
//Importing only adds and updates, so a partially scanned library is still worth importing.
|
||||
var importItems = (await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin)).Items;
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
@@ -156,25 +175,12 @@ public static class LibraryCommands
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception lfEx) when (AuthenticationExceptionHelper.IsAuthenticationFailure(lfEx))
|
||||
{
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Authentication failed.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
@@ -275,9 +281,18 @@ public static class LibraryCommands
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin)
|
||||
/// <summary>Outcome of scanning one or more accounts.</summary>
|
||||
/// <param name="Items">Everything the accounts that did scan returned.</param>
|
||||
/// <param name="FailedAccounts">
|
||||
/// Accounts that could not be scanned. Their books are absent from <paramref name="Items"/> for that reason
|
||||
/// alone, so a caller deciding what to remove must not treat the result as the whole library.
|
||||
/// </param>
|
||||
private sealed record ScanResult(List<ImportItem> Items, IReadOnlyCollection<string> FailedAccounts);
|
||||
|
||||
private static async Task<ScanResult> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
var failedAccounts = new List<string>();
|
||||
|
||||
await using LogArchiver? archiver
|
||||
= Log.Logger.IsDebugEnabled()
|
||||
@@ -290,11 +305,22 @@ public static class LibraryCommands
|
||||
{
|
||||
try
|
||||
{
|
||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin);
|
||||
// An account is scanned as a unit: all of its marketplaces or none of them. A partial scan would
|
||||
// leave the marketplaces that did not run looking empty, and AbsentFromLastScan - which is keyed
|
||||
// by account id, not by marketplace - would mark their titles absent.
|
||||
var accountTasks = new List<Task<List<ImportItem>>>();
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
|
||||
// ScanLocales leads with the account's own marketplace, so any login happens once, up front
|
||||
foreach (var locale in account.ScanLocales)
|
||||
{
|
||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin, storeLocale: locale);
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
accountTasks.Add(scanAccountAsync(apiExtended, account, locale, libraryOptions, archiver));
|
||||
}
|
||||
|
||||
tasks.AddRange(accountTasks);
|
||||
}
|
||||
catch (Exception ex) when (!allowInteractiveLogin && AuthenticationExceptionHelper.IsAuthenticationFailure(ex))
|
||||
{
|
||||
@@ -304,32 +330,39 @@ public static class LibraryCommands
|
||||
{
|
||||
//Catch to allow other accounts to continue scanning.
|
||||
Log.Logger.Error(ex, "Failed to scan account");
|
||||
failedAccounts.Add(account.MaskedLogEntry ?? account.AccountId ?? "[unknown account]");
|
||||
}
|
||||
}
|
||||
|
||||
// import library in parallel
|
||||
var arrayOfLists = await Task.WhenAll(tasks);
|
||||
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
||||
return importItems;
|
||||
return new ScanResult(importItems, failedAccounts);
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver? archiver)
|
||||
/// <param name="locale">
|
||||
/// The marketplace being read. Usually the account's own; for an account holding titles under a second
|
||||
/// storefront, each of those in turn. Books are tagged with it, which is how a download later finds its way
|
||||
/// back to the right store.
|
||||
/// </param>
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, Locale locale, LibraryOptions libraryOptions, LogArchiver? archiver)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
var locale = ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
|
||||
ArgumentValidator.EnsureNotNull(locale, nameof(locale));
|
||||
|
||||
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
||||
{
|
||||
Account = account.MaskedLogEntry ?? "[null]"
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name
|
||||
});
|
||||
|
||||
logTime($"pre scanAccountAsync {account.AccountName}");
|
||||
logTime($"pre scanAccountAsync {account.AccountName} {locale.Name}");
|
||||
|
||||
try
|
||||
{
|
||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions);
|
||||
|
||||
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
||||
logTime($"post scanAccountAsync {account.AccountName} {locale.Name} qty: {dtoItems.Count}");
|
||||
|
||||
await logDtoItemsAsync(dtoItems);
|
||||
|
||||
@@ -346,12 +379,13 @@ public static class LibraryCommands
|
||||
{
|
||||
if (archiver is not null)
|
||||
{
|
||||
var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry}.json";
|
||||
var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry} {locale.Name}.json";
|
||||
var items = await Task.Run(() => JArray.FromObject(dtoItems.Select(i => i.SourceJson)));
|
||||
|
||||
var scanFile = new JObject
|
||||
{
|
||||
{ "Account", account.MaskedLogEntry },
|
||||
{ "Locale", locale.Name },
|
||||
{ "ScannedDateTime", DateTime.Now.ToString("u") },
|
||||
};
|
||||
|
||||
@@ -404,13 +438,39 @@ public static class LibraryCommands
|
||||
#endregion
|
||||
|
||||
#region remove/restore books
|
||||
|
||||
/// <summary>
|
||||
/// Record every change to the trash. Removal is a soft delete, so a book can leave the library and sit
|
||||
/// out of sight indefinitely; without this the log cannot say when that happened or how much is in there.
|
||||
/// </summary>
|
||||
private static void logTrashChange(string action, int qtyChanges)
|
||||
{
|
||||
if (qtyChanges < 1)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Log.Logger.Information("Trash bin changed. {@DebugInfo}", new
|
||||
{
|
||||
Action = action,
|
||||
Books = qtyChanges,
|
||||
BooksInTrash = DbContexts.GetTrashedBookCount()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//The change itself already succeeded. Never fail it over a log line.
|
||||
Log.Logger.Warning(ex, "Trash bin changed ({action}, {qtyChanges}) but the trash count could not be read", action, qtyChanges);
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook?>? idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||
private static int removeBooks(IEnumerable<LibraryBook?>? removeLibraryBooks)
|
||||
{
|
||||
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
|
||||
return 0;
|
||||
|
||||
return DoDbSizeChangeOperation(ctx =>
|
||||
var qtyChanges = DoDbSizeChangeOperation(ctx =>
|
||||
{
|
||||
// Entry() NoTracking entities before SaveChanges()
|
||||
foreach (var lb in removeLibraryBooks.OfType<LibraryBook>())
|
||||
@@ -419,6 +479,9 @@ public static class LibraryCommands
|
||||
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
}
|
||||
});
|
||||
|
||||
logTrashChange("Moved to trash", qtyChanges);
|
||||
return qtyChanges;
|
||||
}
|
||||
|
||||
public static Task<int> RestoreBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => restoreBooks(idsToRemove));
|
||||
@@ -428,7 +491,7 @@ public static class LibraryCommands
|
||||
return 0;
|
||||
try
|
||||
{
|
||||
return DoDbSizeChangeOperation(ctx =>
|
||||
var qtyChanges = DoDbSizeChangeOperation(ctx =>
|
||||
{
|
||||
// Entry() NoTracking entities before SaveChanges()
|
||||
foreach (var lb in libraryBooks)
|
||||
@@ -437,6 +500,9 @@ public static class LibraryCommands
|
||||
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
}
|
||||
});
|
||||
|
||||
logTrashChange("Restored from trash", qtyChanges);
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -452,11 +518,14 @@ public static class LibraryCommands
|
||||
return 0;
|
||||
try
|
||||
{
|
||||
return DoDbSizeChangeOperation(ctx =>
|
||||
var qtyChanges = DoDbSizeChangeOperation(ctx =>
|
||||
{
|
||||
ctx.LibraryBooks.RemoveRange(libraryBooks.OfType<LibraryBook>());
|
||||
ctx.Books.RemoveRange(libraryBooks.OfType<LibraryBook>().Select(lb => lb.Book));
|
||||
});
|
||||
|
||||
logTrashChange("Permanently deleted", qtyChanges);
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -579,13 +648,19 @@ public static class LibraryCommands
|
||||
return 0;
|
||||
|
||||
int qtyChanges;
|
||||
var statusChanged = new List<LibraryBook>();
|
||||
using (var context = DbContexts.GetContext())
|
||||
{
|
||||
// Entry() instead of Attach() due to possible stack overflow with large tables
|
||||
foreach (var book in nonNullBooks)
|
||||
{
|
||||
var statusBefore = book.Book.UserDefinedItem.BookStatus;
|
||||
|
||||
action?.Invoke(book.Book.UserDefinedItem);
|
||||
|
||||
if (book.Book.UserDefinedItem.BookStatus != statusBefore)
|
||||
statusChanged.Add(book);
|
||||
|
||||
var udiEntity = context.Entry(book.Book.UserDefinedItem);
|
||||
|
||||
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
@@ -596,7 +671,16 @@ public static class LibraryCommands
|
||||
qtyChanges = context.SaveChanges();
|
||||
}
|
||||
if (qtyChanges > 0)
|
||||
{
|
||||
// Changing a title's download status is the user saying they want a different outcome for it,
|
||||
// so drop any wait Libation was observing before attempting it again. Compared against the
|
||||
// previous value rather than acting on every call: editing tags or a rating must not quietly
|
||||
// put a title Audible just refused back into the next scheduled run.
|
||||
foreach (var book in statusChanged)
|
||||
DownloadAttemptFailureStore.Clear(book);
|
||||
|
||||
BookUserDefinedItemCommitted?.Invoke(null, nonNullBooks);
|
||||
}
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
@@ -658,7 +742,12 @@ public static class LibraryCommands
|
||||
}
|
||||
}
|
||||
|
||||
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null)
|
||||
/// <param name="requestedBy">
|
||||
/// Names the caller in the log. Both UIs count the whole library and the visible subset as the
|
||||
/// library loads, and the two produce identical lines whenever no filter is applied, so without
|
||||
/// this there is no way to tell which set a given count describes.
|
||||
/// </param>
|
||||
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null, [CallerMemberName] string requestedBy = "")
|
||||
{
|
||||
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||
|
||||
@@ -674,7 +763,7 @@ public static class LibraryCommands
|
||||
var booksError = results.Count(r => r.status == LiberatedStatus.Error);
|
||||
var booksUnavailable = results.Count(r => r.absent && r.status is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload);
|
||||
|
||||
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
|
||||
Log.Logger.Information("Book counts for {RequestedBy}. {@DebugInfo}", requestedBy, new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
|
||||
|
||||
var pdfResults = libraryBooks
|
||||
.AsParallel()
|
||||
@@ -684,9 +773,11 @@ public static class LibraryCommands
|
||||
|
||||
var pdfsDownloaded = pdfResults.Count(r => r.status == LiberatedStatus.Liberated);
|
||||
var pdfsNotDownloaded = pdfResults.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||
var pdfsUnavailable = pdfResults.Count(r => r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||
// Error means Audible has a supplement listed for the title but will not deliver one, so the count it
|
||||
// belongs in is the one for PDFs Libation cannot get. Without this such a title is in no count at all.
|
||||
var pdfsUnavailable = pdfResults.Count(r => r.status == LiberatedStatus.Error || (r.absent && r.status == LiberatedStatus.NotLiberated));
|
||||
|
||||
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
|
||||
Log.Logger.Information("PDF counts for {RequestedBy}. {@DebugInfo}", requestedBy, new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
|
||||
|
||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable, libraryBooks);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using ClosedXML.Excel;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json;
|
||||
using System.Collections.Generic;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// A library scan finished without throwing but cannot be trusted to say what is no longer in the account.
|
||||
/// Deciding "inactive" from such a scan would offer to remove books the user still owns.
|
||||
/// </summary>
|
||||
public class LibraryScanIncompleteException : Exception
|
||||
{
|
||||
/// <summary>Accounts that could not be scanned, if that is why the scan is untrustworthy.</summary>
|
||||
public IReadOnlyCollection<string> FailedAccounts { get; }
|
||||
|
||||
public LibraryScanIncompleteException(string message, IReadOnlyCollection<string>? failedAccounts = null)
|
||||
: base(message)
|
||||
=> FailedAccounts = failedAccounts ?? [];
|
||||
|
||||
internal static LibraryScanIncompleteException ForFailedAccounts(IReadOnlyCollection<string> failedAccounts)
|
||||
=> new(
|
||||
$"{"account".PluralizeWithCount(failedAccounts.Count)} could not be scanned, so Libation cannot tell which books are no longer in your library: "
|
||||
+ string.Join(", ", failedAccounts),
|
||||
failedAccounts);
|
||||
|
||||
internal static LibraryScanIncompleteException ForEmptyScan(int existingBookCount)
|
||||
=> new(
|
||||
$"The library scan returned no books while Libation still holds {"book".PluralizeWithCount(existingBookCount)}. "
|
||||
+ "Treating that as an empty Audible library would offer to remove books you still own.");
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using AudibleApi.Common;
|
||||
using ClosedXML.Excel;
|
||||
using CsvHelper;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
|
||||
@@ -23,6 +23,7 @@ public static class SearchEngineCommands
|
||||
lock (IndexLock)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
repairShortIndex(engine);
|
||||
try
|
||||
{
|
||||
return func(engine);
|
||||
@@ -40,13 +41,98 @@ public static class SearchEngineCommands
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Set once the index has been measured against the library, so the check costs one query per run.</summary>
|
||||
private static bool indexCounted;
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds an index that holds fewer books than the library does.
|
||||
/// <para>
|
||||
/// A book the index never received is not merely unsearchable. Filtering intersects the grid with the
|
||||
/// query's hits, so a positive term such as <c>Absent</c> cannot return it, while the negated <c>-Absent</c>
|
||||
/// resolves to "every document in the index" and therefore drops it from the grid - which looks exactly
|
||||
/// like the negated filter working correctly. Reported as issue #1989.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing else notices: the index is only ever written as a whole, and <see cref="tryUpdate"/>
|
||||
/// deliberately swallows a rebuild that fails so a bad index cannot fail a good scan. A short index
|
||||
/// therefore stays short until something happens to change the library again.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static void repairShortIndex(SearchEngine engine)
|
||||
{
|
||||
if (indexCounted)
|
||||
return;
|
||||
|
||||
// before the work, not after: a check that throws must not run on every query for the rest of the session
|
||||
indexCounted = true;
|
||||
|
||||
try
|
||||
{
|
||||
var indexed = engine.GetIndexedBookCount();
|
||||
|
||||
// no index yet, or one too damaged to read. Both already have their own recovery.
|
||||
if (indexed < 0)
|
||||
return;
|
||||
|
||||
var expected = DbContexts.GetIndexableBookCount();
|
||||
|
||||
// more documents than books is stale rather than harmful: an extra document matches no grid row.
|
||||
if (indexed >= expected)
|
||||
return;
|
||||
|
||||
Log.Warning("The search index holds {Indexed} of {Expected} books. Rebuilding it: search cannot find the rest, and a negated filter hides them.", indexed, expected);
|
||||
|
||||
var failures = fullReIndex(engine);
|
||||
|
||||
if (failures > 0)
|
||||
Log.Error("{Failures} book(s) could not be added to the search index. Search cannot find them, and a negated filter hides them.", failures);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Searching with a short index still beats not searching at all.
|
||||
Log.Error(ex, "Could not check the search index against the library.");
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
public static event EventHandler? SearchEngineUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the index could not be updated even after automatic repair, so it needs the user's help.
|
||||
/// </summary>
|
||||
public static event EventHandler<Exception>? UpdateFailed;
|
||||
|
||||
#region Update
|
||||
private static bool isUpdating;
|
||||
|
||||
/// <summary>Updates the index after books were added to or removed from the library.</summary>
|
||||
public static void OnLibrarySizeChanged(List<LibraryBook> libraryBooks)
|
||||
=> tryUpdate(() => FullReIndex(libraryBooks));
|
||||
|
||||
/// <summary>Updates the index after book details, tags or statuses were committed.</summary>
|
||||
public static void OnBookUserDefinedItemCommitted(IEnumerable<LibraryBook> books)
|
||||
=> tryUpdate(() => UpdateBooks(books));
|
||||
|
||||
/// <summary>
|
||||
/// The database change that triggers an update is committed before the update runs, and this index is derived
|
||||
/// from that database, so a failure here is reported instead of propagated. Letting it escape reported a
|
||||
/// successful scan as "Error importing library", and, since this is the first subscriber to those events,
|
||||
/// stopped the handlers that refresh the grid and the backup counts from running at all.
|
||||
/// </summary>
|
||||
private static void tryUpdate(Action update)
|
||||
{
|
||||
try
|
||||
{
|
||||
update();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to update the search index. Library changes are saved; search and filter results may be stale until the next update succeeds.");
|
||||
UpdateFailed?.Invoke(null, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void UpdateBooks(IEnumerable<LibraryBook> books)
|
||||
{
|
||||
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
|
||||
@@ -60,7 +146,7 @@ public static class SearchEngineCommands
|
||||
}
|
||||
}
|
||||
|
||||
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
||||
public static void FullReIndex() => performSafeCommand(e => fullReIndex(e));
|
||||
public static void FullReIndex(List<LibraryBook> libraryBooks)
|
||||
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
|
||||
|
||||
@@ -115,13 +201,15 @@ public static class SearchEngineCommands
|
||||
}
|
||||
}
|
||||
|
||||
private static void fullReIndex(SearchEngine engine)
|
||||
/// <returns>How many books could not be indexed.</returns>
|
||||
private static int fullReIndex(SearchEngine engine)
|
||||
{
|
||||
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
fullReIndex(engine, library);
|
||||
return fullReIndex(engine, library);
|
||||
}
|
||||
|
||||
private static void fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
|
||||
/// <returns>How many books could not be indexed.</returns>
|
||||
private static int fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
|
||||
=> engine.CreateNewIndex(libraryBooks);
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using DataLayer;
|
||||
using Serilog;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Answers "is the book you searched for in the trash?".
|
||||
/// A trashed book is filtered out of the library and out of the search index, so searching for one returns
|
||||
/// nothing at all and looks exactly like a book that was never imported (issue #1925).
|
||||
/// </summary>
|
||||
public static class TrashBinSearch
|
||||
{
|
||||
/// <summary>
|
||||
/// Books in the trash matching <paramref name="searchString"/>, using the same query syntax as the
|
||||
/// library search so a fielded query means the same thing in both places.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Indexes the trash on the spot rather than keeping an index in step with it. Call only once a library
|
||||
/// search has already come back empty: that happens on Enter, at human speed, so the work is rare.
|
||||
/// Returns nothing rather than throwing; this only ever powers a hint.
|
||||
/// </remarks>
|
||||
public static List<LibraryBook> Search(string? searchString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchString))
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
//GetDeletedLibraryBooks also returns parents so the trash grid can nest episodes; only the
|
||||
//genuinely deleted are candidates here.
|
||||
var trashed = DbContexts.GetDeletedLibraryBooks().Where(lb => lb.IsDeleted).ToList();
|
||||
if (trashed.Count == 0)
|
||||
return [];
|
||||
|
||||
var searchEngine = new TempSearchEngine();
|
||||
if (!searchEngine.ReindexSearchEngine(trashed))
|
||||
return [];
|
||||
|
||||
if (searchEngine.GetSearchResultSet(searchString) is not { } results)
|
||||
return [];
|
||||
|
||||
var matchedIds = results.Docs.Select(d => d.ProductId).ToHashSet();
|
||||
return trashed.Where(lb => matchedIds.Contains(lb.Book.AudibleProductId)).ToList();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Failed to search the trash bin for {searchString}", searchString);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Security;
|
||||
using LibationFileManager;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public class Account : IUpdatable
|
||||
[DebuggerDisplay("{AccountId,nq} - {Locale}")]
|
||||
public class Account : IUpdatable, ILogMasked
|
||||
{
|
||||
public event EventHandler? Updated;
|
||||
private void update(object? sender = null, EventArgs? e = null)
|
||||
@@ -46,15 +51,18 @@ public class Account : IUpdatable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>aka: activation bytes</summary>
|
||||
[AllowNull]
|
||||
public string? DecryptKey
|
||||
/// <summary>
|
||||
/// aka: activation bytes. A <see cref="SecretString"/> so that no reflective dump - Serilog's structured
|
||||
/// logging, or Serilog.Exceptions walking a logged exception - can reach the value. Persists as the same
|
||||
/// bare JSON string it always did.
|
||||
/// </summary>
|
||||
public SecretString DecryptKey
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
var v = (value ?? "").Trim();
|
||||
if (v == field)
|
||||
var v = (value.Reveal() ?? "").Trim();
|
||||
if (v == field.Reveal())
|
||||
return;
|
||||
field = v;
|
||||
update();
|
||||
@@ -80,16 +88,135 @@ public class Account : IUpdatable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The marketplace this account is registered with: where it logs in, and the only place its tokens can be
|
||||
/// refreshed. Every account has exactly one.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public Locale? Locale => IdentityTokens?.Locale;
|
||||
|
||||
private readonly List<string> _additionalLocaleNames = new();
|
||||
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// Further marketplaces this same login holds a library in, beyond <see cref="Locale"/>. A title bought while
|
||||
/// an Amazon address was temporarily set to another country stays in that country's library forever, and only
|
||||
/// a scan of that marketplace will ever see it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only marketplace names live here - no credentials. Audible honors one device registration across every
|
||||
/// marketplace, so these are read with the very tokens <see cref="Locale"/> registered.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Written as null rather than <c>[]</c> when empty, so that the settings file of an account with one
|
||||
/// marketplace - which is nearly all of them - is exactly what it was before this property existed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "AdditionalLocaleNames", NullValueHandling = NullValueHandling.Ignore)]
|
||||
private List<string>? _additionalLocaleNames_json
|
||||
{
|
||||
get => _additionalLocaleNames.Count == 0 ? null : _additionalLocaleNames;
|
||||
// 'set' is only used by json deser
|
||||
set
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
|
||||
_additionalLocaleNames.Clear();
|
||||
foreach (var name in value.Select(canonicalize).OfType<string>())
|
||||
if (!_additionalLocaleNames.Contains(name) && name != Locale?.Name)
|
||||
_additionalLocaleNames.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The additional marketplaces, resolved. Names that no longer match a known locale are dropped, as is the
|
||||
/// registered marketplace: json is applied in document order, so a file listing these before its
|
||||
/// IdentityTokens would slip a duplicate past the check on the way in, and this marketplace would then be
|
||||
/// scanned twice.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<Locale> AdditionalLocales
|
||||
=> _additionalLocaleNames
|
||||
.Select(Localization.Get)
|
||||
.Where(l => !string.IsNullOrEmpty(l.CountryCode) && l.Name != Locale?.Name)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Every marketplace a scan of this account should read: its own, then any extras. One account is scanned as
|
||||
/// a unit, so that a title found under one marketplace is never counted absent because another was scanned.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<Locale> ScanLocales
|
||||
=> Locale is null ? AdditionalLocales : new[] { Locale }.Concat(AdditionalLocales).ToList();
|
||||
|
||||
/// <summary>True if <paramref name="localeName"/> is this account's own marketplace or one of its extras.</summary>
|
||||
public bool HasMarketplace(string? localeName)
|
||||
=> canonicalize(localeName) is string name
|
||||
&& (name == Locale?.Name || _additionalLocaleNames.Contains(name));
|
||||
|
||||
/// <summary>Adds an extra marketplace. No-op if it is already this account's own, or already added.</summary>
|
||||
public bool AddMarketplace(string? localeName)
|
||||
{
|
||||
if (canonicalize(localeName) is not string name || HasMarketplace(name))
|
||||
return false;
|
||||
|
||||
_additionalLocaleNames.Add(name);
|
||||
update();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool RemoveMarketplace(string? localeName)
|
||||
{
|
||||
if (canonicalize(localeName) is not string name || !_additionalLocaleNames.Remove(name))
|
||||
return false;
|
||||
|
||||
update();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Replaces the extra marketplaces wholesale. Used by the accounts dialog, which edits a copy.</summary>
|
||||
public void SetAdditionalMarketplaces(IEnumerable<string?> localeNames)
|
||||
{
|
||||
var replacement = (localeNames ?? [])
|
||||
.Select(canonicalize)
|
||||
.OfType<string>()
|
||||
.Where(n => n != Locale?.Name)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (replacement.SequenceEqual(_additionalLocaleNames))
|
||||
return;
|
||||
|
||||
_additionalLocaleNames.Clear();
|
||||
_additionalLocaleNames.AddRange(replacement);
|
||||
update();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store the locale's canonical name, so that a country code ('de') and an internal name ('germany') cannot
|
||||
/// end up in the list as two separate marketplaces.
|
||||
/// </summary>
|
||||
private static string? canonicalize(string? localeName)
|
||||
{
|
||||
var locale = Localization.Get(localeName);
|
||||
return string.IsNullOrEmpty(locale.CountryCode) ? null : locale.Name;
|
||||
}
|
||||
|
||||
public Account(string accountId)
|
||||
{
|
||||
AccountId = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountId, nameof(accountId)).Trim();
|
||||
}
|
||||
|
||||
public override string ToString() => $"{AccountId} - {Locale?.Name ?? "[empty]"}";
|
||||
/// <summary>
|
||||
/// Masked, because this is what interpolation and non-destructured logging reach for. Use
|
||||
/// <see cref="AccountCredentialStatus.FormatAccountLabel"/> for dialogs shown to the account's owner, and see
|
||||
/// the DebuggerDisplay above for the unmasked form while debugging.
|
||||
/// </summary>
|
||||
public override string ToString() => MaskedLogEntry;
|
||||
|
||||
/// <summary>Derived from the fields above, so persisting it would only add a stale copy of them.</summary>
|
||||
[JsonIgnore]
|
||||
public string MaskedLogEntry => @$"AccountId={mask(AccountId)}|AccountName={mask(AccountName)}|Locale={Locale?.Name ?? "[empty]"}";
|
||||
private static string mask(string? str)
|
||||
=> str is null ? "[null]"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>Describes whether an account has usable stored Audible tokens.</summary>
|
||||
public static class AccountCredentialStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// True when identity tokens are absent or carry no refresh token, meaning the account was never fully logged
|
||||
/// in or its credentials were cleared, rather than merely holding an expired access token.
|
||||
/// </summary>
|
||||
public static bool LooksLikeMissingCredentials(Account? account)
|
||||
{
|
||||
if (account?.IdentityTokens is not { } tokens)
|
||||
return true;
|
||||
|
||||
if (tokens.IsValid)
|
||||
return false;
|
||||
|
||||
// without a refresh token there is nothing to renew from, so this needs a fresh login rather than a retry
|
||||
return string.IsNullOrWhiteSpace(tokens.RefreshToken?.Reveal());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Account label for dialogs shown to the person who owns the account. Not for logs: those get attached to
|
||||
/// public issue reports, which is what <see cref="Account.MaskedLogEntry"/> is for.
|
||||
/// </summary>
|
||||
public static string FormatAccountLabel(Account? account)
|
||||
{
|
||||
if (account is null)
|
||||
return "an Audible account";
|
||||
|
||||
return string.IsNullOrWhiteSpace(account.AccountName) || account.AccountName.EqualsInsensitive(account.AccountId)
|
||||
? $"'{account.AccountId}'"
|
||||
: $"'{account.AccountName}' ({account.AccountId})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// What a failure needs to say about an account, captured so a live <see cref="Account"/> - with its tokens and
|
||||
/// its activation bytes - never rides along on an exception into a log file.
|
||||
/// <para>
|
||||
/// Logs get attached to public issue reports, and Serilog.Exceptions writes every public property of a logged
|
||||
/// exception into one, following nested objects as it goes. So everything public here is masked, and the label
|
||||
/// meant for the account's owner is reachable only through a method: reflection reads properties and never calls
|
||||
/// methods.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AccountSummary
|
||||
{
|
||||
/// <summary>Safe to log. Also what <see cref="ToString"/> returns, so interpolating this cannot leak.</summary>
|
||||
public string MaskedLogEntry { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the account was never fully logged in or its credentials were cleared, rather than merely
|
||||
/// holding an expired session.
|
||||
/// </summary>
|
||||
public bool LooksLikeMissingCredentials { get; }
|
||||
|
||||
private readonly string ownerFacingLabel;
|
||||
|
||||
private AccountSummary(Account account)
|
||||
{
|
||||
MaskedLogEntry = account.MaskedLogEntry;
|
||||
LooksLikeMissingCredentials = AccountCredentialStatus.LooksLikeMissingCredentials(account);
|
||||
ownerFacingLabel = AccountCredentialStatus.FormatAccountLabel(account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The name and address to show the person who owns the account, on their own screen. Never log this: use
|
||||
/// <see cref="MaskedLogEntry"/>.
|
||||
/// </summary>
|
||||
public string RevealOwnerFacingLabel() => ownerFacingLabel;
|
||||
|
||||
public override string ToString() => MaskedLogEntry;
|
||||
|
||||
public static AccountSummary? From(Account? account)
|
||||
=> account is null ? null : new AccountSummary(account);
|
||||
}
|
||||
@@ -97,6 +97,7 @@ public class AccountsSettings : IUpdatable
|
||||
public void Add(Account account)
|
||||
{
|
||||
_add(account);
|
||||
Serilog.Log.Logger.Information("Added Audible account {Account}", account.MaskedLogEntry);
|
||||
update_no_validate();
|
||||
}
|
||||
|
||||
@@ -108,12 +109,46 @@ public class AccountsSettings : IUpdatable
|
||||
account.Updated += update;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account that can speak to <paramref name="locale"/> for this login: the one registered with that
|
||||
/// marketplace, or failing that the one carrying it as an extra marketplace. Callers hand this a book's
|
||||
/// marketplace and get back the credentials that can license it, which is why extras have to resolve here
|
||||
/// as well as the registered marketplace does.
|
||||
/// </summary>
|
||||
public Account? GetAccount(string accountId, string? locale)
|
||||
{
|
||||
if (locale is null)
|
||||
return null;
|
||||
|
||||
return Accounts.SingleOrDefault(a => a.AccountId == accountId && a.Locale?.Name == locale);
|
||||
// AccountId is compared case-insensitively: Audible/library data has been observed to differ
|
||||
// only by letter case (e.g. a stored id capitalized differently than settings), which caused
|
||||
// spurious "No account found" failures that blocked every affected book. See issue #1931.
|
||||
var registered = Accounts.SingleOrDefault(a =>
|
||||
a.AccountId.EqualsInsensitive(accountId)
|
||||
&& a.Locale?.Name == locale);
|
||||
|
||||
if (registered is not null)
|
||||
return registered;
|
||||
|
||||
return Accounts.FirstOrDefault(a =>
|
||||
a.AccountId.EqualsInsensitive(accountId)
|
||||
&& a.HasMarketplace(locale));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account already scanning <paramref name="localeName"/> for this login, if any. Adding a marketplace,
|
||||
/// importing an audible-cli file, and probing all need to know whether a marketplace is spoken for - including
|
||||
/// by a second row for the same login, which is how multiple marketplaces were handled before one account
|
||||
/// could hold several.
|
||||
/// </summary>
|
||||
public Account? GetAccountClaimingMarketplace(string accountId, string? localeName, Account? excluding = null)
|
||||
{
|
||||
var name = Localization.Get(localeName).Name;
|
||||
|
||||
return Accounts.FirstOrDefault(a =>
|
||||
!ReferenceEquals(a, excluding)
|
||||
&& a.AccountId.EqualsInsensitive(accountId)
|
||||
&& a.HasMarketplace(name));
|
||||
}
|
||||
|
||||
public bool Delete(string accountId, string locale)
|
||||
@@ -131,28 +166,23 @@ public class AccountsSettings : IUpdatable
|
||||
|
||||
account.Updated -= update;
|
||||
var result = _accounts_backing.Remove(account);
|
||||
if (result)
|
||||
Serilog.Log.Logger.Information("Removed Audible account {Account}", account.MaskedLogEntry);
|
||||
update_no_validate();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No two rows for one login may scan the same marketplace, or a scan would import it twice and a download
|
||||
/// would have two accounts to choose from. Extra marketplaces count: claiming 'us' as an extra collides with
|
||||
/// another row registered with 'us' just as surely as two 'us' registrations would.
|
||||
/// </summary>
|
||||
private void validate(Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
var accountId = account.AccountId;
|
||||
var locale = account?.IdentityTokens?.Locale?.Name;
|
||||
|
||||
var acct = GetAccount(accountId, locale);
|
||||
|
||||
// new: ok
|
||||
if (acct is null)
|
||||
return;
|
||||
|
||||
// same account instance: ok
|
||||
if (acct == account)
|
||||
return;
|
||||
|
||||
// same account id + locale, different instance: bad
|
||||
throw new InvalidOperationException("Cannot add an account with the same account Id and Locale");
|
||||
foreach (var locale in account.ScanLocales)
|
||||
if (GetAccountClaimingMarketplace(account.AccountId, locale.Name, excluding: account) is not null)
|
||||
throw new InvalidOperationException("Cannot add an account with the same account Id and Locale");
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,9 @@ using LibationFileManager;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
@@ -23,6 +19,10 @@ public class ApiExtended
|
||||
|
||||
private const int MaxConcurrency = 10;
|
||||
private const int BatchSize = 50;
|
||||
/// <summary>Extra attempts to re-request catalog products that Audible omitted from an otherwise successful response.</summary>
|
||||
private const int MissingAsinRetries = 2;
|
||||
/// <summary>Upper bound on how many dropped titles a single log entry will name.</summary>
|
||||
private const int MaxLoggedTitles = 50;
|
||||
|
||||
private ApiExtended(Api api) => Api = api;
|
||||
|
||||
@@ -32,7 +32,11 @@ public class ApiExtended
|
||||
public static Task<ApiExtended> CreateAsync(Account account)
|
||||
=> CreateAsync(account, allowInteractiveLogin: true);
|
||||
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, bool allowInteractiveLogin)
|
||||
/// <param name="storeLocale">
|
||||
/// The marketplace to read, when it is not the one the account is registered with. The account's own tokens
|
||||
/// are used either way; only the store host changes. Null means the account's own marketplace.
|
||||
/// </param>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, bool allowInteractiveLogin, Locale? storeLocale = null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
|
||||
@@ -42,32 +46,43 @@ public class ApiExtended
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
AccountMaskedLogEntry = account.MaskedLogEntry
|
||||
AccountMaskedLogEntry = account.MaskedLogEntry,
|
||||
StoreLocaleName = (storeLocale ?? locale).Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
account.GetIdentityTokensJsonPath(),
|
||||
storeLocale);
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!allowInteractiveLogin || LoginChoiceFactory is null)
|
||||
{
|
||||
// an account that was never logged in needs a first login, not a renewal, and saying so saves the
|
||||
// user hunting for an expired session that never existed
|
||||
var missingCredentials = AccountCredentialStatus.LooksLikeMissingCredentials(account);
|
||||
|
||||
Serilog.Log.Logger.Error(ex,
|
||||
"Stored credentials could not be used and interactive login is not available. {@DebugInfo}",
|
||||
missingCredentials
|
||||
? "Stored credentials are missing or incomplete and interactive login is not available. {@DebugInfo}"
|
||||
: "Stored credentials could not be used and interactive login is not available. {@DebugInfo}",
|
||||
new
|
||||
{
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name,
|
||||
AllowInteractiveLogin = allowInteractiveLogin,
|
||||
LoginChoiceFactorySet = LoginChoiceFactory is not null
|
||||
LoginChoiceFactorySet = LoginChoiceFactory is not null,
|
||||
LooksLikeMissingCredentials = missingCredentials
|
||||
});
|
||||
|
||||
// masked: this message is logged verbatim, both as {Exception} and as ExceptionDetail.Message
|
||||
throw new AuthenticationRequiredException(
|
||||
account,
|
||||
message: $"Stored credentials for '{account.AccountId}' could not be used"
|
||||
message: $"Stored credentials for {account.MaskedLogEntry} "
|
||||
+ (missingCredentials ? "are missing or incomplete" : "could not be used")
|
||||
+ (LoginChoiceFactory is null
|
||||
? " and interactive login is not available in this context (CLI/Docker)."
|
||||
: " and interactive login was not allowed.")
|
||||
@@ -89,7 +104,17 @@ public class ApiExtended
|
||||
LoginChoiceFactory(account),
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
account.GetIdentityTokensJsonPath(),
|
||||
Configuration.Instance.GetDeviceRegistrationProfile());
|
||||
|
||||
// login happens against the account's own marketplace, so the api it hands back reads that one.
|
||||
// re-create once the tokens exist if some other marketplace is the one actually wanted.
|
||||
if (storeLocale is not null && storeLocale.Name != locale.Name)
|
||||
api = await EzApiCreator.GetApiAsync(
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath(),
|
||||
storeLocale);
|
||||
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
@@ -135,7 +160,14 @@ public class ApiExtended
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Beginning library scan.");
|
||||
|
||||
List<Item> items = new();
|
||||
//Read the import filters once so a settings change mid-scan can't produce a half-filtered library.
|
||||
var importEpisodes = Configuration.Instance.ImportEpisodes;
|
||||
var importPlusTitles = Configuration.Instance.ImportPlusTitles;
|
||||
|
||||
var items = new List<Item>();
|
||||
var libraryItemCount = 0;
|
||||
var episodeItemsExcluded = 0;
|
||||
var plusTitlesExcluded = 0;
|
||||
var sw = Stopwatch.StartNew();
|
||||
var totalTime = TimeSpan.Zero;
|
||||
using var semaphore = new SemaphoreSlim(MaxConcurrency);
|
||||
@@ -147,7 +179,9 @@ public class ApiExtended
|
||||
//Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried.
|
||||
await foreach (var itemsBatch in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore))
|
||||
{
|
||||
if (Configuration.Instance.ImportEpisodes)
|
||||
libraryItemCount += itemsBatch.Length;
|
||||
|
||||
if (importEpisodes)
|
||||
{
|
||||
var episodes = itemsBatch.Where(i => i.IsEpisodes).ToList();
|
||||
var series = itemsBatch.Where(i => i.IsSeriesParent).ToList();
|
||||
@@ -170,11 +204,18 @@ public class ApiExtended
|
||||
items.AddRange(episodes);
|
||||
items.AddRange(series);
|
||||
}
|
||||
else
|
||||
episodeItemsExcluded += itemsBatch.Count(i => i.IsSeriesParent || i.IsEpisodes);
|
||||
|
||||
var booksInBatch = itemsBatch.Where(i => !i.IsSeriesParent && !i.IsEpisodes).ToList();
|
||||
|
||||
if (!importPlusTitles)
|
||||
{
|
||||
var ownedInBatch = booksInBatch.Where(i => i.IsAyce is not true).ToList();
|
||||
plusTitlesExcluded += booksInBatch.Count - ownedInBatch.Count;
|
||||
booksInBatch = ownedInBatch;
|
||||
}
|
||||
|
||||
var booksInBatch
|
||||
= itemsBatch
|
||||
.Where(i => !i.IsSeriesParent && !i.IsEpisodes)
|
||||
.Where(i => i.IsAyce is not true || Configuration.Instance.ImportPlusTitles);
|
||||
items.AddRange(booksInBatch);
|
||||
}
|
||||
|
||||
@@ -197,21 +238,30 @@ public class ApiExtended
|
||||
Serilog.Log.Logger.Debug("Begin indexing series episodes");
|
||||
items.AddRange(allEps);
|
||||
|
||||
//Set the Item.Series info for episodes and parents.
|
||||
foreach (var parent in items.Where(i => i.IsSeriesParent))
|
||||
{
|
||||
var children = items.Where(i => i.IsEpisodes && i.Relationships?.Any(r => r.Asin == parent.Asin) is true);
|
||||
SetSeries(parent, children);
|
||||
}
|
||||
|
||||
int orphansRemoved = items.RemoveAll(i => (i.IsEpisodes || i.IsSeriesParent) && i.Series is null);
|
||||
if (orphansRemoved > 0)
|
||||
Serilog.Log.Debug("{orphansRemoved} podcast orphans not imported", orphansRemoved);
|
||||
//Name the casualties: an unlinked episode silently vanishes from the library, and without this
|
||||
//there is nothing in the log to explain why.
|
||||
var orphans = LinkEpisodesToSeries(items).Select(describe).Distinct().ToList();
|
||||
if (orphans.Count > 0)
|
||||
Serilog.Log.Logger.Warning(
|
||||
"{orphansRemoved} podcast episodes were not imported because their series parent was missing from this scan. {@DebugInfo}",
|
||||
orphans.Count,
|
||||
new { Orphans = orphans.Take(MaxLoggedTitles).ToList(), Truncated = orphans.Count > MaxLoggedTitles });
|
||||
|
||||
sw.Stop();
|
||||
totalTime += sw.Elapsed;
|
||||
Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds);
|
||||
Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms.");
|
||||
Serilog.Log.Logger.Information("Library scan tally. {@DebugInfo}", new
|
||||
{
|
||||
LibraryItems = libraryItemCount,
|
||||
EpisodesFetched = allEps.Count,
|
||||
OrphanedEpisodesDropped = orphans.Count,
|
||||
ImportEpisodes = importEpisodes,
|
||||
EpisodeItemsExcluded = episodeItemsExcluded,
|
||||
ImportPlusTitles = importPlusTitles,
|
||||
PlusTitlesExcluded = plusTitlesExcluded,
|
||||
ItemsToImport = items.Count
|
||||
});
|
||||
|
||||
Array.ForEach(ISanitizer.GetAllSanitizers(), s => s.Sanitize(items));
|
||||
var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)).ToList();
|
||||
@@ -219,6 +269,32 @@ public class ApiExtended
|
||||
throw new ImportValidationException(items, allExceptions);
|
||||
|
||||
return items;
|
||||
|
||||
static string describe(Item item) => $"[{item.Asin}] {item.Title}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set <see cref="Item.Series"/> on every series parent in <paramref name="items"/> and on the episodes
|
||||
/// belonging to it, then drop the episodes and parents left unlinked. An episode is left unlinked when
|
||||
/// its parent is not among <paramref name="items"/>, which happens when Audible omits the parent from a
|
||||
/// catalog response or when the parent is a container Libation does not treat as a series (e.g. a season).
|
||||
/// </summary>
|
||||
/// <returns>The unlinked items removed from <paramref name="items"/>.</returns>
|
||||
public static List<Item> LinkEpisodesToSeries(List<Item> items)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(items, nameof(items));
|
||||
|
||||
foreach (var parent in items.Where(i => i.IsSeriesParent))
|
||||
{
|
||||
var children = items.Where(i => i.IsEpisodes && i.Relationships?.Any(r => r.Asin == parent.Asin) is true);
|
||||
SetSeries(parent, children);
|
||||
}
|
||||
|
||||
var unlinked = items.Where(isUnlinked).ToList();
|
||||
items.RemoveAll(isUnlinked);
|
||||
return unlinked;
|
||||
|
||||
static bool isUnlinked(Item item) => (item.IsEpisodes || item.IsSeriesParent) && item.Series is null;
|
||||
}
|
||||
|
||||
#region episodes and podcasts
|
||||
@@ -260,15 +336,27 @@ public class ApiExtended
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media
|
||||
| CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc
|
||||
| CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview
|
||||
| CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series
|
||||
| CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs);
|
||||
|
||||
//Audible sometimes omits products from a response that is otherwise successful. Those episodes
|
||||
//would silently disappear from the library, so re-request them before accepting the loss.
|
||||
var (items, missing) = await FetchRetryingMissingAsync(
|
||||
asins,
|
||||
getCatalogProductsAsync,
|
||||
MissingAsinRetries,
|
||||
attempt => Serilog.Log.Logger.Debug($"Batch {batchNum} Retry {attempt}: Re-fetching asins Audible did not return"));
|
||||
|
||||
sw.Stop();
|
||||
|
||||
Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms");
|
||||
|
||||
if (missing.Count > 0)
|
||||
Serilog.Log.Logger.Warning(
|
||||
"Audible did not return {missingCount} of the {requestedCount} catalog products requested in batch {batchNum}. Those titles are missing from this scan. {@DebugInfo}",
|
||||
missing.Count,
|
||||
asins.Count,
|
||||
batchNum,
|
||||
new { MissingAsins = missing });
|
||||
|
||||
return items;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -279,6 +367,52 @@ public class ApiExtended
|
||||
finally { semaphore.Release(); }
|
||||
}
|
||||
|
||||
private Task<List<Item>> getCatalogProductsAsync(List<string> asins)
|
||||
=> Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media
|
||||
| CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc
|
||||
| CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview
|
||||
| CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series
|
||||
| CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs);
|
||||
|
||||
/// <summary>Requested asins for which <paramref name="received"/> holds no matching <see cref="Item"/>.</summary>
|
||||
public static List<string> GetMissingAsins(IEnumerable<string> requested, IEnumerable<Item> received)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(requested, nameof(requested));
|
||||
ArgumentValidator.EnsureNotNull(received, nameof(received));
|
||||
|
||||
var found = received.Select(i => i.Asin).OfType<string>().ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return requested.Where(a => !found.Contains(a)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch <paramref name="asins"/>, re-requesting any that <paramref name="fetch"/> fails to return.
|
||||
/// Audible's catalog endpoint can answer 200 while quietly omitting products.
|
||||
/// </summary>
|
||||
/// <returns>Everything that was returned, plus the asins still unaccounted for after the last attempt.</returns>
|
||||
public static async Task<(List<Item> Items, List<string> Missing)> FetchRetryingMissingAsync(
|
||||
List<string> asins,
|
||||
Func<List<string>, Task<List<Item>>> fetch,
|
||||
int maxRetries,
|
||||
Action<int>? onRetry = null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(asins, nameof(asins));
|
||||
ArgumentValidator.EnsureNotNull(fetch, nameof(fetch));
|
||||
|
||||
var items = await fetch(asins);
|
||||
var missing = GetMissingAsins(asins, items);
|
||||
|
||||
for (var attempt = 1; attempt <= maxRetries && missing.Count > 0; attempt++)
|
||||
{
|
||||
onRetry?.Invoke(attempt);
|
||||
|
||||
var retriedItems = await fetch(missing);
|
||||
items.AddRange(retriedItems);
|
||||
missing = GetMissingAsins(missing, retriedItems);
|
||||
}
|
||||
|
||||
return (items, missing);
|
||||
}
|
||||
|
||||
public static void SetSeries(Item parent, IEnumerable<Item> children)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(parent, nameof(parent));
|
||||
@@ -307,21 +441,21 @@ public class ApiExtended
|
||||
}
|
||||
|
||||
int lastEpNum = -1, dupeCount = 0;
|
||||
foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime))
|
||||
foreach (var child in children.OrderBy(i => UsableEpisodeNumber(i)).ThenBy(i => i.PublicationDateTime))
|
||||
{
|
||||
string sequence;
|
||||
if (child.EpisodeNumber is null)
|
||||
var episodeNumber = UsableEpisodeNumber(child);
|
||||
if (episodeNumber is null)
|
||||
{
|
||||
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||
sequence = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0";
|
||||
sequence = FallbackSeriesSequence(parent, child);
|
||||
}
|
||||
else
|
||||
{
|
||||
//multipart episodes may have the same episode number
|
||||
if (child.EpisodeNumber == lastEpNum)
|
||||
if (episodeNumber == lastEpNum)
|
||||
dupeCount++;
|
||||
else
|
||||
lastEpNum = child.EpisodeNumber.Value;
|
||||
lastEpNum = episodeNumber.Value;
|
||||
|
||||
sequence = (lastEpNum + dupeCount).ToString();
|
||||
}
|
||||
@@ -340,5 +474,69 @@ public class ApiExtended
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nine digits. Big enough for YYYYMMDD-style numbering; too small for unix timestamps,
|
||||
/// Integer.MAX_VALUE, and the other sentinel integers Audible has sent as episode order (issue #2024).
|
||||
/// </summary>
|
||||
private const long MaxPlausibleSeriesOrder = 1_000_000_000;
|
||||
|
||||
private static bool IsPlausibleSeriesOrder(long n)
|
||||
=> n >= 0 && n < MaxPlausibleSeriesOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Audible sometimes serializes a missing episode_number as a sentinel integer (Integer.MAX_VALUE
|
||||
/// was the one in #2024) instead of omitting the field. Treat any implausibly large value the same way.
|
||||
/// </summary>
|
||||
private static int? UsableEpisodeNumber(Item child)
|
||||
=> child.EpisodeNumber is int n && IsPlausibleSeriesOrder(n) ? n : null;
|
||||
|
||||
/// <summary>
|
||||
/// When episode_number is missing or implausibly large, use relationship sort/sequence
|
||||
/// or the catalog series sequence. Prefer the parent's child relationship (the historical source),
|
||||
/// then the child's parent relationship, then any series sequence Audible already attached.
|
||||
/// </summary>
|
||||
private static string FallbackSeriesSequence(Item parent, Item child)
|
||||
{
|
||||
var fromParent = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin);
|
||||
if (UsableRelationshipOrder(fromParent) is string parentOrder)
|
||||
return parentOrder;
|
||||
|
||||
var fromChild = child.Relationships?.FirstOrDefault(r => r.Asin == parent.Asin);
|
||||
if (UsableRelationshipOrder(fromChild) is string childOrder)
|
||||
return childOrder;
|
||||
|
||||
var catalogSequence = child.Series?.FirstOrDefault(s => s.Asin == parent.Asin)?.Sequence;
|
||||
if (IsUsableOrderString(catalogSequence))
|
||||
return catalogSequence!;
|
||||
|
||||
return "0";
|
||||
}
|
||||
|
||||
private static string? UsableRelationshipOrder(Relationship? relationship)
|
||||
{
|
||||
if (relationship is null)
|
||||
return null;
|
||||
if (IsUsableSort(relationship.Sort))
|
||||
return relationship.Sort!.Value.ToString();
|
||||
if (IsUsableOrderString(relationship.Sequence))
|
||||
return relationship.Sequence;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsUsableSort(long? sort)
|
||||
=> sort is long n && IsPlausibleSeriesOrder(n);
|
||||
|
||||
/// <summary>
|
||||
/// Bare integers that are too large are sentinels or timestamps. Mixed forms like "1-6" or "2.1"
|
||||
/// are real series orders and are left alone.
|
||||
/// </summary>
|
||||
private static bool IsUsableOrderString(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value == "-1")
|
||||
return false;
|
||||
return !long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
|
||||
|| IsPlausibleSeriesOrder(n);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -7,8 +7,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="10.3.4.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.36.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<PublicAssets>runtime</PublicAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -28,4 +30,8 @@
|
||||
<DependentUpon>Cdm.cs</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,5 +1,3 @@
|
||||
using AudibleApi.Authentication;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public static class AuthenticationExceptionHelper
|
||||
@@ -14,7 +12,7 @@ public static class AuthenticationExceptionHelper
|
||||
|
||||
for (var current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is AuthenticationRequiredException or LoginFailedException)
|
||||
if (current is AuthenticationRequiredException)
|
||||
return true;
|
||||
|
||||
if (current is InvalidOperationException { Message: var message }
|
||||
@@ -24,4 +22,28 @@ public static class AuthenticationExceptionHelper
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the <see cref="AuthenticationRequiredException"/> in <paramref name="ex"/> or its inner chain, which is
|
||||
/// the one that knows which account needs a login.
|
||||
/// </summary>
|
||||
public static AuthenticationRequiredException? FindAuthenticationRequired(Exception ex)
|
||||
{
|
||||
for (var current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is AuthenticationRequiredException auth)
|
||||
return auth;
|
||||
}
|
||||
|
||||
if (ex is AggregateException aggregate)
|
||||
{
|
||||
foreach (var inner in aggregate.InnerExceptions)
|
||||
{
|
||||
if (FindAuthenticationRequired(inner) is { } found)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,14 @@ namespace AudibleUtilities;
|
||||
/// </summary>
|
||||
public sealed class AuthenticationRequiredException : Exception
|
||||
{
|
||||
public Account? Account { get; }
|
||||
/// <summary>
|
||||
/// A log-safe summary rather than the <see cref="Account"/> itself. Serilog.Exceptions reflects over every
|
||||
/// public property of a logged exception, so holding the live account published its address - and would have
|
||||
/// published its activation bytes - into logs people attach to public issue reports.
|
||||
/// </summary>
|
||||
public AccountSummary? AccountInfo { get; }
|
||||
|
||||
public AuthenticationRequiredException(Account? account, string? message = null, Exception? innerException = null)
|
||||
: base(message ?? "Audible authentication is required.", innerException)
|
||||
=> Account = account;
|
||||
=> AccountInfo = AccountSummary.From(account);
|
||||
}
|
||||
@@ -20,5 +20,25 @@ public static class Extensions
|
||||
.Select(p => p.EndDate)
|
||||
.FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now)
|
||||
?.DateTime;
|
||||
|
||||
/// <summary>
|
||||
/// The book's summary with Audible's HTML markup flattened away, ready to be stored and used
|
||||
/// as-is. See <see cref="HtmlText.ToPlainText"/>.
|
||||
/// </summary>
|
||||
public string PlainTextDescription()
|
||||
=> HtmlText.ToPlainText(item.Description);
|
||||
|
||||
/// <summary>
|
||||
/// The publisher's copyright line, e.g. "©2024 Bentley Little (P)2025 Journalstone".
|
||||
/// Only present when the product_details response group was requested, and often null anyway.
|
||||
/// </summary>
|
||||
/// <remarks><see cref="Item.Copyright"/> is typed as <see cref="object"/> because Audible has
|
||||
/// never been observed returning anything but a string or null there. Anything else is
|
||||
/// discarded rather than stringified, so a shape we do not understand cannot reach a file tag.
|
||||
/// </remarks>
|
||||
public string? CopyrightString()
|
||||
=> item.Copyright is string copyright && !string.IsNullOrWhiteSpace(copyright)
|
||||
? copyright.Trim()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Audible returns a book's summary as HTML. Nothing downstream of the API renders markup - not the
|
||||
/// library grid, not the exports, and least of all the metadata tags written into an audio file - so
|
||||
/// the markup is flattened here, on the way in, rather than carried around and stripped at each use.
|
||||
/// </summary>
|
||||
public static partial class HtmlText
|
||||
{
|
||||
/// <param name="paragraphSeparator">Joins the block-level runs. A single newline matches what
|
||||
/// Audible itself embeds in the description tags of its .aaxc files.</param>
|
||||
public static string ToPlainText(string? html, string paragraphSeparator = "\n")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return "";
|
||||
|
||||
// Not every summary is marked up. Running the unmarked ones through the parser risks a stray
|
||||
// '<' swallowing the rest of the sentence, and buys nothing.
|
||||
if (!html.Contains('<'))
|
||||
return (WebUtility.HtmlDecode(html) ?? html).Trim();
|
||||
|
||||
// Replace block-level boundaries with newlines, then strip all remaining tags.
|
||||
var stripped = StripTags().Replace(BlockBoundary().Replace(html, "\n"), "");
|
||||
|
||||
var text = WebUtility.HtmlDecode(stripped) ?? stripped;
|
||||
|
||||
var paragraphs = text
|
||||
.Replace("\r\n", "\n")
|
||||
.Replace('\r', '\n')
|
||||
.Split('\n')
|
||||
.Select(line => line.Trim())
|
||||
.Where(line => line.Length > 0);
|
||||
|
||||
return string.Join(paragraphSeparator, paragraphs);
|
||||
}
|
||||
|
||||
/// <summary>Every tag that ends a run of text. A line break counts as a paragraph break because
|
||||
/// Audible's summaries mark up paragraphs and nothing finer.</summary>
|
||||
[GeneratedRegex(@"<\s*br\s*/?\s*>|<\s*/\s*(?:p|div|li|tr|blockquote|h[1-6])\s*>", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BlockBoundary();
|
||||
|
||||
/// <summary>Matches any HTML/XML tag.</summary>
|
||||
[GeneratedRegex(@"<[^>]+>")]
|
||||
private static partial Regex StripTags();
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using AudibleApi;
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public enum MarketplaceProbeOutcome
|
||||
{
|
||||
/// <summary>Already scanned: this account's own marketplace, or one it has already been given.</summary>
|
||||
AlreadyScanned,
|
||||
/// <summary>Scanned by a different account row for the same login - the pre-existing way to hold two marketplaces.</summary>
|
||||
ScannedByAnotherAccount,
|
||||
/// <summary>The marketplace answered, and holds titles.</summary>
|
||||
TitlesFound,
|
||||
/// <summary>The marketplace answered, and holds nothing.</summary>
|
||||
Empty,
|
||||
/// <summary>The marketplace could not be asked. Says nothing either way about what is there.</summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <param name="TitleCount">Titles the marketplace reports, or null when it was not asked or did not answer.</param>
|
||||
/// <param name="ClaimedBy">For <see cref="MarketplaceProbeOutcome.ScannedByAnotherAccount"/>, the account already scanning it.</param>
|
||||
public record MarketplaceProbeResult(
|
||||
Locale Locale,
|
||||
MarketplaceProbeOutcome Outcome,
|
||||
int? TitleCount = null,
|
||||
string? ClaimedBy = null,
|
||||
string? Error = null)
|
||||
{
|
||||
/// <summary>Whether adding this marketplace to the account is something the user can choose to do.</summary>
|
||||
public bool CanAdd => Outcome is MarketplaceProbeOutcome.TitlesFound or MarketplaceProbeOutcome.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// Asks each Audible marketplace whether this login holds anything there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A title bought while an Amazon address was briefly set to another country stays in that country's library for
|
||||
/// good, and a scan of the account's own marketplace will never see it - no error, no warning, the titles are
|
||||
/// simply absent. One device registration is honored by every marketplace, so the only thing standing between
|
||||
/// those titles and a scan is knowing which marketplace to look in. That is what this answers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One request per marketplace, in sequence: enough to get a count, and no more traffic or concurrency than a
|
||||
/// user who clicked a button should generate. Nothing here runs on its own - the accounts dialog asks for it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MarketplaceProbe
|
||||
{
|
||||
/// <summary>Space between requests. A deliberate trickle rather than a fan-out.</summary>
|
||||
public static TimeSpan RequestSpacing { get; set; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>
|
||||
/// The marketplaces worth asking about for this account. Pre-Amazon locales and modern ones are separate
|
||||
/// worlds with their own logins, so only the account's own kind is probed; asking about the rest would double
|
||||
/// the traffic to learn nothing.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<Locale> CandidateLocales(Account account)
|
||||
{
|
||||
var withUsername = account?.Locale?.WithUsername ?? false;
|
||||
|
||||
return Localization.Locales
|
||||
.Where(l => l.WithUsername == withUsername)
|
||||
.OrderBy(l => l.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe every candidate marketplace, yielding each result as it arrives so a dialog can fill in a row at a
|
||||
/// time. Never throws for a single marketplace: one that cannot be reached is reported as such and the rest
|
||||
/// carry on.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<MarketplaceProbeResult> ProbeAsync(
|
||||
Account account,
|
||||
AccountsSettings accountsSettings,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
ArgumentValidator.EnsureNotNull(accountsSettings, nameof(accountsSettings));
|
||||
|
||||
var first = true;
|
||||
|
||||
foreach (var locale in CandidateLocales(account))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (account.HasMarketplace(locale.Name))
|
||||
{
|
||||
yield return new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.AlreadyScanned);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (accountsSettings.GetAccountClaimingMarketplace(account.AccountId, locale.Name, excluding: account) is { } other)
|
||||
{
|
||||
yield return new MarketplaceProbeResult(
|
||||
locale,
|
||||
MarketplaceProbeOutcome.ScannedByAnotherAccount,
|
||||
ClaimedBy: AccountCredentialStatus.FormatAccountLabel(other));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!first)
|
||||
await Task.Delay(RequestSpacing, cancellationToken);
|
||||
first = false;
|
||||
|
||||
yield return await probeOneAsync(account, locale);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<MarketplaceProbeResult> probeOneAsync(Account account, Locale locale)
|
||||
{
|
||||
try
|
||||
{
|
||||
// no interactive login: the whole premise is that this account's existing tokens already work here
|
||||
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin: false, storeLocale: locale);
|
||||
|
||||
var count = await apiExtended.Api.GetItemsCountAsync(
|
||||
new LibraryOptions { PurchasedAfter = new DateTime(1970, 1, 1) });
|
||||
|
||||
Serilog.Log.Logger.Information(
|
||||
"Marketplace probe: {LocaleName} reported {TitleCount} titles. {@DebugInfo}",
|
||||
locale.Name,
|
||||
count,
|
||||
new { Account = account.MaskedLogEntry });
|
||||
|
||||
// -1 means Audible answered without the count header. it answered, so the marketplace is reachable;
|
||||
// treat it as reachable-but-unknown rather than claiming there is nothing there
|
||||
return count switch
|
||||
{
|
||||
> 0 => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.TitlesFound, count),
|
||||
0 => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Empty, 0),
|
||||
_ => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Empty)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Information(
|
||||
ex,
|
||||
"Marketplace probe: {LocaleName} could not be checked. {@DebugInfo}",
|
||||
locale.Name,
|
||||
new { Account = account.MaskedLogEntry });
|
||||
|
||||
return new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Failed, Error: ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using AudibleApi.Authorization;
|
||||
using AudibleApi.Cryptography;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Security;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
@@ -208,7 +209,7 @@ public partial class Mkb79Auth
|
||||
|
||||
var authorize = new Authorize(Locale);
|
||||
var newToken = await authorize.RefreshAccessTokenAsync(refreshToken);
|
||||
AccessToken = newToken.TokenValue;
|
||||
AccessToken = newToken.Reveal();
|
||||
AccessTokenExpires = newToken.Expires;
|
||||
|
||||
var api = new Api(this);
|
||||
@@ -226,7 +227,7 @@ public partial class Mkb79Auth
|
||||
adpToken,
|
||||
newToken,
|
||||
refreshToken,
|
||||
WebsiteCookies?.Select(c => new KeyValuePair<string, string?>(c.Key, c.Value)),
|
||||
WebsiteCookies?.Select(c => new KeyValuePair<string, SecretString>(c.Key, c.Value)),
|
||||
DeviceSerialNumber,
|
||||
DeviceType,
|
||||
AmazonAccountId,
|
||||
@@ -236,12 +237,18 @@ public partial class Mkb79Auth
|
||||
return account;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exported file names one marketplace - the one this account is registered with - because that is all
|
||||
/// the format holds: a single <c>locale_code</c> alongside a single device registration. audible-cli switches
|
||||
/// marketplaces on its own from those same tokens, so nothing is lost to it. Any additional marketplaces
|
||||
/// Libation reads for this account are its own bookkeeping and have no slot here.
|
||||
/// </summary>
|
||||
public static Mkb79Auth FromAccount(Account account)
|
||||
=> new()
|
||||
{
|
||||
AccessToken = account.IdentityTokens?.ExistingAccessToken.TokenValue,
|
||||
ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey,
|
||||
AdpToken = account.IdentityTokens?.AdpToken?.Value,
|
||||
AccessToken = account.IdentityTokens?.ExistingAccessToken.Reveal(),
|
||||
ActivationBytes = account.DecryptKey.HasValue ? account.DecryptKey.Reveal() : null,
|
||||
AdpToken = account.IdentityTokens?.AdpToken?.Reveal(),
|
||||
CustomerInfo = new CustomerInfo
|
||||
{
|
||||
AccountPool = "Amazon",
|
||||
@@ -256,13 +263,14 @@ public partial class Mkb79Auth
|
||||
DeviceSerialNumber = account.IdentityTokens?.DeviceSerialNumber,
|
||||
DeviceType = account.IdentityTokens?.DeviceType,
|
||||
},
|
||||
DevicePrivateKey = account.IdentityTokens?.PrivateKey?.Value,
|
||||
DevicePrivateKey = account.IdentityTokens?.PrivateKey?.Reveal(),
|
||||
AccessTokenExpires = account.IdentityTokens?.ExistingAccessToken.Expires ?? default,
|
||||
LocaleCode = account.Locale?.CountryCode,
|
||||
WithUsername = account.Locale?.WithUsername ?? false,
|
||||
RefreshToken = account.IdentityTokens?.RefreshToken?.Value,
|
||||
StoreAuthenticationCookie = account.IdentityTokens?.StoreAuthenticationCookie,
|
||||
WebsiteCookies = new(account.IdentityTokens?.Cookies ?? []),
|
||||
RefreshToken = account.IdentityTokens?.RefreshToken?.Reveal(),
|
||||
StoreAuthenticationCookie = account.IdentityTokens?.StoreAuthenticationCookie.Reveal(),
|
||||
WebsiteCookies = new(account.IdentityTokens?.Cookies
|
||||
.Select(c => new KeyValuePair<string, string?>(c.Key, c.Value.Reveal())) ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
@@ -10,10 +10,37 @@ public enum Mkb79ImportOutcome
|
||||
InvalidFile,
|
||||
}
|
||||
|
||||
public sealed record Mkb79ImportResult(Mkb79ImportOutcome Outcome, Account? Account = null, string? Message = null);
|
||||
/// <param name="ClaimedBy">
|
||||
/// For <see cref="Mkb79ImportOutcome.DuplicateAccount"/>, the account already scanning that marketplace. It may
|
||||
/// be one registered with it, or one carrying it as an additional marketplace.
|
||||
/// </param>
|
||||
public sealed record Mkb79ImportResult(
|
||||
Mkb79ImportOutcome Outcome,
|
||||
Account? Account = null,
|
||||
string? Message = null,
|
||||
Account? ClaimedBy = null);
|
||||
|
||||
public static class Mkb79AuthImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Why a duplicate import was refused, in the same words everywhere it is refused. Naming the account that
|
||||
/// already reads the marketplace matters now that it need not be a row registered with it - it may be one
|
||||
/// reading it as an additional marketplace, which is not obvious from the accounts grid.
|
||||
/// </summary>
|
||||
public static string DuplicateMessage(Mkb79ImportResult result)
|
||||
{
|
||||
var locale = result.Account?.Locale?.Name ?? "[unknown]";
|
||||
|
||||
if (result.ClaimedBy is { } claimedBy && claimedBy.Locale?.Name != locale)
|
||||
return $"The '{locale}' marketplace is already scanned by the account "
|
||||
+ $"{AccountCredentialStatus.FormatAccountLabel(claimedBy)}, as an additional marketplace. "
|
||||
+ "Nothing was imported.";
|
||||
|
||||
return "An account with that account id and country already exists."
|
||||
+ $"{Environment.NewLine}Account ID: {result.Account?.AccountId}"
|
||||
+ $"{Environment.NewLine}Country: {locale}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize mkb79/audible-cli JSON, refresh tokens, and add the account if not already present.
|
||||
/// </summary>
|
||||
@@ -32,11 +59,12 @@ public static class Mkb79AuthImporter
|
||||
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
if (persister.AccountsSettings.Accounts.Any(a =>
|
||||
a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name))
|
||||
{
|
||||
return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account);
|
||||
}
|
||||
// An mkb79 file names one marketplace, and a marketplace can only be scanned by one account. Ask about
|
||||
// every claim on it, not just registrations: an existing account may already be reading this marketplace
|
||||
// as an additional one, in which case importing would scan it twice.
|
||||
var claimedBy = persister.AccountsSettings.GetAccountClaimingMarketplace(account.AccountId, account.Locale?.Name);
|
||||
if (claimedBy is not null)
|
||||
return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account, ClaimedBy: claimedBy);
|
||||
|
||||
persister.AccountsSettings.Add(account);
|
||||
return new Mkb79ImportResult(Mkb79ImportOutcome.Success, account);
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -35,4 +35,8 @@
|
||||
<Folder Include="Migrations\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20260814170831_AddDownloadHistory")]
|
||||
partial class AddDownloadHistory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("_categoriesCategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryCategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("BookId", "CategoryLadderId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("CategoryLadderId");
|
||||
|
||||
b.ToTable("BookCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("smallint");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
|
||||
{
|
||||
b.Property<int>("DownloadHistoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadHistoryId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("Bytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CompletedAtUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("DownloadHistoryId");
|
||||
|
||||
b.HasIndex("CompletedAtUtcTicks");
|
||||
|
||||
b.ToTable("DownloadHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoriesCategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<bool>("IsFinished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
b.Navigation("Rating")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("CategoriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("CategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("CategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("CategoriesLink");
|
||||
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDownloadHistory : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DownloadHistory",
|
||||
columns: table => new
|
||||
{
|
||||
DownloadHistoryId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
CompletedAtUtcTicks = table.Column<long>(type: "bigint", nullable: false),
|
||||
AudibleProductId = table.Column<string>(type: "text", nullable: true),
|
||||
IsAudiblePlus = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Bytes = table.Column<long>(type: "bigint", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DownloadHistory", x => x.DownloadHistoryId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DownloadHistory_CompletedAtUtcTicks",
|
||||
table: "DownloadHistory",
|
||||
column: "CompletedAtUtcTicks");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DownloadHistory");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+578
@@ -0,0 +1,578 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20260816160446_AddDownloadAttemptFailures")]
|
||||
partial class AddDownloadAttemptFailures
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("_categoriesCategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryCategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("BookId", "CategoryLadderId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("CategoryLadderId");
|
||||
|
||||
b.ToTable("BookCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("smallint");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
|
||||
{
|
||||
b.Property<int>("DownloadAttemptFailureId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadAttemptFailureId"));
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("LastFailedAtUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("RetryAfterUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("DownloadAttemptFailureId");
|
||||
|
||||
b.HasIndex("Account", "AudibleProductId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("DownloadAttemptFailures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
|
||||
{
|
||||
b.Property<int>("DownloadHistoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadHistoryId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("Bytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CompletedAtUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("DownloadHistoryId");
|
||||
|
||||
b.HasIndex("CompletedAtUtcTicks");
|
||||
|
||||
b.ToTable("DownloadHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoriesCategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<bool>("IsFinished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
b.Navigation("Rating")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("CategoriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("CategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("CategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("CategoriesLink");
|
||||
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDownloadAttemptFailures : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DownloadAttemptFailures",
|
||||
columns: table => new
|
||||
{
|
||||
DownloadAttemptFailureId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
AudibleProductId = table.Column<string>(type: "text", nullable: false),
|
||||
Account = table.Column<string>(type: "text", nullable: false),
|
||||
Kind = table.Column<int>(type: "integer", nullable: false),
|
||||
ConsecutiveFailures = table.Column<int>(type: "integer", nullable: false),
|
||||
LastFailedAtUtcTicks = table.Column<long>(type: "bigint", nullable: false),
|
||||
RetryAfterUtcTicks = table.Column<long>(type: "bigint", nullable: false),
|
||||
Reason = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DownloadAttemptFailures", x => x.DownloadAttemptFailureId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DownloadAttemptFailures_Account_AudibleProductId",
|
||||
table: "DownloadAttemptFailures",
|
||||
columns: new[] { "Account", "AudibleProductId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DownloadAttemptFailures");
|
||||
}
|
||||
}
|
||||
}
|
||||
+581
@@ -0,0 +1,581 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20260826123718_AddBookCopyright")]
|
||||
partial class AddBookCopyright
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("_categoriesCategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryCategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Copyright")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("BookId", "CategoryLadderId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("CategoryLadderId");
|
||||
|
||||
b.ToTable("BookCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("smallint");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
|
||||
{
|
||||
b.Property<int>("DownloadAttemptFailureId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadAttemptFailureId"));
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("LastFailedAtUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("RetryAfterUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("DownloadAttemptFailureId");
|
||||
|
||||
b.HasIndex("Account", "AudibleProductId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("DownloadAttemptFailures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
|
||||
{
|
||||
b.Property<int>("DownloadHistoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadHistoryId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("Bytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CompletedAtUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("DownloadHistoryId");
|
||||
|
||||
b.HasIndex("CompletedAtUtcTicks");
|
||||
|
||||
b.ToTable("DownloadHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoriesCategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<bool>("IsFinished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
b.Navigation("Rating")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("CategoriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("CategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("CategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("CategoriesLink");
|
||||
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBookCopyright : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Copyright",
|
||||
table: "Books",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Copyright",
|
||||
table: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.2")
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
@@ -52,6 +52,9 @@ namespace DataLayer.Postgres.Migrations
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Copyright")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
@@ -201,6 +204,72 @@ namespace DataLayer.Postgres.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
|
||||
{
|
||||
b.Property<int>("DownloadAttemptFailureId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadAttemptFailureId"));
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("LastFailedAtUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("RetryAfterUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("DownloadAttemptFailureId");
|
||||
|
||||
b.HasIndex("Account", "AudibleProductId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("DownloadAttemptFailures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
|
||||
{
|
||||
b.Property<int>("DownloadHistoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadHistoryId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("Bytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CompletedAtUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("DownloadHistoryId");
|
||||
|
||||
b.HasIndex("CompletedAtUtcTicks");
|
||||
|
||||
b.ToTable("DownloadHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -31,4 +31,8 @@
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20260814170839_AddDownloadHistory")]
|
||||
partial class AddDownloadHistory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("_categoriesCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryCategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "CategoryLadderId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("CategoryLadderId");
|
||||
|
||||
b.ToTable("BookCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
|
||||
{
|
||||
b.Property<int>("DownloadHistoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Bytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CompletedAtUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("DownloadHistoryId");
|
||||
|
||||
b.HasIndex("CompletedAtUtcTicks");
|
||||
|
||||
b.ToTable("DownloadHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoriesCategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<bool>("IsFinished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
b.Navigation("Rating")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("CategoriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("CategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("CategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("CategoriesLink");
|
||||
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDownloadHistory : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DownloadHistory",
|
||||
columns: table => new
|
||||
{
|
||||
DownloadHistoryId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
CompletedAtUtcTicks = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
AudibleProductId = table.Column<string>(type: "TEXT", nullable: true),
|
||||
IsAudiblePlus = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Bytes = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DownloadHistory", x => x.DownloadHistoryId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DownloadHistory_CompletedAtUtcTicks",
|
||||
table: "DownloadHistory",
|
||||
column: "CompletedAtUtcTicks");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DownloadHistory");
|
||||
}
|
||||
}
|
||||
}
|
||||
+557
@@ -0,0 +1,557 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20260816160439_AddDownloadAttemptFailures")]
|
||||
partial class AddDownloadAttemptFailures
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("_categoriesCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryCategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "CategoryLadderId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("CategoryLadderId");
|
||||
|
||||
b.ToTable("BookCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
|
||||
{
|
||||
b.Property<int>("DownloadAttemptFailureId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("LastFailedAtUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RetryAfterUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("DownloadAttemptFailureId");
|
||||
|
||||
b.HasIndex("Account", "AudibleProductId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("DownloadAttemptFailures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
|
||||
{
|
||||
b.Property<int>("DownloadHistoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Bytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CompletedAtUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("DownloadHistoryId");
|
||||
|
||||
b.HasIndex("CompletedAtUtcTicks");
|
||||
|
||||
b.ToTable("DownloadHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoriesCategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<bool>("IsFinished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
b.Navigation("Rating")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("CategoriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("CategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("CategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("CategoriesLink");
|
||||
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDownloadAttemptFailures : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DownloadAttemptFailures",
|
||||
columns: table => new
|
||||
{
|
||||
DownloadAttemptFailureId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AudibleProductId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Account = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Kind = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ConsecutiveFailures = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
LastFailedAtUtcTicks = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
RetryAfterUtcTicks = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
Reason = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DownloadAttemptFailures", x => x.DownloadAttemptFailureId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DownloadAttemptFailures_Account_AudibleProductId",
|
||||
table: "DownloadAttemptFailures",
|
||||
columns: new[] { "Account", "AudibleProductId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DownloadAttemptFailures");
|
||||
}
|
||||
}
|
||||
}
|
||||
+560
@@ -0,0 +1,560 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20260826123725_AddBookCopyright")]
|
||||
partial class AddBookCopyright
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("_categoriesCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryCategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Copyright")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "CategoryLadderId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("CategoryLadderId");
|
||||
|
||||
b.ToTable("BookCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
|
||||
{
|
||||
b.Property<int>("DownloadAttemptFailureId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("LastFailedAtUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RetryAfterUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("DownloadAttemptFailureId");
|
||||
|
||||
b.HasIndex("Account", "AudibleProductId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("DownloadAttemptFailures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
|
||||
{
|
||||
b.Property<int>("DownloadHistoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Bytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CompletedAtUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("DownloadHistoryId");
|
||||
|
||||
b.HasIndex("CompletedAtUtcTicks");
|
||||
|
||||
b.ToTable("DownloadHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoriesCategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<bool>("IsFinished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
b.Navigation("Rating")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("CategoriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("CategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("CategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("CategoriesLink");
|
||||
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBookCopyright : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Copyright",
|
||||
table: "Books",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Copyright",
|
||||
table: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
@@ -45,6 +45,9 @@ namespace DataLayer.Migrations
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Copyright")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -188,6 +191,68 @@ namespace DataLayer.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
|
||||
{
|
||||
b.Property<int>("DownloadAttemptFailureId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("LastFailedAtUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RetryAfterUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("DownloadAttemptFailureId");
|
||||
|
||||
b.HasIndex("Account", "AudibleProductId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("DownloadAttemptFailures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
|
||||
{
|
||||
b.Property<int>("DownloadHistoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Bytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CompletedAtUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("DownloadHistoryId");
|
||||
|
||||
b.HasIndex("CompletedAtUtcTicks");
|
||||
|
||||
b.ToTable("DownloadHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DataLayer.Configurations;
|
||||
|
||||
internal class DownloadAttemptFailureConfig : IEntityTypeConfiguration<DownloadAttemptFailure>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DownloadAttemptFailure> entity)
|
||||
{
|
||||
entity.HasKey(f => f.DownloadAttemptFailureId);
|
||||
|
||||
// One row per title per account, upserted on each failure: the point is to remember the latest
|
||||
// verdict, not to accumulate a history.
|
||||
entity.HasIndex(f => new { f.Account, f.AudibleProductId }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DataLayer.Configurations;
|
||||
|
||||
internal class DownloadHistoryConfig : IEntityTypeConfiguration<DownloadHistory>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DownloadHistory> entity)
|
||||
{
|
||||
entity.HasKey(dh => dh.DownloadHistoryId);
|
||||
|
||||
// Every read is a range query over the rolling window, and every write prunes by age.
|
||||
entity.HasIndex(dh => dh.CompletedAtUtcTicks);
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.2.1.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.2.1.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PackageReference Include="Dinah.Core" Version="11.0.0.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="11.0.0.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -35,6 +35,10 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -52,6 +52,9 @@ public class Book
|
||||
public bool IsSpatial { get; private set; }
|
||||
public DateTime? DatePublished { get; private set; }
|
||||
public string? Language { get; private set; }
|
||||
/// <summary>The publisher's copyright line. Only returned by the API's product_details response
|
||||
/// group, and null for plenty of titles even then.</summary>
|
||||
public string? Copyright { get; private set; }
|
||||
|
||||
// is owned, not optional 1:1
|
||||
public UserDefinedItem UserDefinedItem { get; private set; }
|
||||
@@ -119,6 +122,13 @@ public class Book
|
||||
public void UpdateLengthInMinutes(int lengthInMinutes)
|
||||
=> LengthInMinutes = lengthInMinutes;
|
||||
|
||||
public void UpdateDescription(string? description)
|
||||
{
|
||||
// don't overwrite with default values
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
Description = description.Trim();
|
||||
}
|
||||
|
||||
#region contributors, authors, narrators
|
||||
internal HashSet<BookContributor> ContributorsLink { get; private set; }
|
||||
|
||||
@@ -232,31 +242,67 @@ public class Book
|
||||
private readonly HashSet<Supplement>? _supplements;
|
||||
public IEnumerable<Supplement> Supplements => _supplements?.ToList() ?? [];
|
||||
|
||||
public void AddSupplementDownloadUrl(string url)
|
||||
/// <summary>
|
||||
/// Records where Audible says this book's supplement can be found, replacing anything recorded before.
|
||||
/// <para>
|
||||
/// Audible reports one supplement per title, so this replaces rather than accumulates. It used to add, and
|
||||
/// guarded against duplicates by comparing the incoming url to itself - which came to the same thing only
|
||||
/// because the always-true comparison meant "this book already has a supplement", so a url that changed was
|
||||
/// silently ignored.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void SetSupplementDownloadUrl(string url)
|
||||
{
|
||||
// supplements are owned by Book, so no need to Load():
|
||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner.
|
||||
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
|
||||
|
||||
if (_supplements?.Any(s => url.EqualsInsensitive(url)) is true)
|
||||
if (_supplements is null)
|
||||
return;
|
||||
|
||||
_supplements?.Add(new Supplement(this, url));
|
||||
if (_supplements.Count == 1 && _supplements.First().Url.EqualsInsensitive(url))
|
||||
return;
|
||||
|
||||
_supplements.Clear();
|
||||
_supplements.Add(new Supplement(this, url));
|
||||
|
||||
// Only when the book had no supplement at all. A url Audible has changed says nothing about whether the
|
||||
// file behind it is already downloaded.
|
||||
UserDefinedItem.PdfStatus ??= LiberatedStatus.NotLiberated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets that this book has a supplement, for when Audible says it has none after all.
|
||||
/// <para>
|
||||
/// The status goes back to null rather than to NotLiberated, because null is what "this title has no PDF"
|
||||
/// is recorded as everywhere else: it is the state of a book that never had one, and it is what stops the
|
||||
/// grid from showing a PDF marker and the counts from expecting one.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>Whether anything was removed.</returns>
|
||||
public bool RemoveSupplements()
|
||||
{
|
||||
if (_supplements is null || _supplements.Count == 0)
|
||||
return false;
|
||||
|
||||
_supplements.Clear();
|
||||
UserDefinedItem.PdfStatus = null;
|
||||
return true;
|
||||
}
|
||||
#endregion
|
||||
|
||||
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
||||
|
||||
public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language)
|
||||
public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language, string? copyright = null)
|
||||
{
|
||||
// don't overwrite with default values
|
||||
IsAbridged |= isAbridged;
|
||||
IsSpatial = isSpatial ?? IsSpatial;
|
||||
DatePublished = datePublished ?? DatePublished;
|
||||
Language = language?.Trim().FirstCharToUpper() ?? Language;
|
||||
Copyright = copyright?.Trim() ?? Copyright;
|
||||
}
|
||||
|
||||
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
|
||||
|
||||
@@ -16,11 +16,12 @@ public class Contributor
|
||||
|
||||
// id.s
|
||||
// ----
|
||||
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
|
||||
// the name in the path is decorative; the id is what resolves it. Both of these reach the same page:
|
||||
// https://www.audible.com/author/Robert-Bevan/B0EXAMPLE1 == https://www.audible.com/author/B0EXAMPLE1
|
||||
// goes to summary page
|
||||
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
|
||||
// at bottom "See all titles by Robert Bevan" goes to https://www.audible.com/search?searchAuthor=Robert+Bevan
|
||||
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
|
||||
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
|
||||
// all narrators have no id: https://www.audible.com/search?searchNarrator=Robert+Bevan
|
||||
|
||||
internal int ContributorId { get; private set; }
|
||||
public string Name { get; private set; }
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
|
||||
namespace DataLayer;
|
||||
|
||||
/// <summary>
|
||||
/// Why a download attempt failed, coarse enough to choose how long to wait before trying again.
|
||||
/// Persisted as an int; do not renumber.
|
||||
/// </summary>
|
||||
public enum DownloadFailureKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Audible refused a content license and named an eligibility reason: the title is not owned, is not in
|
||||
/// the Plus catalog, or the account is not entitled to it. Changes only when the account or the catalog
|
||||
/// changes, so this is worth waiting a long time on.
|
||||
/// </summary>
|
||||
LicenseDenied = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Audible accepted the request but has no downloadable asset, as for a preorder that has not been
|
||||
/// released. Expected to start working by itself once the title is published.
|
||||
/// </summary>
|
||||
AssetUnavailable = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Looks like a service interruption or throttling rather than a decision about this title. Retried soon.
|
||||
/// </summary>
|
||||
ServiceInterruption = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The most recent failed attempt to download one title, so that a title Audible has just refused is not
|
||||
/// requested again on every run. One row per (account, title): the same ASIN can be refused on one account
|
||||
/// and downloadable on another.
|
||||
/// <para>
|
||||
/// Nothing here is permanent. <see cref="RetryAfterUtcTicks"/> always names a time, so a title held back
|
||||
/// because of an outage, throttling or an unreleased preorder starts being attempted again on its own.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The database is deliberately the home for this instead of a file under LibationFiles: in Docker,
|
||||
/// LibationFiles is a throwaway directory inside the container and only the database is on a volume, so a
|
||||
/// file-based record would forget every failure on each container start - exactly the case this fixes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DownloadAttemptFailure
|
||||
{
|
||||
internal int DownloadAttemptFailureId { get; private set; }
|
||||
|
||||
public string AudibleProductId { get; private set; }
|
||||
|
||||
/// <summary>The <see cref="LibraryBook.Account"/> the attempt was made with.</summary>
|
||||
public string Account { get; private set; }
|
||||
|
||||
public DownloadFailureKind Kind { get; private set; }
|
||||
|
||||
/// <summary>Failures in a row without an intervening success. Drives how long the next wait is.</summary>
|
||||
public int ConsecutiveFailures { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC ticks rather than a DateTime so range queries mean the same thing on SQLite and PostgreSQL.
|
||||
/// Local time is for display only.
|
||||
/// </summary>
|
||||
public long LastFailedAtUtcTicks { get; private set; }
|
||||
|
||||
/// <summary>When this title becomes eligible for another automatic attempt, in UTC ticks.</summary>
|
||||
public long RetryAfterUtcTicks { get; private set; }
|
||||
|
||||
/// <summary>One line from Audible, kept so the user can be told why without re-requesting a license.</summary>
|
||||
public string? Reason { get; private set; }
|
||||
|
||||
public DateTimeOffset LastFailedAt => new(LastFailedAtUtcTicks, TimeSpan.Zero);
|
||||
public DateTimeOffset RetryAfter => new(RetryAfterUtcTicks, TimeSpan.Zero);
|
||||
|
||||
private DownloadAttemptFailure()
|
||||
{
|
||||
// for EF
|
||||
AudibleProductId = null!;
|
||||
Account = null!;
|
||||
}
|
||||
|
||||
public DownloadAttemptFailure(string audibleProductId, string account, DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset lastFailedAt, DateTimeOffset retryAfter, string? reason)
|
||||
{
|
||||
AudibleProductId = audibleProductId;
|
||||
Account = account;
|
||||
Record(kind, consecutiveFailures, lastFailedAt, retryAfter, reason);
|
||||
}
|
||||
|
||||
public void Record(DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset lastFailedAt, DateTimeOffset retryAfter, string? reason)
|
||||
{
|
||||
Kind = kind;
|
||||
ConsecutiveFailures = consecutiveFailures;
|
||||
LastFailedAtUtcTicks = lastFailedAt.UtcTicks;
|
||||
RetryAfterUtcTicks = retryAfter.UtcTicks;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
=> $"{AudibleProductId} {Kind} x{ConsecutiveFailures}, retry after {RetryAfter.ToLocalTime()}";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
|
||||
namespace DataLayer;
|
||||
|
||||
/// <summary>
|
||||
/// One successful audiobook download, recorded so the opt-in daily download limit survives restarts.
|
||||
/// Recorded for every download regardless of whether a limit is configured: a user who turns the limit
|
||||
/// on after a heavy session gets a limit that reflects what actually happened.
|
||||
/// <para>
|
||||
/// The database is deliberately the home for this instead of a file under LibationFiles: in Docker,
|
||||
/// LibationFiles is a throwaway directory inside the container and only the database is on a volume.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DownloadHistory
|
||||
{
|
||||
internal int DownloadHistoryId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC ticks rather than a DateTime so range queries mean the same thing on SQLite and PostgreSQL, and so a
|
||||
/// window that spans a DST change stays exactly 24 hours. Local time is for display only.
|
||||
/// </summary>
|
||||
public long CompletedAtUtcTicks { get; private set; }
|
||||
|
||||
public string? AudibleProductId { get; private set; }
|
||||
|
||||
public bool IsAudiblePlus { get; private set; }
|
||||
|
||||
/// <summary>Size on disk of the files written to the Books directory for this title.</summary>
|
||||
public long Bytes { get; private set; }
|
||||
|
||||
public DateTimeOffset CompletedAt => new(CompletedAtUtcTicks, TimeSpan.Zero);
|
||||
|
||||
private DownloadHistory() { }
|
||||
|
||||
public DownloadHistory(DateTimeOffset completedAt, string? audibleProductId, bool isAudiblePlus, long bytes)
|
||||
{
|
||||
CompletedAtUtcTicks = completedAt.UtcTicks;
|
||||
AudibleProductId = audibleProductId;
|
||||
IsAudiblePlus = isAudiblePlus;
|
||||
Bytes = bytes;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
=> $"{AudibleProductId} {CompletedAt.ToLocalTime()} {(IsAudiblePlus ? "Plus" : "owned")} {Bytes} bytes";
|
||||
}
|
||||
@@ -26,6 +26,8 @@ public class LibationContext : DbContext, INotifyDisposed
|
||||
public DbSet<Series> Series { get; private set; }
|
||||
public DbSet<Category> Categories { get; private set; }
|
||||
public DbSet<CategoryLadder> CategoryLadders { get; private set; }
|
||||
public DbSet<DownloadHistory> DownloadHistory { get; private set; }
|
||||
public DbSet<DownloadAttemptFailure> DownloadAttemptFailures { get; private set; }
|
||||
|
||||
public event EventHandler? ObjectDisposed;
|
||||
public override void Dispose()
|
||||
@@ -56,6 +58,8 @@ public class LibationContext : DbContext, INotifyDisposed
|
||||
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
||||
modelBuilder.ApplyConfiguration(new CategoryLadderConfig());
|
||||
modelBuilder.ApplyConfiguration(new BookCategoryConfig());
|
||||
modelBuilder.ApplyConfiguration(new DownloadHistoryConfig());
|
||||
modelBuilder.ApplyConfiguration(new DownloadAttemptFailureConfig());
|
||||
|
||||
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
|
||||
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
|
||||
|
||||
@@ -89,6 +89,7 @@ public class MockLibraryBook : LibraryBook
|
||||
bool isAbridged = false,
|
||||
bool isSpatial = false,
|
||||
string language = "English",
|
||||
string? copyright = null,
|
||||
LiberatedStatus bookStatus = LiberatedStatus.Liberated,
|
||||
LiberatedStatus? pdfStatus = null,
|
||||
AudioFormat? lastDlFormat = null,
|
||||
@@ -106,12 +107,14 @@ public class MockLibraryBook : LibraryBook
|
||||
localeName);
|
||||
|
||||
lastDlFormat ??= new AudioFormat(Codec.AAC_LC, 128, 44100, 2);
|
||||
lastDlVersion ??= new Version(13, 0);
|
||||
// Three fields, like a real Libation version: Extensions.ToVersionString formats to at least three,
|
||||
// so a two-field default throws as soon as anything renders a naming template for a mock book.
|
||||
lastDlVersion ??= new Version(13, 0, 0);
|
||||
book.UserDefinedItem.SetLastDownloaded(lastDlVersion, lastDlFormat, "1");
|
||||
book.UserDefinedItem.PdfStatus = pdfStatus;
|
||||
book.UserDefinedItem.BookStatus = bookStatus;
|
||||
|
||||
book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language);
|
||||
book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language, copyright);
|
||||
|
||||
return new MockLibraryBook(
|
||||
book,
|
||||
|
||||
@@ -69,6 +69,17 @@ public static class LibraryBookQueries
|
||||
.getLibrary()
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// How many books a full search re-index writes: the same rows as
|
||||
/// <see cref="GetLibrary_Flat_NoTracking"/> without the podcast parents, which are stripped before
|
||||
/// indexing. A row count only; no entities are loaded, so this is cheap enough to check the index against.
|
||||
/// </summary>
|
||||
public int GetIndexableBookCount()
|
||||
=> context
|
||||
.LibraryBooks
|
||||
.AsNoTracking()
|
||||
.Count(lb => !lb.IsDeleted && lb.Book.ContentType != ContentType.Parent);
|
||||
|
||||
/// <summary>Counts <see cref="LibraryBook"/> rows by <see cref="LibraryBook.IsDeleted"/> (no related entities loaded).</summary>
|
||||
public (int NotInTrash, int InTrash) GetLibraryBookCountsByTrashFlag()
|
||||
{
|
||||
@@ -108,6 +119,16 @@ public static class LibraryBookQueries
|
||||
.ParentedEpisodes()
|
||||
.Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId);
|
||||
|
||||
/// <summary>
|
||||
/// Books displayed as top-level rows: ordinary products plus episodes whose series parent
|
||||
/// is not in the database. Showing orphaned episodes here keeps a successfully imported
|
||||
/// title discoverable instead of silently hiding it from both grids.
|
||||
/// </summary>
|
||||
public IEnumerable<LibraryBook> StandaloneBooks()
|
||||
=> libraryBooks
|
||||
.Where(lb => lb.Book.IsProduct())
|
||||
.Concat(libraryBooks.FindOrphanedEpisodes());
|
||||
|
||||
public IEnumerable<LibraryBook> FindChildren(LibraryBook parent)
|
||||
=> libraryBooks.Where(lb => lb.Book.IsEpisodeChild() && lb.HasSeriesId(parent.Book.AudibleProductId));
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ public class BookImporter : ItemsImporterBase
|
||||
new AudibleProductId(item.ProductId),
|
||||
item.Title,
|
||||
item.Subtitle,
|
||||
item.Description,
|
||||
item.PlainTextDescription(),
|
||||
item.LengthInMinutes,
|
||||
contentType,
|
||||
authors,
|
||||
@@ -157,12 +157,54 @@ public class BookImporter : ItemsImporterBase
|
||||
book.ReplacePublisher(publisher);
|
||||
}
|
||||
|
||||
if (item.PdfUrl is not null)
|
||||
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||
syncSupplement(item, book);
|
||||
|
||||
return book;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings a book's stored description into line with what this scan says about it.
|
||||
/// <para>
|
||||
/// The description used to be stored exactly as Audible sends it, which is HTML. It is flattened on the
|
||||
/// way in now, so every book imported before that change is still holding markup and needs rewriting -
|
||||
/// hence updating a description at all, which import never used to do.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A scan that reports no description leaves the stored one alone. Episodes are imported from the
|
||||
/// catalog, and "no summary" there can just as easily mean the response group was not asked for.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static void syncDescription(Item item, Book book)
|
||||
=> book.UpdateDescription(item.PlainTextDescription());
|
||||
|
||||
/// <summary>
|
||||
/// Brings a book's supplement into line with what this scan says about it.
|
||||
/// <para>
|
||||
/// Only new books used to get this far, so a title that gained a PDF after it was first imported never got
|
||||
/// one, and a title that lost its PDF went on claiming one - and, until issue #1973, went on being asked
|
||||
/// for on every run.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A supplement is dropped only when Audible says outright that no supplement url is available. A missing
|
||||
/// url says nothing on its own: episodes are imported from the catalog, which is never asked for
|
||||
/// <c>pdf_url</c>, so there "no url" means "not asked" rather than "none exists". A PDF already downloaded
|
||||
/// is left alone either way, because the file is on disk and the library should go on saying so.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static void syncSupplement(Item item, Book book)
|
||||
{
|
||||
if (item.PdfUrl is not null)
|
||||
book.SetSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||
else if (item.IsPdfUrlAvailable is false
|
||||
&& book.UserDefinedItem.PdfStatus is not LiberatedStatus.Liberated
|
||||
&& book.RemoveSupplements())
|
||||
{
|
||||
Serilog.Log.Logger.Information(
|
||||
"Audible no longer offers a supplement for {audibleProductId}, so Libation has stopped expecting one.",
|
||||
book.AudibleProductId);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateBook(ImportItem importItem, Book book)
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
@@ -179,6 +221,8 @@ public class BookImporter : ItemsImporterBase
|
||||
// Update the book titles, since formatting can change
|
||||
book.UpdateTitle(item.Title, item.Subtitle);
|
||||
|
||||
syncDescription(item, book);
|
||||
|
||||
// set/update book-specific info which may have changed
|
||||
if (item.PictureId is not null)
|
||||
book.PictureId = item.PictureId;
|
||||
@@ -193,7 +237,11 @@ public class BookImporter : ItemsImporterBase
|
||||
// updateBook must update language on books which were imported before the migration which added language.
|
||||
// 2025-07-30
|
||||
// updateBook must update isSpatial on books which were imported before the migration which added isSpatial.
|
||||
book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language);
|
||||
// 2026-08-26
|
||||
// updateBook must update copyright on books which were imported before the migration which added copyright.
|
||||
book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language, item.CopyrightString());
|
||||
|
||||
syncSupplement(item, book);
|
||||
|
||||
book.UpdateProductRating(
|
||||
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
|
||||
|
||||
@@ -18,4 +18,8 @@
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
// ApplicationServices.Tests rather than a DtoImporterService.Tests of its own: this project has no test
|
||||
// assembly, and ApplicationServices references it, so its tests can already see the importers. A literal
|
||||
// because the dependency runs the other way, leaving nothing here to take the name from.
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ApplicationServices.Tests")]
|
||||
@@ -39,14 +39,15 @@ public static class AudioFileStorageExt
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PDF: audio file does not exist
|
||||
/// A file name from the file template, directly under the Books directory rather than in the book's own
|
||||
/// folder. Only the "save a copy of the cover art" dialogs use this, and only for the name they suggest.
|
||||
/// </summary>
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension, bool returnFirstExisting = false)
|
||||
=> AudibleFileStorage.BooksDirectory is { } books ? Templates.File.GetFilename(libraryBook.ToDto(), books, extension, null, returnFirstExisting)
|
||||
: throw new InvalidOperationException("Books directory is not set.");
|
||||
|
||||
/// <summary>
|
||||
/// PDF: audio file already exists
|
||||
/// A file name from the file template, in a directory the caller has already chosen.
|
||||
/// </summary>
|
||||
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension, MultiConvertFileProperties? partProperties = null, bool returnFirstExisting = false)
|
||||
=> partProperties is null ? Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension, returnFirstExisting: returnFirstExisting)
|
||||
|
||||
@@ -7,8 +7,10 @@ using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -16,18 +18,21 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace FileLiberator;
|
||||
|
||||
public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptBook>
|
||||
public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptBook>, ILicensedDownload
|
||||
{
|
||||
public override string Name => "Download & Decrypt";
|
||||
private CancellationTokenSource? cancellationTokenSource;
|
||||
private AudiobookDownloadBase? abDownloader;
|
||||
|
||||
/// <summary>
|
||||
/// Optional override to supply license info directly instead of querying the api based on Configuration options
|
||||
/// </summary>
|
||||
/// <inheritdoc/>
|
||||
public DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DownloadOptions.LicenseInfo? ObtainedLicense { get; private set; }
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.AudioExists;
|
||||
protected override bool RecordsAttemptFailures => true;
|
||||
|
||||
public override async Task CancelAsync()
|
||||
{
|
||||
if (abDownloader is not null) await abDownloader.CancelAsync();
|
||||
@@ -39,6 +44,7 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
OnBegin(libraryBook);
|
||||
cancellationTokenSource = new CancellationTokenSource();
|
||||
var cancellationToken = cancellationTokenSource.Token;
|
||||
ObtainedLicense = null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -52,6 +58,11 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
//Processable instances are reusable, so don't set LicenseInfo
|
||||
//override from within a DownloadDecryptBook instance.
|
||||
var license = LicenseInfo ?? await DownloadOptions.GetDownloadLicenseAsync(api, libraryBook, Configuration, cancellationToken);
|
||||
|
||||
// Published before the download begins so the supplement step gets it even from a run that fails
|
||||
// later on: what it needs is the license, and this one is already granted.
|
||||
ObtainedLicense = license;
|
||||
|
||||
using var downloadOptions = DownloadOptions.BuildDownloadOptions(libraryBook, Configuration, license);
|
||||
var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken);
|
||||
|
||||
@@ -113,6 +124,7 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
|
||||
Serilog.Log.Verbose("Updating liberated status for {@Book}", libraryBook.LogFriendly());
|
||||
await libraryBook.UpdateBookStatusAsync(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion);
|
||||
RecordDownloadForDailyLimit(libraryBook, result.ResultFiles);
|
||||
Serilog.Log.Verbose("Setting directory time for {@Book}", libraryBook.LogFriendly());
|
||||
SetDirectoryTime(libraryBook, finalStorageDir);
|
||||
Serilog.Log.Verbose("Deleting cache files for {@Book}", libraryBook.LogFriendly());
|
||||
@@ -237,31 +249,69 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
|
||||
#endregion
|
||||
|
||||
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
|
||||
FillMissingTags(tags, options.LibraryBook.Book, options.LibraryBookDto, options.ContentMetadata.ContentReference, options.DrmType);
|
||||
}
|
||||
|
||||
/// <summary>Audible's own format for <c>rldt</c>, which Libation matches when it supplies the tag itself.</summary>
|
||||
private const string ReleaseDateFormat = "dd-MMM-yyyy";
|
||||
|
||||
/// <summary>
|
||||
/// Audible's ADRM (.aaxc) downloads arrive with most of these tags already written; its Widevine
|
||||
/// (DASH) downloads arrive with almost none of them, so the library's own data has to stand in.
|
||||
/// </summary>
|
||||
internal static void FillMissingTags(Mpeg4Lib.MetadataItems tags, Book book, LibraryBookDto dto, ContentReference contentReference, DrmType drmType)
|
||||
{
|
||||
tags.Title ??= dto.TitleWithSubtitle;
|
||||
tags.Album ??= tags.Title;
|
||||
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
|
||||
tags.Artist ??= string.Join("; ", book.Authors.Select(a => a.Name));
|
||||
tags.AlbumArtists ??= tags.Artist;
|
||||
tags.Genres = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames());
|
||||
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
|
||||
tags.Comment ??= options.LibraryBook.Book.Description;
|
||||
tags.Genres = string.Join(", ", book.LowestCategoryNames());
|
||||
tags.ProductID ??= contentReference.Sku;
|
||||
tags.Comment ??= book.Description;
|
||||
tags.LongDescription ??= tags.Comment;
|
||||
tags.Publisher ??= options.LibraryBook.Book.Publisher;
|
||||
tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name));
|
||||
tags.Asin = options.LibraryBook.Book.AudibleProductId;
|
||||
tags.Acr = options.ContentMetadata.ContentReference.Acr;
|
||||
tags.Version = options.ContentMetadata.ContentReference.Version;
|
||||
if (options.LibraryBook.Book.DatePublished is DateTime pubDate)
|
||||
//Only the .aaxc files carry a copyright line of their own. AaxcDownloadConvertBase's fixup
|
||||
//normalizes "(P)" to "℗" after this runs, so the catalog's wording lands the same way.
|
||||
tags.Copyright ??= book.Copyright;
|
||||
tags.Publisher ??= book.Publisher;
|
||||
tags.Narrator ??= string.Join("; ", book.Narrators.Select(n => n.Name));
|
||||
tags.Asin = book.AudibleProductId;
|
||||
tags.Acr = contentReference.Acr;
|
||||
tags.Version = contentReference.Version;
|
||||
if (book.DatePublished is DateTime pubDate)
|
||||
{
|
||||
tags.Year ??= pubDate.Year.ToString();
|
||||
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
|
||||
tags.ReleaseDate ??= pubDate.ToString(ReleaseDateFormat);
|
||||
|
||||
//Audible's .aaxc files frequently carry 01-Jan-2000 where the real release date belongs.
|
||||
//The file's own tag is otherwise the better source - it is what Audible shipped with this
|
||||
//particular recording - so it still wins everywhere except for that one placeholder value.
|
||||
//
|
||||
//Only the exact sentinel is overridden, and only when the catalog disagrees with it, so a
|
||||
//title genuinely released on 01-Jan-2000 comes out the same either way. Two broader rules
|
||||
//were considered and rejected: always preferring the catalog date would discard Audible's
|
||||
//date on re-releases the catalog dates wrong, and treating any year below some floor as
|
||||
//bogus has no defensible floor, since real recordings predate 2000.
|
||||
if (IsPlaceholderReleaseDate(tags.ReleaseDate) && pubDate.Date != PlaceholderReleaseDate)
|
||||
{
|
||||
tags.ReleaseDate = pubDate.ToString(ReleaseDateFormat);
|
||||
//A file carrying the placeholder rldt carries the matching placeholder year.
|
||||
if (tags.Year == PlaceholderReleaseDate.Year.ToString())
|
||||
tags.Year = pubDate.Year.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
const string tagDomain = "org.libation";
|
||||
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ACR", tags.Acr);
|
||||
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_DRM_TYPE", options.DrmType.ToString());
|
||||
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_LOCALE", options.LibraryBook.Book.Locale);
|
||||
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_DRM_TYPE", drmType.ToString());
|
||||
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_LOCALE", book.Locale);
|
||||
}
|
||||
|
||||
private static readonly DateTime PlaceholderReleaseDate = new(2000, 1, 1);
|
||||
|
||||
internal static bool IsPlaceholderReleaseDate(string? releaseDate)
|
||||
=> DateTime.TryParse(releaseDate, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
|
||||
&& parsed.Date == PlaceholderReleaseDate;
|
||||
|
||||
private void AaxcDownloader_RetrievedCoverArt(object? sender, byte[]? e)
|
||||
{
|
||||
if (Configuration.AllowLibationFixup && sender is AaxcDownloadConvertBase downloader)
|
||||
@@ -310,6 +360,24 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
#endregion
|
||||
|
||||
#region Post-success routines
|
||||
/// <summary>
|
||||
/// Records the finished download for the daily download limit. Always recorded, even when no limit is
|
||||
/// configured, so that turning the limit on later reflects downloads already performed.
|
||||
/// </summary>
|
||||
private static void RecordDownloadForDailyLimit(LibraryBook libraryBook, List<TempFile> movedFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Files have already been moved, so these paths are their final locations in the Books directory.
|
||||
var bytes = movedFiles.Sum(f => File.Exists(f.FilePath) ? new FileInfo(f.FilePath).Length : 0);
|
||||
DownloadHistoryStore.Record(libraryBook.Book.AudibleProductId, libraryBook.IsAudiblePlus, bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to measure a completed download for the daily download limit. The download itself succeeded. {@Book}", libraryBook.LogFriendly());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read the audio format from the audio file's metadata.</summary>
|
||||
public AudioFormat GetFileFormatInfo(DownloadOptions options, TempFile firstAudioFile)
|
||||
{
|
||||
@@ -504,6 +572,24 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
|
||||
try
|
||||
{
|
||||
// Fetched before the destination is touched. A storefront that no longer lists the title answers
|
||||
// with an empty product, and the file already on disk is then the better of the two.
|
||||
var item = await api.GetCatalogProductAsync(options.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
|
||||
if (item?.SourceJson is not { } sourceJson)
|
||||
{
|
||||
Serilog.Log.Logger.Error("Failed to retrieve metadata from server for {@Book}.", options.LibraryBook.LogFriendly());
|
||||
return;
|
||||
}
|
||||
|
||||
if (CatalogProductIsEmpty(sourceJson))
|
||||
{
|
||||
Serilog.Log.Logger.Warning(
|
||||
"Audible's catalog holds no details for {@Book}, so its metadata file was left as it was. This happens when the title is no longer listed in the storefront of the account that owns it.",
|
||||
options.LibraryBook.LogFriendly());
|
||||
return;
|
||||
}
|
||||
|
||||
metadataPath
|
||||
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||
options.LibraryBook,
|
||||
@@ -514,13 +600,6 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
if (File.Exists(metadataPath))
|
||||
FileUtility.SaferDelete(metadataPath);
|
||||
|
||||
var item = await api.GetCatalogProductAsync(options.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
|
||||
if (item?.SourceJson is not { } sourceJson)
|
||||
{
|
||||
Serilog.Log.Logger.Error("Failed to retrieve metadata from server for {@Book}.", options.LibraryBook.LogFriendly());
|
||||
return;
|
||||
}
|
||||
sourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ChapterInfo));
|
||||
sourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ContentReference));
|
||||
|
||||
@@ -537,6 +616,24 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a catalog product carries no details at all.
|
||||
/// <para>
|
||||
/// A storefront that no longer lists a title still answers a request for it: HTTP 200, total_results 1,
|
||||
/// and a product holding an asin and a handful of always-returned flags. Nothing between the request and
|
||||
/// the file notices, so a re-download of such a title used to replace a metadata file written while the
|
||||
/// title was still listed - the only copy of that data - with the placeholder. Reported in issue #1947,
|
||||
/// where a Canada-only title produced
|
||||
/// <c>{"asin":"...","asset_details":[],"is_preview_enabled":false,"is_vvab":false,"rating":{...zeros...}}</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Keyed off the title because every response group that returns anything descriptive returns one, and
|
||||
/// the placeholder's own fields are Audible's to change.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static bool CatalogProductIsEmpty(Newtonsoft.Json.Linq.JObject sourceJson)
|
||||
=> string.IsNullOrWhiteSpace(sourceJson.Value<string>("title"));
|
||||
#endregion
|
||||
|
||||
#region Macros
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using AudibleApi;
|
||||
using AudibleApi.Common;
|
||||
using DataLayer;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
|
||||
namespace FileLiberator;
|
||||
|
||||
/// <summary>What Libation understood about a failed download attempt, and how long to wait because of it.</summary>
|
||||
public sealed record DownloadFailureDiagnosis(DownloadFailureKind Kind, string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Recognises the download failures that mean "asking again right now will fail the same way": Audible
|
||||
/// refusing a license, and Audible having no audio to deliver.
|
||||
/// <para>
|
||||
/// Only failures recognised here are recorded and waited on. Everything else - a dropped connection, a
|
||||
/// decrypt error, a full disk - keeps the long-standing behaviour of being retried on the next run, because
|
||||
/// there is no reason to believe the next attempt fails for the same reason.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DownloadFailureClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Substring in Audible's Sable error when no audio asset exists for the title, which is what an
|
||||
/// unreleased preorder looks like. Shared with <see cref="WidevineRecommendation.SableAcrNullMarker"/>,
|
||||
/// which pairs it with an error code to spot a much narrower case.
|
||||
/// </summary>
|
||||
private const string NoAudioAssetMarker = "acr:null";
|
||||
|
||||
public static bool TryClassify(Exception ex, [NotNullWhen(true)] out DownloadFailureDiagnosis? diagnosis)
|
||||
{
|
||||
diagnosis = Classify(ex);
|
||||
return diagnosis is not null;
|
||||
}
|
||||
|
||||
public static DownloadFailureDiagnosis? Classify(Exception? ex)
|
||||
=> ex switch
|
||||
{
|
||||
null => null,
|
||||
ContentLicenseDeniedException denied => ClassifyLicenseDenial(denied),
|
||||
ApiErrorException api => ClassifyApiError(api),
|
||||
// A rethrown Widevine failure arrives wrapped by whichever step gave up on it.
|
||||
_ => Classify(ex.InnerException)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Audible attaches a rejection reason per validation type it ran. <c>GenericError</c> is Audible
|
||||
/// declining to say why, which in practice means an outage rather than a decision about the title;
|
||||
/// the GUI already reads it that way when it chooses which guidance to offer. Explicit
|
||||
/// <c>CustomerThrottled</c> is a license denial the UI names separately; backoff still treats it as
|
||||
/// a settled refusal. Anything else names an eligibility problem with the account or the title,
|
||||
/// which will not change within the hour. Saying nothing at all is also treated as an outage: a
|
||||
/// refusal with no stated reason is not a settled one.
|
||||
/// </summary>
|
||||
private static DownloadFailureDiagnosis ClassifyLicenseDenial(ContentLicenseDeniedException ex)
|
||||
{
|
||||
LicenseDenialReason?[] reasons = [ex.Ownership, ex.AYCL, ex.Membership, ex.Client];
|
||||
|
||||
var stated = reasons.Where(r => !string.IsNullOrWhiteSpace(r?.RejectionReason)).ToArray();
|
||||
var looksLikeOutage
|
||||
= stated.Length == 0
|
||||
|| stated.Any(r => r!.RejectionReason is RejectionReason.GenericError);
|
||||
|
||||
return new DownloadFailureDiagnosis(
|
||||
looksLikeOutage ? DownloadFailureKind.ServiceInterruption : DownloadFailureKind.LicenseDenied,
|
||||
BuildLicenseDenialReason(reasons) ?? ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>The most specific message Audible gave, prefixed with which check it failed.</summary>
|
||||
private static string? BuildLicenseDenialReason(IEnumerable<LicenseDenialReason?> reasons)
|
||||
=> reasons
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r?.Message))
|
||||
.Select(r => r!.ValidationType is { Length: > 0 } type ? $"{type}: {r.Message}" : r.Message)
|
||||
.FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// A license request that fails with no content reference (<c>acr:null</c>) means Audible has nothing to
|
||||
/// deliver for this title yet, which is what a preorder that has not been released looks like.
|
||||
/// </summary>
|
||||
private static DownloadFailureDiagnosis? ClassifyApiError(ApiErrorException ex)
|
||||
{
|
||||
if (ex.RequestUri?.Contains("/licenserequest", StringComparison.OrdinalIgnoreCase) is not true
|
||||
|| ex.JsonMessage?.Contains(NoAudioAssetMarker, StringComparison.Ordinal) is not true)
|
||||
return null;
|
||||
|
||||
return new DownloadFailureDiagnosis(
|
||||
DownloadFailureKind.AssetUnavailable,
|
||||
"Audible returned no audio for this title. An unreleased preorder looks like this; so does a title Audible has not finished preparing.");
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,17 @@ public partial class DownloadOptions
|
||||
public static async Task<LicenseInfo> GetDownloadLicenseAsync(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
|
||||
{
|
||||
var license = await ChooseContent(api, libraryBook, config, token);
|
||||
|
||||
// Only the audiobook download needs this. A granted license always carries it, and a supplement is
|
||||
// fetched from the same license without ever looking at it, so the requirement belongs here rather
|
||||
// than in LicenseInfo.
|
||||
if (license.ContentMetadata is not ContentMetadata contentMetadata)
|
||||
throw new InvalidDataException($"Content license contains no content metadata for asin: [{libraryBook.Book.AudibleProductId}]");
|
||||
|
||||
Serilog.Log.Logger.Debug("Content License {@License}", new
|
||||
{
|
||||
license.DrmType,
|
||||
license.ContentMetadata.ContentReference
|
||||
contentMetadata.ContentReference
|
||||
});
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -40,8 +47,8 @@ public partial class DownloadOptions
|
||||
var metadata = await api.GetContentMetadataAsync(
|
||||
libraryBook.Book.AudibleProductId,
|
||||
license.DrmType,
|
||||
license.ContentMetadata.ContentReference.Acr,
|
||||
license.ContentMetadata.ContentReference.FileVersion);
|
||||
contentMetadata.ContentReference.Acr,
|
||||
contentMetadata.ContentReference.FileVersion);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
@@ -49,19 +56,19 @@ public partial class DownloadOptions
|
||||
{
|
||||
libraryBook.Book.AudibleProductId,
|
||||
license.DrmType,
|
||||
license.ContentMetadata.ContentReference.Acr,
|
||||
license.ContentMetadata.ContentReference.FileVersion
|
||||
contentMetadata.ContentReference.Acr,
|
||||
contentMetadata.ContentReference.FileVersion
|
||||
});
|
||||
}
|
||||
else if (metadata.ContentReference != license.ContentMetadata.ContentReference)
|
||||
else if (metadata.ContentReference != contentMetadata.ContentReference)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("Metadata ContentReference does not match License ContentReference with drm_type = {DrmType}. {@Metadata}. {@License} ",
|
||||
license.DrmType,
|
||||
metadata.ContentReference,
|
||||
license.ContentMetadata.ContentReference);
|
||||
contentMetadata.ContentReference);
|
||||
}
|
||||
else
|
||||
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
|
||||
contentMetadata.ChapterInfo = metadata.ChapterInfo;
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
return license;
|
||||
@@ -70,25 +77,29 @@ public partial class DownloadOptions
|
||||
public class LicenseInfo
|
||||
{
|
||||
public DrmType DrmType { get; set; }
|
||||
public ContentMetadata ContentMetadata { get; set; }
|
||||
public ContentMetadata? ContentMetadata { get; set; }
|
||||
public KeyData[]? DecryptionKeys { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where Audible says this title's supplement can be fetched from, or null when it says the title has
|
||||
/// none. The license request asks for it (<c>response_groups</c> includes <c>pdf_url</c>), so carrying
|
||||
/// it here is what lets one license serve both the audiobook and the PDF.
|
||||
/// </summary>
|
||||
public string? PdfUrl { get; set; }
|
||||
|
||||
[JsonConstructor]
|
||||
private LicenseInfo()
|
||||
{
|
||||
ContentMetadata = null!;
|
||||
}
|
||||
private LicenseInfo() { }
|
||||
|
||||
public static LicenseInfo Create(ContentLicense license, IEnumerable<KeyData>? keys = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(license, nameof(license));
|
||||
ArgumentNullException.ThrowIfNull(license.ContentMetadata, nameof(license.ContentMetadata));
|
||||
|
||||
return new LicenseInfo
|
||||
{
|
||||
DrmType = license.DrmType,
|
||||
ContentMetadata = license.ContentMetadata,
|
||||
DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher)
|
||||
DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher),
|
||||
PdfUrl = license.PdfUrl
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,6 +107,23 @@ public partial class DownloadOptions
|
||||
=> voucher?.Key is null ? null : [new KeyData(voucher.Key, voucher.Iv)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests a plain content license, without the Widevine negotiation and the chapter-metadata correction
|
||||
/// that <see cref="GetDownloadLicenseAsync"/> layers on for the audiobook download.
|
||||
/// <para>
|
||||
/// A supplement needs nothing from a license but its <see cref="LicenseInfo.PdfUrl"/>, and asking for the
|
||||
/// same license the audiobook download asks for is what makes one license usable by both steps.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static async Task<LicenseInfo> GetSupplementLicenseAsync(Api api, LibraryBook libraryBook, Configuration config)
|
||||
{
|
||||
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, ChooseQuality(config));
|
||||
return LicenseInfo.Create(license);
|
||||
}
|
||||
|
||||
private static DownloadQuality ChooseQuality(Configuration config)
|
||||
=> config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
||||
|
||||
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
|
||||
{
|
||||
Serilog.Log.Logger.Information("Download Settings {@Settings}", new
|
||||
@@ -107,7 +135,7 @@ public partial class DownloadOptions
|
||||
config.SpatialAudioCodec
|
||||
});
|
||||
|
||||
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
||||
var dlQuality = ChooseQuality(config);
|
||||
|
||||
bool canUseWidevine = api.SupportsWidevine();
|
||||
if (!config.UseWidevine || !canUseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
|
||||
@@ -117,7 +145,7 @@ public partial class DownloadOptions
|
||||
if (canUseWidevine)
|
||||
Serilog.Log.Logger.Warning("Unable to get a Widevine CDM. Falling back to ADRM.");
|
||||
else
|
||||
Serilog.Log.Logger.Warning("Account {account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask());
|
||||
Serilog.Log.Logger.Warning("Account {account} is not registered as an android device, so content will not be downloaded with Widevine DRM. The iPhone registration cannot use Widevine. To use Widevine, remove and re-add the account while registered as Android.", libraryBook.Account.ToMask());
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -181,9 +209,12 @@ public partial class DownloadOptions
|
||||
/// </summary>
|
||||
public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
|
||||
{
|
||||
if (licInfo.ContentMetadata?.ChapterInfo is not ChapterInfo chapterInfo)
|
||||
throw new InvalidDataException($"Content license contains no chapter info for asin: [{libraryBook.Book.AudibleProductId}]");
|
||||
|
||||
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
||||
var chapters
|
||||
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||
= flattenChapters(chapterInfo.Chapters, titleConcat)
|
||||
.OrderBy(c => c.StartOffsetMs)
|
||||
.ToList();
|
||||
|
||||
@@ -191,7 +222,7 @@ public partial class DownloadOptions
|
||||
combineCredits(chapters);
|
||||
|
||||
if (config.StripAudibleBrandAudio)
|
||||
stripBranding(chapters, licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs, licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
|
||||
stripBranding(chapters, chapterInfo.BrandIntroDurationMs, chapterInfo.BrandOutroDurationMs);
|
||||
|
||||
if (config.SplitFilesByChapter)
|
||||
combineShortChapters(chapters, config.MinimumFileDuration * 1000);
|
||||
@@ -199,7 +230,7 @@ public partial class DownloadOptions
|
||||
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
|
||||
{
|
||||
ChapterInfo = new Mpeg4Lib.ChapterInfo(TimeSpan.FromMilliseconds(chapters[0].StartOffsetMs)),
|
||||
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
||||
RuntimeLength = TimeSpan.FromMilliseconds(chapterInfo.RuntimeLengthMs),
|
||||
};
|
||||
|
||||
//Build AAXClean.ChapterInfo
|
||||
|
||||
@@ -56,13 +56,16 @@ public partial class DownloadOptions : IDownloadOptions, IDisposable
|
||||
|
||||
ArgumentValidator.EnsureNotNull(licInfo, nameof(licInfo));
|
||||
|
||||
if (licInfo.ContentMetadata.ContentUrl?.OfflineUrl is not string licUrl)
|
||||
if (licInfo.ContentMetadata is not AudibleApi.Common.ContentMetadata contentMetadata)
|
||||
throw new InvalidDataException("Content license doesn't contain content metadata");
|
||||
|
||||
if (contentMetadata.ContentUrl?.OfflineUrl is not string licUrl)
|
||||
throw new InvalidDataException("Content license doesn't contain an offline Url");
|
||||
|
||||
DownloadUrl = licUrl;
|
||||
DecryptionKeys = licInfo.DecryptionKeys;
|
||||
DrmType = licInfo.DrmType;
|
||||
ContentMetadata = licInfo.ContentMetadata;
|
||||
ContentMetadata = contentMetadata;
|
||||
InputType
|
||||
= licInfo.DrmType is AudibleApi.Common.DrmType.Widevine ? AAXClean.FileType.Dash
|
||||
: licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 4 && licInfo.DecryptionKeys[0].KeyPart2 is null ? AAXClean.FileType.Aax
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using ApplicationServices;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
using System.IO;
|
||||
@@ -11,34 +13,80 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace FileLiberator;
|
||||
|
||||
public class DownloadPdf : Processable, IProcessable<DownloadPdf>
|
||||
public class DownloadPdf : Processable, IProcessable<DownloadPdf>, ILicensedDownload
|
||||
{
|
||||
public override string Name => "Download Pdf";
|
||||
|
||||
/// <summary>
|
||||
/// A supplement is delivered through a content license, the same request the audiobook download makes, so
|
||||
/// a refusal of one is a refusal of the other and belongs in the record that keeps a scheduled run from
|
||||
/// asking again.
|
||||
/// </summary>
|
||||
protected override bool RecordsAttemptFailures => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DownloadOptions.LicenseInfo? ObtainedLicense { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// A title Audible has a supplement for, whose supplement is not downloaded and was not written off. The
|
||||
/// status check matches <see cref="DataLayer.LibraryBookQueries.NeedsPdfDownload"/>, and treats
|
||||
/// <see cref="LiberatedStatus.Error"/> as "don't retry" exactly as the audiobook download does through
|
||||
/// <c>AudioExists</c>.
|
||||
/// </summary>
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
|
||||
&& !libraryBook.Book.PdfExists;
|
||||
&& libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated;
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnBegin(libraryBook);
|
||||
string? createdDirectory = null;
|
||||
ObtainedLicense = null;
|
||||
|
||||
try
|
||||
{
|
||||
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
|
||||
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||
var result = verifyDownload(actualDownloadedFilePath);
|
||||
// A license this run already holds is used as it is. Requesting another would repeat the request
|
||||
// the audiobook download just made, which is what filled the log in issue #1973.
|
||||
var carriedLicense = LicenseInfo;
|
||||
var license = carriedLicense ?? await requestLicenseAsync(libraryBook);
|
||||
ObtainedLicense = license;
|
||||
|
||||
if (result.IsSuccess)
|
||||
if (license.PdfUrl is not string downloadUrl)
|
||||
return await noSupplementAvailableAsync(libraryBook);
|
||||
|
||||
var proposedDownloadFilePath = GetProposedDownloadFilePath(libraryBook);
|
||||
createdDirectory = createDirectoryFor(proposedDownloadFilePath);
|
||||
|
||||
var result = await downloadAndVerifyAsync(libraryBook, downloadUrl, proposedDownloadFilePath);
|
||||
|
||||
// Audible's download links are signed and expire, and a carried license was granted before an
|
||||
// audiobook download that may have taken hours. One fresh license is worth trying before the
|
||||
// title is reported as failed; a license requested moments ago would only fail the same way.
|
||||
if (!result.IsSuccess && carriedLicense is not null)
|
||||
{
|
||||
SetFileTime(libraryBook, actualDownloadedFilePath);
|
||||
if (Path.GetDirectoryName(actualDownloadedFilePath) is string outputDir)
|
||||
SetDirectoryTime(libraryBook, outputDir);
|
||||
Serilog.Log.Logger.Information(
|
||||
"Requesting a new license to fetch the supplement of {libraryBook}: the one from its audiobook download did not work.",
|
||||
libraryBook.LogFriendly());
|
||||
|
||||
license = await requestLicenseAsync(libraryBook);
|
||||
ObtainedLicense = license;
|
||||
|
||||
if (license.PdfUrl is not string freshDownloadUrl)
|
||||
return await noSupplementAvailableAsync(libraryBook);
|
||||
|
||||
result = await downloadAndVerifyAsync(libraryBook, freshDownloadUrl, proposedDownloadFilePath);
|
||||
}
|
||||
|
||||
await libraryBook.UpdatePdfStatusAsync(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
// Refusals are left to reach ProcessSingleAsync, which records them for every step alike. Everything
|
||||
// else is still swallowed here, which is what stopped a missing PDF from taking the app down with it.
|
||||
catch (Exception ex) when (DownloadFailureClassifier.Classify(ex) is null)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
|
||||
|
||||
@@ -49,45 +97,185 @@ public class DownloadPdf : Processable, IProcessable<DownloadPdf>
|
||||
}
|
||||
finally
|
||||
{
|
||||
removeIfLeftEmpty(createdDirectory);
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
|
||||
private async Task<DownloadOptions.LicenseInfo> requestLicenseAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
return await DownloadOptions.GetSupplementLicenseAsync(api, libraryBook, Configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audible granted a license and it carried no supplement link, which is Audible saying this title has no
|
||||
/// PDF to give. Written off the way the audiobook download writes off a title it should not attempt again:
|
||||
/// <see cref="LiberatedStatus.Error"/> means "don't retry" for a supplement exactly as it does for audio,
|
||||
/// and the same controls - a forced or named <c>liberate</c> run, Mark PDF as Download Pending - set it back.
|
||||
/// <para>
|
||||
/// Reported as a completed step and not as a failure. Nothing went wrong and nothing is left to attempt, and
|
||||
/// a failure here would put the queue's Abort / Retry / Ignore question to the user about a book whose audio
|
||||
/// is fine.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task<StatusHandler> noSupplementAvailableAsync(LibraryBook libraryBook)
|
||||
{
|
||||
const string explanation = "Audible has no PDF for this title, although the library listing says it has one. "
|
||||
+ "Its PDF will not be asked for again; mark the PDF 'Download Pending' to try once more.";
|
||||
|
||||
Serilog.Log.Logger.Information(
|
||||
"Audible granted a license for {libraryBook} that carried no supplement link, so it has no PDF to download. {explanation}",
|
||||
libraryBook.LogFriendly(),
|
||||
explanation);
|
||||
|
||||
OnStatusUpdate(explanation);
|
||||
await libraryBook.UpdatePdfStatusAsync(LiberatedStatus.Error);
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the supplement and keeps it only if what came back is one. A transport failure is reported as a
|
||||
/// failed status rather than thrown, so that a license worth retrying still gets retried.
|
||||
/// </summary>
|
||||
private async Task<StatusHandler> downloadAndVerifyAsync(LibraryBook libraryBook, string downloadUrl, string proposedDownloadFilePath)
|
||||
{
|
||||
string actualDownloadedFilePath;
|
||||
try
|
||||
{
|
||||
actualDownloadedFilePath = await downloadPdfAsync(downloadUrl, proposedDownloadFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
|
||||
return new StatusHandler { $"Error downloading PDF. See log for details. Error summary: {ex.Message}" };
|
||||
}
|
||||
|
||||
var result = verifyDownload(actualDownloadedFilePath);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
OnFileCreated(libraryBook, actualDownloadedFilePath);
|
||||
SetFileTime(libraryBook, actualDownloadedFilePath);
|
||||
if (Path.GetDirectoryName(actualDownloadedFilePath) is string outputDir)
|
||||
SetDirectoryTime(libraryBook, outputDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keeping it would leave a file in the library that is not the supplement, under a name that
|
||||
// says it is, and would stop the folder cleanup in ProcessAsync from running.
|
||||
FileUtility.SaferDelete(actualDownloadedFilePath);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>The directory this run had to create, or null when it was already there.</summary>
|
||||
private static string? createDirectoryFor(string filePath)
|
||||
{
|
||||
if (Path.GetDirectoryName(filePath) is not string directory || Directory.Exists(directory))
|
||||
return null;
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A PDF-only download is the one case that has to make the book's folder before it has anything to put
|
||||
/// in it. Without this, every failed download would leave an empty folder in the library.
|
||||
/// </summary>
|
||||
private static void removeIfLeftEmpty(string? directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (directory is not null && Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Debug(ex, "Could not remove the empty folder left by a failed PDF download: {directory}", directory);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Beside the book's audio files, in the folder the naming templates put that book in. The directory may
|
||||
/// not exist yet; see <see cref="createDirectoryFor"/>.
|
||||
/// <para>
|
||||
/// The audio file is looked up first so a PDF joins the files already on disk even if they were named by
|
||||
/// an older template or moved by hand. That lookup matches on the product id appearing in the path, so it
|
||||
/// finds nothing for a library whose folder and file templates omit <c><id></c>, and nothing for a
|
||||
/// book marked downloaded whose files are not on this machine. Falling back to the folder template rather
|
||||
/// than to the Books directory itself keeps those PDFs with their book instead of loose in the library
|
||||
/// root, where they also risk colliding with each other.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal string GetProposedDownloadFilePath(LibraryBook libraryBook)
|
||||
{
|
||||
var extension = Path.GetExtension(getdownloadUrl(libraryBook)) ?? ".pdf";
|
||||
|
||||
// if audio file exists, get it's dir. else return base Book dir
|
||||
var existingPath = Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId));
|
||||
if (existingPath is not null)
|
||||
return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, existingPath, extension);
|
||||
var destinationDir
|
||||
= Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId))
|
||||
?? AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook, Configuration);
|
||||
|
||||
return AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, extension);
|
||||
return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, destinationDir, extension, returnFirstExisting: Configuration.OverwriteExisting);
|
||||
}
|
||||
|
||||
private static string? getdownloadUrl(LibraryBook libraryBook)
|
||||
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
|
||||
|
||||
private async Task<string> downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
|
||||
private async Task<string> downloadPdfAsync(string downloadUrl, string proposedDownloadFilePath)
|
||||
{
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
||||
|
||||
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
|
||||
|
||||
var client = new HttpClient();
|
||||
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
OnFileCreated(libraryBook, actualDownloadedFilePath);
|
||||
|
||||
OnStatusUpdate(actualDownloadedFilePath);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
|
||||
private static StatusHandler verifyDownload(string actualDownloadedFilePath)
|
||||
=> !File.Exists(actualDownloadedFilePath)
|
||||
? new StatusHandler { "Downloaded PDF cannot be found" }
|
||||
: new StatusHandler();
|
||||
/// <summary>
|
||||
/// That the file exists says only that the server sent a body, and an Audible error is a 200 with a JSON
|
||||
/// body like any other response. Dinah's downloader renames by Content-Disposition, so such a body lands
|
||||
/// in the book's folder under whatever Audible called it and the title is recorded as having its PDF.
|
||||
/// <para>
|
||||
/// A supplement is whatever its URL says it is, usually a PDF and occasionally an archive, so only a file
|
||||
/// named .pdf is held to the PDF header. Everything else is checked for the one thing no supplement of
|
||||
/// any kind starts with: the opening character of a JSON or markup document.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static StatusHandler verifyDownload(string actualDownloadedFilePath)
|
||||
{
|
||||
if (!File.Exists(actualDownloadedFilePath))
|
||||
return new StatusHandler { "Downloaded PDF cannot be found" };
|
||||
|
||||
var firstBytes = readFirstBytes(actualDownloadedFilePath);
|
||||
|
||||
if (firstBytes.Length == 0)
|
||||
return new StatusHandler { "Downloaded PDF is empty" };
|
||||
|
||||
if (Path.GetExtension(actualDownloadedFilePath).EqualsInsensitive(".pdf")
|
||||
&& !firstBytes.AsSpan().StartsWith("%PDF"u8))
|
||||
return new StatusHandler { "What Audible returned is not a PDF. Nothing was saved; the PDF is still marked as not downloaded." };
|
||||
|
||||
if (firstBytes[0] is (byte)'{' or (byte)'[' or (byte)'<')
|
||||
return new StatusHandler { "Audible returned a document instead of the supplement. Nothing was saved; the PDF is still marked as not downloaded." };
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
|
||||
/// <summary>Enough to recognise a file signature, and past a byte-order mark if the body carries one.</summary>
|
||||
private static byte[] readFirstBytes(string path)
|
||||
{
|
||||
var buffer = new byte[8];
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
var read = buffer[..stream.ReadAtLeast(buffer, buffer.Length, throwOnEndOfStream: false)];
|
||||
|
||||
return read.AsSpan().StartsWith("\uFEFF"u8) ? read[3..] : read;
|
||||
}
|
||||
|
||||
public static DownloadPdf Create(Configuration config) => new() { Configuration = config };
|
||||
private DownloadPdf() { }
|
||||
|
||||
@@ -26,4 +26,8 @@
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace FileLiberator;
|
||||
|
||||
/// <summary>
|
||||
/// A step that downloads something Audible delivers through a content license: the audiobook, and the
|
||||
/// supplement whose link the same license carries.
|
||||
/// <para>
|
||||
/// One license serves every such step for a title. The request is identical - <c>response_groups</c> asks for
|
||||
/// <c>pdf_url</c> alongside the content reference - so a run holding a license must hand it on rather than ask
|
||||
/// for another, which would be a second refusal to record for a title already refused once.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ILicensedDownload
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional override to supply license info directly instead of querying the api based on Configuration options
|
||||
/// </summary>
|
||||
DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The license this step ended up using, whether supplied or requested, so the next step for the same title
|
||||
/// can reuse it. Null until a license has been obtained, and reset at the start of every attempt: a
|
||||
/// Processable instance is reused across books, so a license left behind belongs to a different title.
|
||||
/// </summary>
|
||||
DownloadOptions.LicenseInfo? ObtainedLicense { get; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using DataLayer;
|
||||
using ApplicationServices;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
@@ -42,6 +43,15 @@ public abstract class Processable
|
||||
/// <returns>True == success</returns>
|
||||
public abstract Task<StatusHandler> ProcessAsync(LibraryBook libraryBook);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a refusal from Audible during this step should be remembered, so a scheduled run stops asking
|
||||
/// for the same license every time. True for the steps that request a content license - the audiobook
|
||||
/// download and the supplement download, which make the same request - because that request is the one
|
||||
/// Audible refuses and the one the record gates. A step that only works on files already on disk has
|
||||
/// nothing to record.
|
||||
/// </summary>
|
||||
protected virtual bool RecordsAttemptFailures => false;
|
||||
|
||||
// when used in foreach: stateful. deferred execution
|
||||
public IEnumerable<LibraryBook> GetValidLibraryBooks(IEnumerable<LibraryBook> library)
|
||||
=> library.Where(libraryBook =>
|
||||
@@ -62,15 +72,40 @@ public abstract class Processable
|
||||
Account = libraryBook.Account?.ToMask() ?? "[empty]"
|
||||
});
|
||||
|
||||
var status
|
||||
= (await ProcessAsync(libraryBook))
|
||||
?? new StatusHandler { "Processable should never return a null status" };
|
||||
StatusHandler status;
|
||||
try
|
||||
{
|
||||
status
|
||||
= (await ProcessAsync(libraryBook))
|
||||
?? new StatusHandler { "Processable should never return a null status" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RecordAttemptFailure(libraryBook, ex);
|
||||
throw;
|
||||
}
|
||||
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
|
||||
if (status.IsSuccess && RecordsAttemptFailures)
|
||||
DownloadAttemptFailureStore.Clear(libraryBook);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recorded here rather than in each host so the CLI, the GUI queue and anything added later all remember
|
||||
/// a refusal the same way. Failures Libation cannot attribute to Audible are left unrecorded and keep
|
||||
/// being retried on the next run.
|
||||
/// </summary>
|
||||
private void RecordAttemptFailure(LibraryBook libraryBook, Exception ex)
|
||||
{
|
||||
if (!RecordsAttemptFailures || !DownloadFailureClassifier.TryClassify(ex, out var diagnosis))
|
||||
return;
|
||||
|
||||
DownloadAttemptFailureStore.Record(libraryBook, diagnosis.Kind, diagnosis.Reason);
|
||||
}
|
||||
|
||||
public async Task<StatusHandler> TryProcessAsync(LibraryBook libraryBook)
|
||||
=> Validate(libraryBook)
|
||||
? await ProcessAsync(libraryBook)
|
||||
|
||||
@@ -15,6 +15,27 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo
|
||||
{
|
||||
public override string Name => "Upload to Audiobookshelf";
|
||||
|
||||
public enum UploadOutcome { Uploaded, AlreadyExists, NoFilesFound, Failed }
|
||||
|
||||
public sealed class UploadOutcomeEventArgs(UploadOutcome outcome, string message) : EventArgs
|
||||
{
|
||||
public UploadOutcome Outcome { get; } = outcome;
|
||||
public string Message { get; } = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised exactly once per processed book, classifying what happened and why.
|
||||
/// <para/>
|
||||
/// Upload failures are reported here rather than through the returned <see cref="StatusHandler"/>.
|
||||
/// A non-success StatusHandler marks the book as a bad book: the GUI process queue breaks its step
|
||||
/// loop and raises the Abort/Retry/Ignore dialog. Uploading is a courtesy step layered onto
|
||||
/// liberation and must never fail the book, so <see cref="ProcessAsync"/> always reports success.
|
||||
/// </summary>
|
||||
public event EventHandler<UploadOutcomeEventArgs>? OutcomeDetermined;
|
||||
|
||||
private void OnOutcomeDetermined(UploadOutcome outcome, string message)
|
||||
=> OutcomeDetermined?.Invoke(this, new UploadOutcomeEventArgs(outcome, message));
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
{
|
||||
if (!Configuration.AudiobookshelfEnabled)
|
||||
@@ -26,7 +47,9 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo
|
||||
|| string.IsNullOrWhiteSpace(Configuration.AudiobookshelfFolderId))
|
||||
return false;
|
||||
|
||||
return libraryBook.Book.AudioExists;
|
||||
// Deliberately narrower than Book.AudioExists, which also accepts LiberatedStatus.Error.
|
||||
// An errored liberation may have left partial files; uploading those is worse than skipping.
|
||||
return libraryBook.Book.UserDefinedItem.BookStatus == LiberatedStatus.Liberated;
|
||||
}
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
@@ -37,7 +60,10 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo
|
||||
var files = GetFilesToUpload(libraryBook);
|
||||
if (files.Count == 0)
|
||||
{
|
||||
OnStatusUpdate("No audio files found to upload");
|
||||
const string message = "No audio files found on disk to upload";
|
||||
OnStatusUpdate(message);
|
||||
Serilog.Log.Logger.Warning("No audio files found on disk to upload for {Book}", libraryBook.LogFriendly());
|
||||
OnOutcomeDetermined(UploadOutcome.NoFilesFound, message);
|
||||
return new StatusHandler();
|
||||
}
|
||||
|
||||
@@ -59,20 +85,26 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo
|
||||
|
||||
if (result == AudiobookshelfApiService.UploadResult.Success)
|
||||
{
|
||||
OnStatusUpdate("Upload to Audiobookshelf completed successfully");
|
||||
const string message = "Upload to Audiobookshelf completed successfully";
|
||||
OnStatusUpdate(message);
|
||||
OnOutcomeDetermined(UploadOutcome.Uploaded, message);
|
||||
return new StatusHandler();
|
||||
}
|
||||
else if (result == AudiobookshelfApiService.UploadResult.AlreadyExists)
|
||||
{
|
||||
OnStatusUpdate("Book already exists on Audiobookshelf; skipping upload");
|
||||
const string message = "Book already exists on Audiobookshelf; skipping upload";
|
||||
OnStatusUpdate(message);
|
||||
Serilog.Log.Logger.Information("Book already exists on Audiobookshelf, skipping upload: {Book}", libraryBook.LogFriendly());
|
||||
OnOutcomeDetermined(UploadOutcome.AlreadyExists, message);
|
||||
return new StatusHandler();
|
||||
}
|
||||
else
|
||||
{
|
||||
OnStatusUpdate("Upload to Audiobookshelf failed; book remains liberated on disk");
|
||||
const string message = "Upload to Audiobookshelf failed; book remains liberated on disk";
|
||||
OnStatusUpdate(message);
|
||||
Serilog.Log.Logger.Error("Audiobookshelf upload failed for {Book}, but continuing as soft-failure", libraryBook.LogFriendly());
|
||||
// Soft-fail: log the error but do not mark the book as failed
|
||||
OnOutcomeDetermined(UploadOutcome.Failed, message + ". See log for details.");
|
||||
return new StatusHandler();
|
||||
}
|
||||
}
|
||||
@@ -81,6 +113,7 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo
|
||||
Serilog.Log.Logger.Error(ex, "Error uploading {Book} to Audiobookshelf; continuing as soft-failure", libraryBook.LogFriendly());
|
||||
OnStatusUpdate($"Audiobookshelf upload error: {ex.Message}");
|
||||
// Soft-fail: log the error but do not mark the book as failed
|
||||
OnOutcomeDetermined(UploadOutcome.Failed, $"Audiobookshelf upload error: {ex.Message}");
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
@@ -89,40 +122,71 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetFilesToUpload(LibraryBook libraryBook)
|
||||
{
|
||||
var files = new List<string>();
|
||||
var productId = libraryBook.Book.AudibleProductId;
|
||||
/// <summary>
|
||||
/// Resolves a book's audio files by both the path cache and a live scan of the Books directory.
|
||||
/// <para/>
|
||||
/// Must not use <see cref="FilePathCache"/> alone. A book can be liberated - and so pass
|
||||
/// <see cref="Validate"/>, which reads a database status - while having no cache entry at all.
|
||||
/// </summary>
|
||||
internal static List<string> GetAudioFilesOnDisk(string productId)
|
||||
=> AudibleFileStorage.Audio.GetPaths(productId)
|
||||
.Select(p => (string)p)
|
||||
.Where(File.Exists)
|
||||
.ToList();
|
||||
|
||||
// Get audio files from cache
|
||||
var audioFiles = FilePathCache.GetFiles(productId)
|
||||
.Where(f => f.fileType == FileType.Audio && File.Exists(f.path))
|
||||
.Select(f => (string)f.path)
|
||||
.Distinct()
|
||||
/// <summary>
|
||||
/// Composes the final upload payload from one preferred audio format, in deterministic order,
|
||||
/// followed by cover art at most once.
|
||||
/// </summary>
|
||||
internal static List<string> BuildUploadFileList(IEnumerable<string> audioPaths, string? coverPath)
|
||||
{
|
||||
var audioFiles = audioPaths
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
files.AddRange(audioFiles);
|
||||
var m4bFiles = audioFiles
|
||||
.Where(p => p.EndsWith(".m4b", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
var preferredAudioFiles = m4bFiles.Count > 0
|
||||
? m4bFiles
|
||||
: audioFiles.Any(p => p.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase))
|
||||
? audioFiles.Where(p => p.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase)).ToList()
|
||||
: audioFiles;
|
||||
|
||||
// Use Libation's known cover art output path (same logic as DownloadDecryptBook.DownloadCoverArt)
|
||||
if (audioFiles.FirstOrDefault() is { } firstAudioFile)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(firstAudioFile);
|
||||
if (dir is not null)
|
||||
{
|
||||
var coverPath = AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||
libraryBook,
|
||||
dir,
|
||||
".jpg",
|
||||
returnFirstExisting: false);
|
||||
var files = preferredAudioFiles
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.Where(p => !string.Equals(p, coverPath, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
if (File.Exists(coverPath))
|
||||
files.Add(coverPath);
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(coverPath))
|
||||
files.Add(coverPath);
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
internal static List<string> GetFilesToUpload(LibraryBook libraryBook)
|
||||
{
|
||||
var audioFiles = GetAudioFilesOnDisk(libraryBook.Book.AudibleProductId);
|
||||
|
||||
return BuildUploadFileList(audioFiles, GetCoverArtPath(libraryBook, audioFiles.FirstOrDefault()));
|
||||
}
|
||||
|
||||
/// <summary>Libation's known cover art output path. Same logic as DownloadDecryptBook.DownloadCoverArt.</summary>
|
||||
private static string? GetCoverArtPath(LibraryBook libraryBook, string? firstAudioFile)
|
||||
{
|
||||
if (firstAudioFile is null || Path.GetDirectoryName(firstAudioFile) is not string dir)
|
||||
return null;
|
||||
|
||||
var coverPath = AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||
libraryBook,
|
||||
dir,
|
||||
".jpg",
|
||||
returnFirstExisting: false);
|
||||
|
||||
return File.Exists(coverPath) ? coverPath : null;
|
||||
}
|
||||
|
||||
public static UploadToAudiobookshelf Create(Configuration config) => new() { Configuration = config };
|
||||
private UploadToAudiobookshelf() { }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using AudibleUtilities;
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
@@ -21,18 +22,23 @@ public static class UtilityExtensions
|
||||
account: libraryBook.Account.ToMask()
|
||||
);
|
||||
|
||||
public static Func<Account, Task<ApiExtended>>? ApiExtendedFunc { get; set; }
|
||||
|
||||
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
||||
{
|
||||
using var accounts = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
var account = accounts.AccountsSettings.GetAccount(libraryBook.Account, libraryBook.Book.Locale)
|
||||
?? throw new InvalidCredentialException($"No account found for '{libraryBook.Account}' and locale '{libraryBook.Book.Locale}'");
|
||||
|
||||
var apiExtended = await ApiExtended.CreateAsync(account);
|
||||
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin: true, storeLocale: libraryBook.StoreLocale());
|
||||
return apiExtended.Api;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marketplace a download or license request must speak to: the one the book was scanned from, which is not
|
||||
/// always the account's home store. See issue #2020.
|
||||
/// </summary>
|
||||
internal static Locale StoreLocale(this LibraryBook libraryBook)
|
||||
=> Localization.Get(libraryBook.Book.Locale);
|
||||
|
||||
public static bool SupportsWidevine(this AudibleApi.Api api)
|
||||
{
|
||||
//TODO: Expose Api's identity maintainer directly instead of using reflection.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileLiberator) + ".Tests")]
|
||||
@@ -69,24 +69,48 @@ public class BackgroundFileSystem : IDisposable
|
||||
fsCache.AddRange(SafestEnumerateFiles(RootDirectory));
|
||||
}
|
||||
|
||||
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
|
||||
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
|
||||
try
|
||||
{
|
||||
IncludeSubdirectories = true,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
fileSystemWatcher.Created += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Error += FileSystemWatcher_Error;
|
||||
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
|
||||
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
|
||||
{
|
||||
IncludeSubdirectories = true,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
fileSystemWatcher.Created += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Error += FileSystemWatcher_Error;
|
||||
|
||||
backgroundScanner = new Task(BackgroundScanner);
|
||||
backgroundScanner.Start();
|
||||
backgroundScanner = new Task(BackgroundScanner);
|
||||
backgroundScanner.Start();
|
||||
}
|
||||
// Watching a directory fails for the same reasons reading one does, and a removable drive can be pulled
|
||||
// between the listing above and this. Constructing this type happens inside the static initializer that
|
||||
// builds the Books file cache, and the runtime caches a failed initializer for the life of the process:
|
||||
// one throw here would be rethrown at every later reader of the Books directory. Give up the live
|
||||
// updates instead. Dropping RootDirectory has the owner rebuild this once the directory works again.
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Could not watch a directory for changes, so its file cache was abandoned: {@DebugText}", new { path = (string?)RootDirectory });
|
||||
Stop();
|
||||
RootDirectory = null;
|
||||
}
|
||||
}
|
||||
private void Stop()
|
||||
{
|
||||
//Stop raising events
|
||||
fileSystemWatcher?.Dispose();
|
||||
//Stop raising events. Detach first so a handler cannot run after this returns, and clear the field so a
|
||||
//second Stop() is harmless.
|
||||
var watcher = fileSystemWatcher;
|
||||
fileSystemWatcher = null;
|
||||
if (watcher is not null)
|
||||
{
|
||||
watcher.Created -= FileSystemWatcher_Changed;
|
||||
watcher.Deleted -= FileSystemWatcher_Changed;
|
||||
watcher.Renamed -= FileSystemWatcher_Changed;
|
||||
watcher.Error -= FileSystemWatcher_Error;
|
||||
watcher.Dispose();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -97,10 +121,24 @@ public class BackgroundFileSystem : IDisposable
|
||||
catch (ObjectDisposedException) { }
|
||||
|
||||
//Wait for background scanner to terminate before reinitializing.
|
||||
backgroundScanner?.Wait();
|
||||
try
|
||||
{
|
||||
backgroundScanner?.Wait();
|
||||
}
|
||||
// A scanner that died still has to be waited on, but its failure is not this caller's to raise: Stop() is
|
||||
// reached from Refresh() and from Dispose(), neither of which has anything to do with whatever went wrong,
|
||||
// and both of which are about to replace the scanner anyway.
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "The file cache's background scanner had already stopped on an error.");
|
||||
}
|
||||
|
||||
//Dispose of directoryChangesEvents after backgroundScanner exists.
|
||||
directoryChangesEvents?.Dispose();
|
||||
//Dispose of directoryChangesEvents after backgroundScanner exists. Clear the field first so a late event
|
||||
//has nothing to add to. CompleteAdding has to have happened while it was still set, or the scanner would
|
||||
//still be blocked waiting for it.
|
||||
var events = directoryChangesEvents;
|
||||
directoryChangesEvents = null;
|
||||
events?.Dispose();
|
||||
|
||||
lock (fsCacheLocker)
|
||||
fsCache.Clear();
|
||||
@@ -113,7 +151,18 @@ public class BackgroundFileSystem : IDisposable
|
||||
|
||||
private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
directoryChangesEvents?.Add(e);
|
||||
try
|
||||
{
|
||||
directoryChangesEvents?.Add(e);
|
||||
}
|
||||
// Stop() completes and disposes the collection, but events the OS had already buffered still arrive after
|
||||
// that. On Windows they arrive on a native completion callback, where an exception does not fail a call -
|
||||
// it takes the process down. Dropping them is correct: whoever called Stop() is either reinitializing,
|
||||
// which rebuilds the cache from disk, or disposing.
|
||||
//
|
||||
// Covers both ways the collection refuses: completed for additions, and disposed, whose
|
||||
// ObjectDisposedException derives from this.
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
|
||||
#region Background Thread
|
||||
@@ -121,8 +170,19 @@ public class BackgroundFileSystem : IDisposable
|
||||
{
|
||||
while (directoryChangesEvents?.TryTake(out var change, -1) is true)
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
UpdateLocalCache(change);
|
||||
try
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
UpdateLocalCache(change);
|
||||
}
|
||||
// One path this cannot read must not end the scan. An exception here used to be terminal twice over:
|
||||
// it stopped the cache tracking anything further, and it was stored on the task, so the next Stop()
|
||||
// rethrew it as an AggregateException at whoever had called Refresh() - a caller with nothing to do
|
||||
// with the file that went missing.
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Debug(ex, "Could not apply a file system change to the file cache: {@DebugText}", new { change.ChangeType, change.FullPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,14 +216,36 @@ public class BackgroundFileSystem : IDisposable
|
||||
{
|
||||
path = path.LongPathName;
|
||||
//Temporary files created when updating the db will disappear before their attributes can be read.
|
||||
if (Path.GetFileName(path).Contains("LibationContext.db") || !File.Exists(path) && !Directory.Exists(path))
|
||||
if (Path.GetFileName(path).Contains("LibationContext.db"))
|
||||
return;
|
||||
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
|
||||
|
||||
// Whether it exists and what it is were two questions, and the answer to the first could stop being true
|
||||
// before the second was asked: a download's temp file, or a folder the user has just moved or deleted, is
|
||||
// gone by the time its attributes are read. One question instead, and its failure means the same thing
|
||||
// the existence check meant - there is nothing here to add.
|
||||
if (TryGetAttributes(path) is not FileAttributes attributes)
|
||||
return;
|
||||
|
||||
if (attributes.HasFlag(FileAttributes.Directory))
|
||||
AddUniqueFiles(SafestEnumerateFiles(path));
|
||||
else
|
||||
AddUniqueFile(path);
|
||||
}
|
||||
|
||||
/// <returns>What the path is, or null when it cannot be read and so has nothing to add.</returns>
|
||||
internal static FileAttributes? TryGetAttributes(LongPath path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.GetAttributes(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
Serilog.Log.Logger.Debug(ex, "Nothing to add to the file cache for {@DebugText}", new { path = (string)path });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<LongPath> SafestEnumerateFiles(string path)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.2.1.1" />
|
||||
<PackageReference Include="Polly" Version="8.6.6" />
|
||||
<PackageReference Include="Dinah.Core" Version="11.0.0.1" />
|
||||
<PackageReference Include="Polly" Version="8.7.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
|
||||
@@ -57,15 +57,8 @@ public static class FileSystemTest
|
||||
Serilog.Log.Logger.Debug("Testing ability to write filename: {filename}", filename);
|
||||
File.WriteAllBytes(filename, []);
|
||||
Serilog.Log.Logger.Debug("Deleting test file after successful write: {filename}", filename);
|
||||
try
|
||||
{
|
||||
FileUtility.SaferDelete(filename);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//An error deleting the file doesn't constitute a write failure.
|
||||
Serilog.Log.Logger.Debug(ex, "Error deleting test file: {filename}", filename);
|
||||
}
|
||||
// An error deleting the file doesn't constitute a write failure.
|
||||
FileUtility.TrySaferDelete(filename);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -181,26 +181,49 @@ public static class FileUtility
|
||||
|
||||
/// <summary>Delete file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferDelete(LongPath source)
|
||||
=> retryPolicy.Execute(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
=> retryPolicy.Execute(() => deleteFile(source, logAttemptFailure: true));
|
||||
|
||||
Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source });
|
||||
File.Delete(source);
|
||||
Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source });
|
||||
}
|
||||
catch (Exception e)
|
||||
/// <summary>
|
||||
/// Delete file, best-effort. No error when source does not exist. Retries up to 3 times and
|
||||
/// returns false instead of throwing when the file cannot be deleted.
|
||||
/// </summary>
|
||||
public static bool TrySaferDelete(LongPath source)
|
||||
{
|
||||
try
|
||||
{
|
||||
retryPolicy.Execute(() => deleteFile(source, logAttemptFailure: false));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Unlike SaferDelete, log only once after retries are exhausted. Best-effort cleanup
|
||||
// callers should not produce an error entry for every retry or need their own wrapper.
|
||||
Serilog.Log.Logger.Warning(ex, "Unable to delete file after retries: {@DebugText}", new { source });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteFile(LongPath source, bool logAttemptFailure)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source });
|
||||
throw;
|
||||
Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source });
|
||||
File.Delete(source);
|
||||
Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (logAttemptFailure)
|
||||
Serilog.Log.Logger.Error(ex, "Failed to delete file: {@DebugText}", new { source });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferMove(LongPath source, LongPath destination)
|
||||
@@ -240,8 +263,10 @@ public static class FileUtility
|
||||
/// <param name="rootPath">Starting directory</param>
|
||||
/// <param name="patternMatch">Filename pattern match</param>
|
||||
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
|
||||
/// <param name="onIncomplete">Called with the reason when the walk ends early, so a caller that must not
|
||||
/// mistake a truncated list for an empty directory can tell the difference.</param>
|
||||
/// <returns>List of files</returns>
|
||||
public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
||||
public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly, Action<Exception>? onIncomplete = null)
|
||||
{
|
||||
var enumOptions = new EnumerationOptions
|
||||
{
|
||||
@@ -250,7 +275,101 @@ public static class FileUtility
|
||||
ReturnSpecialDirectories = false,
|
||||
MatchType = MatchType.Simple
|
||||
};
|
||||
return Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath)p);
|
||||
return IterateSafely(
|
||||
() => Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath)p),
|
||||
path,
|
||||
onIncomplete);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks a file system sequence so that a directory which stops being readable partway through ends the walk
|
||||
/// instead of throwing at whoever is consuming it.
|
||||
/// <para>
|
||||
/// <see cref="EnumerationOptions.IgnoreInaccessible"/> only forgives permissions. A disconnected or failing
|
||||
/// volume raises an I/O error from the enumerator itself, and because enumeration is lazy that error is
|
||||
/// raised wherever the sequence is finally walked - which is past any try/catch the caller wrapped around
|
||||
/// the call that produced it. Libation lost a whole session that way: a Books folder on a USB drive that
|
||||
/// started returning I/O errors took down the file cache, the type initializer that builds it, and with it
|
||||
/// every subsequent launch. See issue #1984.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static IEnumerable<LongPath> IterateSafely(Func<IEnumerable<LongPath>> getSequence, LongPath path, Action<Exception>? onIncomplete = null)
|
||||
{
|
||||
IEnumerator<LongPath> enumerator;
|
||||
try
|
||||
{
|
||||
//Opening the directory is itself a read, and fails the same way.
|
||||
enumerator = getSequence().GetEnumerator();
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadable(ex))
|
||||
{
|
||||
ReportIncomplete(ex, path, onIncomplete);
|
||||
yield break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
LongPath current;
|
||||
try
|
||||
{
|
||||
if (!enumerator.MoveNext())
|
||||
break;
|
||||
current = enumerator.Current;
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadable(ex))
|
||||
{
|
||||
//Whatever has already been read is still good and still worth returning.
|
||||
ReportIncomplete(ex, path, onIncomplete);
|
||||
break;
|
||||
}
|
||||
|
||||
yield return current;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
enumerator.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsUnreadable(Exception ex)
|
||||
=> ex is IOException or UnauthorizedAccessException or System.Security.SecurityException;
|
||||
|
||||
private static void ReportIncomplete(Exception ex, LongPath path, Action<Exception>? onIncomplete)
|
||||
{
|
||||
try
|
||||
{
|
||||
//A directory that has simply gone is routine: temp folders are created and cleaned up under a scan
|
||||
//all the time. A directory that is there and cannot be read is worth seeing in a bug report.
|
||||
if (ex is DirectoryNotFoundException)
|
||||
Serilog.Log.Logger.Debug(ex, "Stopped listing files in a directory that is no longer there: {@DebugText}", new { path = (string)path });
|
||||
else
|
||||
Serilog.Log.Logger.Warning(ex, "Could not finish listing files. The results are incomplete: {@DebugText}", new { path = (string)path });
|
||||
}
|
||||
catch { /* logging must not be the thing that breaks a file listing */ }
|
||||
|
||||
onIncomplete?.Invoke(ex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a directory can actually be read, as opposed to merely existing. A removable drive that has been
|
||||
/// pulled, and a failing one, can both still answer that they are a directory while every read of them fails.
|
||||
/// </summary>
|
||||
public static bool CanEnumerate(LongPath path)
|
||||
{
|
||||
var readable = true;
|
||||
|
||||
//The first entry is enough. A volume that cannot be read fails on the first attempt, and this must not
|
||||
//pay for walking a whole library to answer the question.
|
||||
foreach (var _ in IterateSafely(
|
||||
() => Directory.EnumerateFileSystemEntries(path.Path).Select(p => (LongPath)p),
|
||||
path,
|
||||
_ => readable = false))
|
||||
break;
|
||||
|
||||
return readable;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace FileManager;
|
||||
|
||||
@@ -20,21 +19,66 @@ public interface IJsonBackedDictionary
|
||||
string? GetStringFromJsonPath(string jsonPath, string propertyName)
|
||||
=> GetStringFromJsonPath($"{jsonPath}.{propertyName}");
|
||||
|
||||
static T? UpCast<T>(object obj)
|
||||
static T? UpCast<T>(object obj, string? propertyName = null)
|
||||
{
|
||||
if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj;
|
||||
if (obj is JObject jObject) return jObject.ToObject<T>();
|
||||
if (obj is JValue jValue)
|
||||
{
|
||||
if (typeof(T).IsAssignableTo(typeof(Enum)))
|
||||
{
|
||||
return
|
||||
Enum.TryParse(typeof(T), jValue.Value<string>(), out var enumVal)
|
||||
? (T)enumVal
|
||||
: Enum.GetValues(typeof(T)).Cast<T>().First();
|
||||
}
|
||||
return ParseEnum<T>(jValue, propertyName ?? typeof(T).Name);
|
||||
|
||||
return jValue.Value<T>();
|
||||
}
|
||||
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(JValue jValue, string propertyPath)
|
||||
{
|
||||
var enumType = typeof(T);
|
||||
|
||||
if (TryGetEnumFromNumber(jValue, enumType, propertyPath, out var fromNumber))
|
||||
return (T)fromNumber!;
|
||||
|
||||
var text = jValue.Type == JTokenType.String
|
||||
? jValue.Value<string>()
|
||||
: jValue.Value?.ToString();
|
||||
|
||||
if (text is not null
|
||||
&& Enum.TryParse(enumType, text, ignoreCase: true, out var parsed)
|
||||
&& parsed is not null
|
||||
&& Enum.IsDefined(enumType, parsed))
|
||||
{
|
||||
return (T)parsed;
|
||||
}
|
||||
|
||||
throw InvalidConfigurationValueException.ForEnum(propertyPath, text ?? jValue.ToString(), enumType);
|
||||
}
|
||||
|
||||
private static bool TryGetEnumFromNumber(JValue jValue, Type enumType, string propertyPath, out object? value)
|
||||
{
|
||||
value = null;
|
||||
if (jValue.Type is not (JTokenType.Integer or JTokenType.Float))
|
||||
return false;
|
||||
|
||||
object? raw = jValue.Value;
|
||||
if (raw is null)
|
||||
return false;
|
||||
|
||||
object converted;
|
||||
try
|
||||
{
|
||||
converted = Convert.ChangeType(raw, Enum.GetUnderlyingType(enumType));
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
|
||||
{
|
||||
throw InvalidConfigurationValueException.ForEnum(propertyPath, raw.ToString(), enumType);
|
||||
}
|
||||
|
||||
if (!Enum.IsDefined(enumType, converted))
|
||||
throw InvalidConfigurationValueException.ForEnum(propertyPath, raw.ToString(), enumType);
|
||||
|
||||
value = Enum.ToObject(enumType, converted);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
|
||||
namespace FileManager;
|
||||
|
||||
/// <summary>
|
||||
/// Settings.json (or other JSON-backed config) contained a value that cannot be mapped to the expected type.
|
||||
/// </summary>
|
||||
public sealed class InvalidConfigurationValueException : Exception
|
||||
{
|
||||
public string PropertyPath { get; }
|
||||
public string? InvalidValue { get; }
|
||||
public Type? ExpectedType { get; }
|
||||
|
||||
public InvalidConfigurationValueException(string propertyPath, string? invalidValue, Type? expectedType, string message)
|
||||
: base(message)
|
||||
{
|
||||
PropertyPath = propertyPath;
|
||||
InvalidValue = invalidValue;
|
||||
ExpectedType = expectedType;
|
||||
}
|
||||
|
||||
public static InvalidConfigurationValueException ForEnum(string propertyPath, string? invalidValue, Type enumType)
|
||||
{
|
||||
var allowed = string.Join(", ", Enum.GetNames(enumType));
|
||||
var display = FormatValue(invalidValue);
|
||||
var message =
|
||||
$"Invalid value for '{propertyPath}': {display}. " +
|
||||
$"Expected one of: {allowed}.";
|
||||
return new InvalidConfigurationValueException(propertyPath, invalidValue, enumType, message);
|
||||
}
|
||||
|
||||
public static InvalidConfigurationValueException ForPath(string propertyPath, string? invalidValue, string message)
|
||||
=> new(propertyPath, invalidValue, expectedType: null, message);
|
||||
|
||||
public static string FormatValue(string? value)
|
||||
=> value is null ? "[null]"
|
||||
: value.Length == 0 ? "[empty]"
|
||||
: $"\"{value}\"";
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
using Newtonsoft.Json;
|
||||
using Dinah.Core.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace FileManager;
|
||||
|
||||
@@ -16,6 +18,15 @@ public class PersistentDictionary : IJsonBackedDictionary
|
||||
private Dictionary<string, string?> stringCache { get; } = new();
|
||||
private Dictionary<string, object?> objectCache { get; } = new();
|
||||
|
||||
// Configuration.Instance is a process-wide singleton whose properties are read and written from
|
||||
// the UI thread, BackgroundWorker callbacks and download workers simultaneously. Every cache and
|
||||
// file access below must be serialized: unsynchronized Dictionary writes corrupt the cache, and
|
||||
// unsynchronized file access lets a reader observe a half-written file.
|
||||
// This lock cannot reach a second process (the GUI and the CLI share Settings.json), which is why
|
||||
// every write goes through AtomicFileWriter: an outside reader sees either the old file or the new
|
||||
// one, never a truncated one.
|
||||
private Lock locker { get; } = new();
|
||||
|
||||
public PersistentDictionary(string filepath, bool isReadOnly = false)
|
||||
{
|
||||
Filepath = filepath;
|
||||
@@ -36,32 +47,48 @@ public class PersistentDictionary : IJsonBackedDictionary
|
||||
[return: NotNullIfNotNull(nameof(defaultValue))]
|
||||
public string? GetString(string propertyName, string? defaultValue = null)
|
||||
{
|
||||
if (!stringCache.ContainsKey(propertyName))
|
||||
lock (locker)
|
||||
{
|
||||
var jObject = readFile();
|
||||
if (jObject.ContainsKey(propertyName))
|
||||
stringCache[propertyName] = jObject[propertyName]?.Value<string>();
|
||||
else
|
||||
stringCache[propertyName] = defaultValue;
|
||||
}
|
||||
if (!stringCache.ContainsKey(propertyName))
|
||||
{
|
||||
var jObject = readFile();
|
||||
if (jObject.ContainsKey(propertyName))
|
||||
stringCache[propertyName] = jObject[propertyName]?.Value<string>();
|
||||
else
|
||||
stringCache[propertyName] = defaultValue;
|
||||
}
|
||||
|
||||
return stringCache[propertyName];
|
||||
return stringCache[propertyName];
|
||||
}
|
||||
}
|
||||
|
||||
[return: NotNullIfNotNull(nameof(defaultValue))]
|
||||
public T? GetNonString<T>(string propertyName, T? defaultValue = default)
|
||||
{
|
||||
var obj = GetObject(propertyName);
|
||||
|
||||
if (obj is null)
|
||||
object? obj;
|
||||
lock (locker)
|
||||
{
|
||||
objectCache[propertyName] = defaultValue;
|
||||
return defaultValue;
|
||||
obj = getObject(propertyName);
|
||||
|
||||
if (obj is null)
|
||||
{
|
||||
objectCache[propertyName] = defaultValue;
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
return IJsonBackedDictionary.UpCast<T>(obj);
|
||||
|
||||
// UpCast can throw InvalidConfigurationValueException. Do it outside the lock: it neither
|
||||
// reads nor writes the cache, and callers turn the exception into a user-facing error.
|
||||
return IJsonBackedDictionary.UpCast<T>(obj, propertyName);
|
||||
}
|
||||
|
||||
public object? GetObject(string propertyName)
|
||||
{
|
||||
lock (locker)
|
||||
return getObject(propertyName);
|
||||
}
|
||||
|
||||
private object? getObject(string propertyName)
|
||||
{
|
||||
if (!objectCache.ContainsKey(propertyName))
|
||||
{
|
||||
@@ -76,47 +103,68 @@ public class PersistentDictionary : IJsonBackedDictionary
|
||||
|
||||
public string? GetStringFromJsonPath(string jsonPath)
|
||||
{
|
||||
if (!stringCache.ContainsKey(jsonPath))
|
||||
lock (locker)
|
||||
{
|
||||
try
|
||||
if (!stringCache.ContainsKey(jsonPath))
|
||||
{
|
||||
var jObject = readFile();
|
||||
var token = jObject.SelectToken(jsonPath);
|
||||
if (token is null)
|
||||
try
|
||||
{
|
||||
var jObject = readFile();
|
||||
var token = jObject.SelectToken(jsonPath);
|
||||
if (token is null)
|
||||
return null;
|
||||
stringCache[jsonPath] = token.Value<string>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
stringCache[jsonPath] = token.Value<string>();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return stringCache[jsonPath];
|
||||
return stringCache[jsonPath];
|
||||
}
|
||||
}
|
||||
|
||||
public bool Exists(string propertyName) => readFile().ContainsKey(propertyName);
|
||||
public bool Exists(string propertyName)
|
||||
{
|
||||
lock (locker)
|
||||
return readFile().ContainsKey(propertyName);
|
||||
}
|
||||
|
||||
private object locker { get; } = new object();
|
||||
public void SetString(string propertyName, string? newValue)
|
||||
{
|
||||
// only do this check in string cache, NOT object cache
|
||||
if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue)
|
||||
return;
|
||||
bool written;
|
||||
lock (locker)
|
||||
{
|
||||
// only do this check in string cache, NOT object cache
|
||||
if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue)
|
||||
return;
|
||||
|
||||
// set cache
|
||||
stringCache[propertyName] = newValue;
|
||||
// set cache
|
||||
stringCache[propertyName] = newValue;
|
||||
|
||||
writeFile(propertyName, newValue);
|
||||
written = writeFile(propertyName, newValue);
|
||||
}
|
||||
|
||||
if (written)
|
||||
logConfigChanged(propertyName, newValue);
|
||||
}
|
||||
|
||||
public void SetNonString(string propertyName, object? newValue)
|
||||
{
|
||||
// set cache
|
||||
objectCache[propertyName] = newValue;
|
||||
bool written;
|
||||
JToken parsedNewValue;
|
||||
lock (locker)
|
||||
{
|
||||
// set cache
|
||||
objectCache[propertyName] = newValue;
|
||||
|
||||
var parsedNewValue = JToken.Parse(JsonConvert.SerializeObject(newValue));
|
||||
writeFile(propertyName, parsedNewValue);
|
||||
parsedNewValue = JToken.Parse(JsonConvert.SerializeObject(newValue));
|
||||
written = writeFile(propertyName, parsedNewValue);
|
||||
}
|
||||
|
||||
if (written)
|
||||
logConfigChanged(propertyName, parsedNewValue.ToString());
|
||||
}
|
||||
|
||||
public bool RemoveProperty(string propertyName)
|
||||
@@ -138,7 +186,7 @@ public class PersistentDictionary : IJsonBackedDictionary
|
||||
|
||||
var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
|
||||
|
||||
File.WriteAllText(Filepath, endContents);
|
||||
writeFileContents(endContents);
|
||||
success = true;
|
||||
}
|
||||
Serilog.Log.Logger.Information("Removed property. {propertyName}", propertyName);
|
||||
@@ -148,30 +196,32 @@ public class PersistentDictionary : IJsonBackedDictionary
|
||||
return success;
|
||||
}
|
||||
|
||||
private void writeFile(string propertyName, JToken? newValue)
|
||||
/// <summary>Caller must hold <see cref="locker"/>.</summary>
|
||||
/// <returns>The file was rewritten</returns>
|
||||
private bool writeFile(string propertyName, JToken? newValue)
|
||||
{
|
||||
if (IsReadOnly)
|
||||
return;
|
||||
return false;
|
||||
|
||||
// write new setting to file
|
||||
lock (locker)
|
||||
{
|
||||
var jObject = readFile();
|
||||
var startContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
|
||||
var jObject = readFile();
|
||||
var startContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
|
||||
|
||||
jObject[propertyName] = newValue;
|
||||
var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
|
||||
jObject[propertyName] = newValue;
|
||||
var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
|
||||
|
||||
if (startContents == endContents)
|
||||
return;
|
||||
if (startContents == endContents)
|
||||
return false;
|
||||
|
||||
File.WriteAllText(Filepath, endContents);
|
||||
}
|
||||
writeFileContents(endContents);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void logConfigChanged(string propertyName, string? newValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var str = formatValueForLog(newValue?.ToString());
|
||||
Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { propertyName, newValue = str });
|
||||
Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { propertyName, newValue = formatValueForLog(newValue) });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
@@ -185,19 +235,17 @@ public class PersistentDictionary : IJsonBackedDictionary
|
||||
|
||||
var path = $"{jsonPath}.{propertyName}";
|
||||
|
||||
{
|
||||
// only do this check in string cache, NOT object cache
|
||||
if (stringCache.ContainsKey(path) && stringCache[path] == newValue)
|
||||
return false;
|
||||
|
||||
// set cache
|
||||
stringCache[path] = newValue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
// only do this check in string cache, NOT object cache
|
||||
if (stringCache.ContainsKey(path) && stringCache[path] == newValue)
|
||||
return false;
|
||||
|
||||
// set cache
|
||||
stringCache[path] = newValue;
|
||||
|
||||
var jObject = readFile();
|
||||
var token = jObject.SelectToken(jsonPath);
|
||||
if (token is null || token[propertyName] is null)
|
||||
@@ -208,7 +256,7 @@ public class PersistentDictionary : IJsonBackedDictionary
|
||||
return false;
|
||||
|
||||
token[propertyName] = newValue;
|
||||
File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented));
|
||||
writeFileContents(JsonConvert.SerializeObject(jObject, Formatting.Indented));
|
||||
}
|
||||
}
|
||||
catch (Exception exDebug)
|
||||
@@ -237,6 +285,42 @@ public class PersistentDictionary : IJsonBackedDictionary
|
||||
: value.Length > 100 ? $"[Length={value.Length}] {value[0..50]}...{value[^50..^0]}"
|
||||
: value;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the settings file in one step, so a concurrent reader - including one in another
|
||||
/// Libation process - sees either the whole old file or the whole new one. Mirrors how
|
||||
/// <see cref="Dinah.Core.IO.JsonFilePersister{T}"/> saves AccountsSettings.json.
|
||||
/// <para/>
|
||||
/// Windows refuses to rename over a file while someone else holds a handle to it, and the CLI,
|
||||
/// another GUI instance or a virus scanner can all hold Settings.json for a moment, so retry
|
||||
/// before giving up and letting the caller see the failure.
|
||||
/// </summary>
|
||||
private void writeFileContents(string contents)
|
||||
{
|
||||
const int attempts = 5;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
AtomicFileWriter.WriteAllText(Filepath, contents, validateJsonTempFile);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (attempt < attempts && ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Thread.Sleep(20 * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Throws before the temp file replaces the real one, leaving the real one untouched.</summary>
|
||||
private static void validateJsonTempFile(string tempPath)
|
||||
{
|
||||
var contents = File.ReadAllText(tempPath);
|
||||
if (string.IsNullOrWhiteSpace(contents))
|
||||
throw new JsonSerializationException($"Refusing to write an empty settings file to {tempPath}");
|
||||
JToken.Parse(contents);
|
||||
}
|
||||
|
||||
/// <summary>Caller must hold <see cref="locker"/>.</summary>
|
||||
private JObject readFile()
|
||||
{
|
||||
if (!File.Exists(Filepath))
|
||||
@@ -270,8 +354,12 @@ public class PersistentDictionary : IJsonBackedDictionary
|
||||
|
||||
private void createNewFile()
|
||||
{
|
||||
File.WriteAllText(Filepath, "{}");
|
||||
writeFileContents("{}");
|
||||
}
|
||||
|
||||
public JObject GetJObject() => readFile();
|
||||
public JObject GetJObject()
|
||||
{
|
||||
lock (locker)
|
||||
return readFile();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileManager) + ".Tests")]
|
||||
@@ -70,15 +70,17 @@
|
||||
<TrimmableAssembly Include="Avalonia.Themes.Default" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.14" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.2" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace HangoverAvalonia.ViewModels;
|
||||
|
||||
public partial class MainVM : ViewModelBase
|
||||
{
|
||||
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
|
||||
public Func<string, Task<bool>> ConfirmDbMutationAsync { get; set; }
|
||||
|
||||
public MainVM()
|
||||
{
|
||||
|
||||
@@ -77,7 +77,7 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
|
||||
item.IsChecked = false;
|
||||
}
|
||||
|
||||
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
|
||||
public Func<string, Task<bool>> ConfirmDbMutationAsync { get; set; }
|
||||
|
||||
public async Task RestoreCheckedAsync()
|
||||
{
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||
<!-- Remove obj folder -->
|
||||
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<Project Path="_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj" />
|
||||
<Project Path="_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj" />
|
||||
<Project Path="_Tests/FileManager.Tests/FileManager.Tests.csproj" />
|
||||
<Project Path="_Tests/LibationCli.Tests/LibationCli.Tests.csproj" />
|
||||
<Project Path="_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj" />
|
||||
<Project Path="_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj" />
|
||||
<Project Path="_Tests/LibationUiBase.Tests/LibationUiBase.Tests.csproj" />
|
||||
|
||||
@@ -31,9 +31,6 @@
|
||||
<SolidColorBrush x:Key="HyperlinkVisited" Color="Purple" />
|
||||
<SolidColorBrush x:Key="CancelRed" Color="FireBrick" />
|
||||
<SolidColorBrush x:Key="IconFill" Color="#231F20" />
|
||||
<SolidColorBrush x:Key="StoplightRed" Color="#F06060" />
|
||||
<SolidColorBrush x:Key="StoplightYellow" Color="#F0E160" />
|
||||
<SolidColorBrush x:Key="StoplightGreen" Color="#70FA70" />
|
||||
</ResourceDictionary>
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#bed2fa" />
|
||||
@@ -44,9 +41,6 @@
|
||||
<SolidColorBrush x:Key="HyperlinkVisited" Color="Orchid" />
|
||||
<SolidColorBrush x:Key="CancelRed" Color="#802727" />
|
||||
<SolidColorBrush x:Key="IconFill" Color="#DCE0DF" />
|
||||
<SolidColorBrush x:Key="StoplightRed" Color="#7d1f1f" />
|
||||
<SolidColorBrush x:Key="StoplightYellow" Color="#7d7d1f" />
|
||||
<SolidColorBrush x:Key="StoplightGreen" Color="#1f7d1f" />
|
||||
</ResourceDictionary>
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -26,6 +26,18 @@ namespace LibationAvalonia;
|
||||
public class App : Application
|
||||
{
|
||||
public static Task<List<DataLayer.LibraryBook>>? LibraryTask { get; set; }
|
||||
|
||||
/// <summary>Set by <see cref="Program"/> when another Libation instance already holds this folder's lock.</summary>
|
||||
public static bool IsAnotherInstanceRunning { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set by <see cref="Program"/> when startup rolled back an incomplete in-app upgrade. The restored
|
||||
/// files on disk no longer match the assemblies this process loaded, so it shows this and quits.
|
||||
/// </summary>
|
||||
public static StartupRecoveryNotice? StartupRecoveryNotice { get; set; }
|
||||
|
||||
/// <summary>Set when the user accepted the offer to start Libation again. Read by <see cref="Program"/> after shutdown.</summary>
|
||||
public static bool RestartRequested { get; private set; }
|
||||
public static ChardonnayTheme? DefaultThemeColors { get; private set; }
|
||||
public static MainWindow? MainWindow { get; private set; }
|
||||
public static Uri AssetUriBase { get; } = new("avares://Libation/Assets/");
|
||||
@@ -47,8 +59,27 @@ public class App : Application
|
||||
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
|
||||
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
|
||||
|
||||
if (InstallUpgradeManager.TakeStartupRecoveryAlert() is { } recovery)
|
||||
_ = MessageBox.Show(null, recovery.Body, recovery.Title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
// The install folder was just rolled back underneath us. See issue #2001.
|
||||
if (StartupRecoveryNotice is { } recovery)
|
||||
{
|
||||
_ = ShowRecoveryThenShutdownAsync(desktop, recovery);
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
// Another instance already owns this folder. No database work has been done in this process,
|
||||
// so just tell the user and shut down instead of racing on shared files. See issue #1931.
|
||||
if (IsAnotherInstanceRunning)
|
||||
{
|
||||
_ = ShowThenShutdownAsync(
|
||||
desktop,
|
||||
"Libation is already running.\r\n\r\n"
|
||||
+ "Please use the Libation window that is already open. Running more than one copy of "
|
||||
+ "Libation against the same folder at the same time can corrupt your library.",
|
||||
"Libation is already running");
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
BadBookActionDialogBase.ShowAsyncImpl = (owner, message, caption) =>
|
||||
Dialogs.BadBookActionDialog.ShowAsync(owner as Window, message, caption);
|
||||
@@ -68,6 +99,61 @@ public class App : Application
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports the rollback and shuts down. When the restored install is worth going back into, the user is
|
||||
/// asked whether to start Libation again, and <see cref="Program"/> does it after this shuts down.
|
||||
/// </summary>
|
||||
private static async Task ShowRecoveryThenShutdownAsync(IClassicDesktopStyleApplicationLifetime desktop, StartupRecoveryNotice recovery)
|
||||
{
|
||||
if (!recovery.OfferRestart)
|
||||
{
|
||||
await ShowThenShutdownAsync(desktop, recovery.Body, recovery.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var answer = await MessageBox.Show(
|
||||
recovery.Body,
|
||||
recovery.Title,
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning,
|
||||
MessageBoxDefaultButton.Button1);
|
||||
|
||||
RestartRequested = answer is DialogResult.Yes;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Serilog is unavailable on this path, and shutting down matters more than the question.
|
||||
StartupLog.Error(ex, "Failed to ask whether to restart after the rollback");
|
||||
}
|
||||
finally
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows one modal and then shuts the app down without opening the main window, for the cases where
|
||||
/// Libation has decided at startup that it must not keep running.
|
||||
/// </summary>
|
||||
private static async Task ShowThenShutdownAsync(IClassicDesktopStyleApplicationLifetime desktop, string body, string title)
|
||||
{
|
||||
try
|
||||
{
|
||||
await MessageBox.Show(body, title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Serilog is unavailable on the rollback path, and shutting down matters more than the message.
|
||||
StartupLog.Error(ex, $"Failed to show the startup message '{title}'");
|
||||
}
|
||||
finally
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static async void RunSetupIfNeededAsync(IClassicDesktopStyleApplicationLifetime desktop, Configuration config)
|
||||
{
|
||||
var setup = new LibationSetup(config.LibationFiles)
|
||||
@@ -147,24 +233,55 @@ public class App : Application
|
||||
|
||||
private static async void MainWindow_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (LibraryTask is not null && MainWindow is not null)
|
||||
// This is an async void handler: any exception that escapes it becomes an unhandled
|
||||
// exception and crashes Libation. Every path below is caught so that a failure to load
|
||||
// or bind the library degrades to an empty, still-usable grid instead. See issue #1931.
|
||||
if (LibraryTask is null || MainWindow is not { } mainWindow)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
List<DataLayer.LibraryBook> library = await LibraryTask;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => mainWindow.OnLibraryLoadedAsync(library));
|
||||
}
|
||||
catch (Exception ex) when (StartupAssemblyBootstrap.IsInstallFolderAssemblyLoadFailure(ex))
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to load library at startup");
|
||||
FatalStartupMessage failure = StartupAssemblyBootstrap.GetStartupFailureMessage(ex)!;
|
||||
await MessageBox.Show(
|
||||
mainWindow,
|
||||
failure.Body,
|
||||
failure.Title,
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
await showEmptyLibraryAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Any other failure loading or binding the library must not take down the whole app.
|
||||
// Log it, tell the user, and continue with an empty grid so Settings, re-scan, etc.
|
||||
// remain reachable.
|
||||
Serilog.Log.Logger.Error(ex, "Failed to load library at startup");
|
||||
await MessageBox.Show(
|
||||
mainWindow,
|
||||
"Libation could not load your library, so the library view will be empty for this session. "
|
||||
+ "Restarting Libation may resolve it. If this keeps happening, your library database or your "
|
||||
+ "computer's memory or disk may be corrupted.",
|
||||
"Error loading library",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
await showEmptyLibraryAsync();
|
||||
}
|
||||
|
||||
async Task showEmptyLibraryAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
List<DataLayer.LibraryBook> library = await LibraryTask;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => mainWindow.OnLibraryLoadedAsync([]));
|
||||
}
|
||||
catch (Exception ex) when (StartupAssemblyBootstrap.IsInstallFolderAssemblyLoadFailure(ex))
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to load library at startup");
|
||||
FatalStartupMessage failure = StartupAssemblyBootstrap.GetStartupFailureMessage(ex)!;
|
||||
await MessageBox.Show(
|
||||
MainWindow,
|
||||
failure.Body,
|
||||
failure.Title,
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync([]));
|
||||
Serilog.Log.Logger.Error(ex, "Failed to show empty library after a library load failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 433 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user