mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-08 11:47:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7b09a3bfef | ||
|
|
e0d49b57e1 | ||
|
|
6ba0741393 | ||
|
|
ea3fc17cfa | ||
|
|
f64824e283 | ||
|
|
5334827c09 | ||
|
|
fcefc27a29 | ||
|
|
6bfcfb239c | ||
|
|
917f44fe57 | ||
|
|
134f895c82 | ||
|
|
4513a8007b | ||
|
|
8581c0e1af | ||
|
|
3872c10c81 | ||
|
|
712568b188 | ||
|
|
5d1a31f876 | ||
|
|
00fdd903aa | ||
|
|
8a61e4d7f4 | ||
|
|
c02563204d | ||
|
|
2e4c0a10b0 | ||
|
|
ccc814ce1c | ||
|
|
98f657a40b | ||
|
|
28ce71c1b7 | ||
|
|
a438dfd82f | ||
|
|
12875993fa | ||
|
|
40b75185c2 | ||
|
|
c5a88de8a6 | ||
|
|
cb1074e7fd | ||
|
|
f5ed61fa65 | ||
|
|
680e48638a | ||
|
|
b49384cfcf | ||
|
|
2b4cde62e2 | ||
|
|
636b85f8e4 | ||
|
|
961ee3372e | ||
|
|
7d9882c500 | ||
|
|
785ec7bf96 | ||
|
|
6c917ad751 | ||
|
|
5f4a75aade | ||
|
|
e78a4392f6 | ||
|
|
f12f5d42a1 | ||
|
|
79b6521a4e | ||
|
|
cdcbd28cca | ||
|
|
f35530d18b | ||
|
|
d8dd86123d | ||
|
|
0dfee1ff5c | ||
|
|
842b0c0933 | ||
|
|
1c5119e24d | ||
|
|
a6d637c718 | ||
|
|
9579c3157b | ||
|
|
31258c7951 | ||
|
|
16379168d4 | ||
|
|
6bc82f7c6e | ||
|
|
43216b2947 |
No files matched your search
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -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"
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ inputs.release }}
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.docker_username }}
|
||||
password: ${{ secrets.docker_token }}
|
||||
@@ -59,7 +59,9 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ steps.metadata.outputs.tags != ''}}
|
||||
# Always read cache. Only write on release so PR validate cannot fail
|
||||
# when GHA cache is full or the token cannot write cache.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: ${{ inputs.release && 'type=gha,mode=max,ignore-error=true' || '' }}
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
@@ -90,7 +90,23 @@ export default defineConfig({
|
||||
text: "Audio File Formats",
|
||||
link: "/docs/features/audio-file-formats",
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -99,6 +115,7 @@ export default defineConfig({
|
||||
text: "Easy guide to searching",
|
||||
link: "/docs/features/lucene",
|
||||
},
|
||||
{ text: "Trash Bin", link: "/docs/features/trash-bin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -120,6 +137,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>
|
||||
+34
-2
@@ -38,6 +38,25 @@ init_config_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
init_master_key() {
|
||||
FILE=libation-master.key
|
||||
FULLPATH=${LIBATION_CONFIG_DIR}/${FILE}
|
||||
if [ -n "${LIBATION_MASTER_KEY_FILE}" ]; then
|
||||
debug "LIBATION_MASTER_KEY_FILE is already set, not looking for ${FILE}"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "${LIBATION_MASTER_KEY}" ]; then
|
||||
debug "LIBATION_MASTER_KEY is already set, not looking for ${FILE}"
|
||||
return 0
|
||||
fi
|
||||
if [ -f ${FULLPATH} ]; then
|
||||
info "loading ${FILE}"
|
||||
cp ${FULLPATH} ${LIBATION_CONFIG_INTERNAL}/
|
||||
else
|
||||
debug "${FULLPATH} not found, assuming accounts don't use encrypted tokens"
|
||||
fi
|
||||
}
|
||||
|
||||
update_settings() {
|
||||
FILE=$1
|
||||
KEY=$2
|
||||
@@ -141,6 +160,11 @@ setup_db() {
|
||||
run() {
|
||||
info "scanning accounts"
|
||||
/libation/LibationCli scan
|
||||
local scan_exit=$?
|
||||
if [ "${scan_exit}" -ne 0 ]; then
|
||||
error "scan failed (exit ${scan_exit}); skipping liberate. If the log shows Failed to decrypt ExistingAccessToken, see https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken"
|
||||
exit "${scan_exit}"
|
||||
fi
|
||||
info "liberating books"
|
||||
/libation/LibationCli liberate
|
||||
}
|
||||
@@ -149,7 +173,8 @@ main() {
|
||||
info "initializing libation"
|
||||
init_config_file AccountsSettings.json
|
||||
init_config_file Settings.json
|
||||
|
||||
init_master_key
|
||||
|
||||
info "loading settings"
|
||||
update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
|
||||
update_settings Settings.json InProgress /tmp
|
||||
@@ -171,6 +196,13 @@ main() {
|
||||
warn "${LIBATION_BOOKS_DIR} does not appear to be mounted, books will not be saved"
|
||||
fi
|
||||
|
||||
# If a command was passed to the container (e.g. `docker run -it libation bash`),
|
||||
# run that instead of the liberate loop so the environment is still set up
|
||||
if [ "$#" -gt 0 ]; then
|
||||
info "executing '$*' instead of the liberate loop"
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Let the user know what the run type will be
|
||||
if [[ -z "${SLEEP_TIME}" ]]; then
|
||||
SLEEP_TIME=-1
|
||||
@@ -198,4 +230,4 @@ main() {
|
||||
info "exiting"
|
||||
}
|
||||
|
||||
main
|
||||
main "$@"
|
||||
+3
-1
@@ -37,6 +37,8 @@ RUN apt-get update && apt-get -y upgrade && \
|
||||
COPY --from=build /Source/bin/Publish/Linux-chardonnay /libation
|
||||
COPY Docker/* /libation
|
||||
|
||||
RUN ln -s /libation/LibationCli /usr/local/bin/LibationCli
|
||||
|
||||
USER ${USER_UID}:${USER_GID}
|
||||
|
||||
CMD ["/libation/liberate.sh"]
|
||||
ENTRYPOINT ["/libation/liberate.sh"]
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -14,11 +14,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean.Codecs" Version="3.0.2" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="3.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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.0</Version>
|
||||
<Version>14.0.2</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>
|
||||
|
||||
@@ -4,9 +4,9 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ApplicationServices;
|
||||
@@ -17,25 +17,125 @@ public static class AudiobookshelfApiService
|
||||
public record Folder(string Id, string FullPath);
|
||||
public enum UploadResult { Success, AlreadyExists, Failed }
|
||||
|
||||
/// <summary>
|
||||
/// Audiobookshelf client (browser) route segments. If present in a pasted URL,
|
||||
/// everything from that segment onward is UI path, not part of the API base.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ClientRouteSegments = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"library",
|
||||
"item",
|
||||
"collection",
|
||||
"playlist",
|
||||
"author",
|
||||
"series",
|
||||
"audiobook",
|
||||
"config",
|
||||
"login",
|
||||
"account",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Normalize a user-supplied Audiobookshelf server URL to an API base address.
|
||||
/// Compensates for common mistakes such as pasting a browser library/item page URL,
|
||||
/// including a trailing <c>/api</c> path, query strings, or omitting the scheme.
|
||||
/// </summary>
|
||||
/// <returns>Absolute http(s) base URL with no trailing slash.</returns>
|
||||
public static string NormalizeServerUrl(string? serverUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serverUrl))
|
||||
throw new ArgumentException("Server URL is required.", nameof(serverUrl));
|
||||
|
||||
var trimmed = serverUrl.Trim();
|
||||
|
||||
// Allow host:port without a scheme (common when copying from docs or local setups).
|
||||
if (!trimmed.Contains("://", StringComparison.Ordinal))
|
||||
trimmed = "http://" + trimmed;
|
||||
|
||||
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||
|| string.IsNullOrWhiteSpace(uri.Host))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Server URL must be an absolute http(s) address, for example http://localhost:13378 or https://abs.example.com",
|
||||
nameof(serverUrl));
|
||||
}
|
||||
|
||||
var path = StripClientRoutesAndApi(uri.AbsolutePath);
|
||||
var authority = uri.GetLeftPart(UriPartial.Authority);
|
||||
if (string.IsNullOrEmpty(path) || path == "/")
|
||||
return authority;
|
||||
|
||||
return authority + path.TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string StripClientRoutesAndApi(string absolutePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(absolutePath) || absolutePath == "/")
|
||||
return "";
|
||||
|
||||
var parts = absolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var prefixLength = 0;
|
||||
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
var part = parts[i];
|
||||
|
||||
// Trailing or mid-path /api/... means the user included the API root.
|
||||
if (string.Equals(part, "api", StringComparison.OrdinalIgnoreCase))
|
||||
break;
|
||||
|
||||
if (ClientRouteSegments.Contains(part))
|
||||
break;
|
||||
|
||||
prefixLength += 1 + part.Length;
|
||||
}
|
||||
|
||||
if (prefixLength <= 0)
|
||||
return "";
|
||||
|
||||
return absolutePath[..prefixLength];
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient(string serverUrl)
|
||||
{
|
||||
var normalized = NormalizeServerUrl(serverUrl);
|
||||
var client = new HttpClient();
|
||||
client.BaseAddress = new Uri(serverUrl.TrimEnd('/') + "/");
|
||||
client.BaseAddress = new Uri(normalized + "/");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
return client;
|
||||
}
|
||||
|
||||
public static async Task<List<Library>> GetLibrariesAsync(string serverUrl, string apiToken)
|
||||
{
|
||||
var normalizedUrl = NormalizeServerUrl(serverUrl);
|
||||
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
|
||||
using var client = CreateClient(serverUrl);
|
||||
|
||||
using var client = CreateClient(normalizedUrl);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
|
||||
|
||||
var response = await client.GetAsync("api/libraries");
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync("api/libraries");
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
throw new HttpRequestException(
|
||||
$"Timed out connecting to Audiobookshelf at {normalizedUrl}. Check the Server URL and that the server is running.",
|
||||
ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new HttpRequestException(
|
||||
$"Could not reach Audiobookshelf at {normalizedUrl}. Check the Server URL, network, and that the server is running. Details: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException($"Audiobookshelf API returned {(int)response.StatusCode} ({response.StatusCode}) when fetching libraries. Response: {errorBody}");
|
||||
throw CreateLibrariesFetchException(normalizedUrl, response.StatusCode, errorBody);
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
@@ -57,6 +157,146 @@ public static class AudiobookshelfApiService
|
||||
return allLibraries.Where(l => string.Equals(l.MediaType, "book", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
private static HttpRequestException CreateLibrariesFetchException(string normalizedUrl, HttpStatusCode statusCode, string errorBody)
|
||||
{
|
||||
var code = (int)statusCode;
|
||||
var bodyPreview = SummarizeErrorBody(errorBody);
|
||||
|
||||
if (statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
return new HttpRequestException(
|
||||
$"Audiobookshelf rejected the API token ({code} {statusCode}). " +
|
||||
$"Check that the token is correct and not expired. Server: {normalizedUrl}");
|
||||
}
|
||||
|
||||
if (statusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return new HttpRequestException(
|
||||
$"Audiobookshelf returned 404 when calling {normalizedUrl}/api/libraries. " +
|
||||
"Server URL should be the Audiobookshelf base address only " +
|
||||
"(for example http://localhost:13378 or https://abs.example.com), " +
|
||||
"not a library or book page from the browser. " +
|
||||
"If Audiobookshelf is served under a subpath, include that subpath " +
|
||||
$"(for example http://host/audiobookshelf). Response: {bodyPreview}");
|
||||
}
|
||||
|
||||
return new HttpRequestException(
|
||||
$"Audiobookshelf API returned {code} ({statusCode}) when fetching libraries from {normalizedUrl}. Response: {bodyPreview}");
|
||||
}
|
||||
|
||||
private static string SummarizeErrorBody(string errorBody)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(errorBody))
|
||||
return "(empty)";
|
||||
|
||||
var trimmed = errorBody.Trim();
|
||||
const int maxLen = 280;
|
||||
if (trimmed.Length <= maxLen)
|
||||
return trimmed;
|
||||
|
||||
return trimmed[..maxLen] + "...";
|
||||
}
|
||||
|
||||
public sealed record ConnectResult(
|
||||
bool Success,
|
||||
string StatusMessage,
|
||||
string? NormalizedServerUrl,
|
||||
bool ServerUrlAdjusted,
|
||||
IReadOnlyList<Library> Libraries);
|
||||
|
||||
/// <summary>
|
||||
/// Normalize URL, fetch book libraries, and produce UI status text.
|
||||
/// Callers apply busy-state and bind <see cref="ConnectResult.Libraries"/> to controls.
|
||||
/// </summary>
|
||||
public static async Task<ConnectResult> ConnectAsync(string? serverUrl, string? apiToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serverUrl) || string.IsNullOrWhiteSpace(apiToken))
|
||||
{
|
||||
return new ConnectResult(
|
||||
Success: false,
|
||||
StatusMessage: "Please enter both server URL and API token.",
|
||||
NormalizedServerUrl: null,
|
||||
ServerUrlAdjusted: false,
|
||||
Libraries: []);
|
||||
}
|
||||
|
||||
string normalizedUrl;
|
||||
bool urlAdjusted;
|
||||
try
|
||||
{
|
||||
var trimmedUrl = serverUrl.Trim();
|
||||
normalizedUrl = NormalizeServerUrl(trimmedUrl);
|
||||
urlAdjusted = !string.Equals(trimmedUrl.TrimEnd('/'), normalizedUrl, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ConnectResult(
|
||||
Success: false,
|
||||
StatusMessage: $"Connection failed: {ex.Message}",
|
||||
NormalizedServerUrl: null,
|
||||
ServerUrlAdjusted: false,
|
||||
Libraries: []);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var libraries = await GetLibrariesAsync(normalizedUrl, apiToken.Trim());
|
||||
if (libraries.Count == 0)
|
||||
{
|
||||
return new ConnectResult(
|
||||
Success: false,
|
||||
StatusMessage: "No book libraries found. Check your settings.",
|
||||
NormalizedServerUrl: normalizedUrl,
|
||||
ServerUrlAdjusted: urlAdjusted,
|
||||
Libraries: []);
|
||||
}
|
||||
|
||||
return new ConnectResult(
|
||||
Success: true,
|
||||
StatusMessage: FormatConnectedStatus(libraries.Count, urlAdjusted),
|
||||
NormalizedServerUrl: normalizedUrl,
|
||||
ServerUrlAdjusted: urlAdjusted,
|
||||
Libraries: libraries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ConnectResult(
|
||||
Success: false,
|
||||
StatusMessage: $"Connection failed: {ex.Message}",
|
||||
NormalizedServerUrl: normalizedUrl,
|
||||
ServerUrlAdjusted: urlAdjusted,
|
||||
Libraries: []);
|
||||
}
|
||||
}
|
||||
|
||||
public static string FormatConnectedStatus(int libraryCount, bool urlAdjusted)
|
||||
{
|
||||
var libraryWord = libraryCount == 1 ? "library" : "libraries";
|
||||
var message = $"Connected. Found {libraryCount} {libraryWord}.";
|
||||
if (urlAdjusted)
|
||||
message += " Server URL adjusted to the API base address.";
|
||||
return message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalize for persistence when possible; otherwise keep the trimmed raw value
|
||||
/// so the user can fix it on the next connect attempt.
|
||||
/// </summary>
|
||||
public static string TryNormalizeServerUrlForSave(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return url?.Trim() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
return NormalizeServerUrl(url);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return url.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<bool> BookExistsAsync(string serverUrl, string apiToken, string libraryId, string title, string? author = null)
|
||||
{
|
||||
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
|
||||
|
||||
@@ -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,89 @@
|
||||
using AudibleApi.Authorization;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Libation-facing helpers when AccountsSettings.json contains encrypted tokens
|
||||
/// that cannot be unlocked on this OS/machine (common when copying Windows config into Docker).
|
||||
/// </summary>
|
||||
public static class AccountSettingsDecryptFailure
|
||||
{
|
||||
public const string LoadErrorCaption = "Error Loading Account Settings";
|
||||
|
||||
public const string FaqUrl
|
||||
= "https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken";
|
||||
|
||||
private static readonly string[] ThingsToTryBullets =
|
||||
[
|
||||
"Keep tokens encrypted: on the machine that created the file, export the master key (Settings -> Important -> Export encryption key..., or LibationCli export-master-key libation-master.key), then copy that file into the Docker/config folder next to AccountsSettings.json (or set LIBATION_MASTER_KEY_FILE / LIBATION_MASTER_KEY).",
|
||||
"Or convert to plaintext: Settings -> Important, uncheck \"Store authentication tokens encrypted\", convert existing tokens when prompted, then copy the updated AccountsSettings.json again.",
|
||||
"Or re-authenticate on this machine/container with LibationCli login-external or import-account.",
|
||||
$"Details: {FaqUrl}",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="ex"/> (or an inner exception) is a decrypt failure from identity token JSON.
|
||||
/// </summary>
|
||||
public static bool TryFindInTree(Exception ex, out IdentityTokenDecryptException? match)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
IdentityTokenDecryptException? found = null;
|
||||
walk(ex);
|
||||
match = found;
|
||||
return found is not null;
|
||||
|
||||
void walk(Exception? e)
|
||||
{
|
||||
if (e is null || found is not null)
|
||||
return;
|
||||
|
||||
if (e is IdentityTokenDecryptException decryptEx)
|
||||
{
|
||||
found = decryptEx;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e is AggregateException agg)
|
||||
{
|
||||
foreach (var inner in agg.InnerExceptions)
|
||||
walk(inner);
|
||||
}
|
||||
|
||||
walk(e.InnerException);
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<string> GetExplainerLines(Exception ex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
|
||||
yield return "Encrypted authentication tokens in AccountsSettings.json could not be decrypted on this machine.";
|
||||
yield return "The encryption key is stored in the OS secret store where the tokens were encrypted (for example Windows DPAPI) and does not travel when you copy the file to Docker or another computer.";
|
||||
if (TryFindInTree(ex, out var decryptEx) && decryptEx is not null)
|
||||
yield return $"Underlying error: {decryptEx.Message.TrimEnd('.')}.";
|
||||
yield return string.Empty;
|
||||
yield return "Things to try:";
|
||||
foreach (var bullet in ThingsToTryBullets)
|
||||
yield return "• " + bullet;
|
||||
}
|
||||
|
||||
public static string GetExplainerBody(Exception ex)
|
||||
=> string.Join("\r\n", GetExplainerLines(ex));
|
||||
|
||||
/// <summary>
|
||||
/// Dialog body after backup-and-empty recovery when load failed due to token decrypt.
|
||||
/// </summary>
|
||||
public static string GetRecoveredDialogBody(Exception ex, string backupPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(backupPath);
|
||||
|
||||
return $"""
|
||||
{GetExplainerBody(ex)}
|
||||
|
||||
Libation created a new, empty account settings file so the app can start. You will need to re-add your Audible account(s) (or copy a plaintext AccountsSettings.json) before scanning or downloading.
|
||||
|
||||
The previous account settings file was archived at '{backupPath}'
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -108,12 +108,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)
|
||||
@@ -135,24 +169,17 @@ public class AccountsSettings : IUpdatable
|
||||
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,8 @@ 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.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
@@ -23,6 +18,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 +31,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,22 +45,49 @@ 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)
|
||||
throw new AuthenticationRequiredException(account, innerException: 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);
|
||||
|
||||
if (LoginChoiceFactory is null)
|
||||
throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
|
||||
Serilog.Log.Logger.Error(ex,
|
||||
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,
|
||||
LooksLikeMissingCredentials = missingCredentials
|
||||
});
|
||||
|
||||
// masked: this message is logged verbatim, both as {Exception} and as ExceptionDetail.Message
|
||||
throw new AuthenticationRequiredException(
|
||||
account,
|
||||
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.")
|
||||
+ $" {ex.Message}",
|
||||
innerException: ex);
|
||||
}
|
||||
|
||||
await InteractiveLoginGate.WaitAsync();
|
||||
try
|
||||
@@ -75,6 +105,15 @@ public class ApiExtended
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
|
||||
// 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);
|
||||
}
|
||||
finally
|
||||
@@ -119,7 +158,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);
|
||||
@@ -131,7 +177,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();
|
||||
@@ -154,11 +202,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);
|
||||
}
|
||||
|
||||
@@ -181,21 +236,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();
|
||||
@@ -203,6 +267,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
|
||||
@@ -244,15 +334,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)
|
||||
@@ -263,6 +365,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));
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="10.3.1.1" />
|
||||
<PackageReference Include="AudibleApi" Version="13.1.1.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -28,4 +28,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();
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core.IO;
|
||||
using Dinah.Core.Security;
|
||||
using LibationFileManager;
|
||||
using System.ComponentModel;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
@@ -13,6 +15,15 @@ public static class IdentityTokenStorageWiring
|
||||
{
|
||||
public const string ApplicationName = "Libation";
|
||||
|
||||
/// <summary>Env var: path to a raw 32-byte master key file (from <c>export-master-key</c>).</summary>
|
||||
public const string MasterKeyFileEnvVar = "LIBATION_MASTER_KEY_FILE";
|
||||
|
||||
/// <summary>Env var: Base64-encoded 32-byte master key.</summary>
|
||||
public const string MasterKeyEnvVar = "LIBATION_MASTER_KEY";
|
||||
|
||||
/// <summary>Default master key file name under the Libation files directory.</summary>
|
||||
public const string DefaultMasterKeyFileName = "libation-master.key";
|
||||
|
||||
private static Lock Gate { get; } = new();
|
||||
private static Configuration? _wiredConfig;
|
||||
|
||||
@@ -47,7 +58,7 @@ public static class IdentityTokenStorageWiring
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var method = config.TokenStorageMethod;
|
||||
var store = OsSecretStore.Create(ApplicationName);
|
||||
var store = ResolveSecretStore(config);
|
||||
AesGcmSecretProtector? protector = store.IsAvailable
|
||||
? new AesGcmSecretProtector(store)
|
||||
: null;
|
||||
@@ -56,6 +67,36 @@ public static class IdentityTokenStorageWiring
|
||||
IdentityTokenStorage.Configure(method, protector);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the secret store used for the AES-GCM master key.
|
||||
/// Supported priority: <see cref="MasterKeyFileEnvVar"/> -> existing default <see cref="DefaultMasterKeyFileName"/>
|
||||
/// under Libation files -> <see cref="MasterKeyEnvVar"/> -> OS-bound <see cref="OsSecretStore"/>.
|
||||
/// If the OS store is unavailable, falls through to
|
||||
/// <see cref="TryCreateLastResortPortableMasterKeyStore"/> (headless compatibility path; not the preferred setup).
|
||||
/// </summary>
|
||||
public static IOsSecretStore ResolveSecretStore(Configuration config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var keyFileEnv = Environment.GetEnvironmentVariable(MasterKeyFileEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(keyFileEnv))
|
||||
return LoadMasterKeyFileOrUnavailable(keyFileEnv.Trim());
|
||||
|
||||
var defaultKeyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName);
|
||||
if (File.Exists(defaultKeyPath))
|
||||
return LoadMasterKeyFileOrUnavailable(defaultKeyPath);
|
||||
|
||||
var keyEnv = Environment.GetEnvironmentVariable(MasterKeyEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(keyEnv))
|
||||
return LoadMasterKeyBase64OrUnavailable(keyEnv.Trim());
|
||||
|
||||
var osStore = OsSecretStore.Create(ApplicationName);
|
||||
if (osStore.IsAvailable)
|
||||
return osStore;
|
||||
|
||||
return TryCreateLastResortPortableMasterKeyStore(config);
|
||||
}
|
||||
|
||||
/// <summary>True when the OS secret store can hold Libation's encryption master key.</summary>
|
||||
public static bool IsOsSecretStoreAvailable(out string? unavailableReason)
|
||||
{
|
||||
@@ -64,6 +105,153 @@ public static class IdentityTokenStorageWiring
|
||||
return store.IsAvailable;
|
||||
}
|
||||
|
||||
/// <summary>True when any resolved secret store (portable or OS) can supply an encryption master key.</summary>
|
||||
public static bool IsEncryptionKeyAvailable(Configuration config, out string? unavailableReason)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
var store = ResolveSecretStore(config);
|
||||
unavailableReason = store.IsAvailable ? null : store.UnavailableReason;
|
||||
return store.IsAvailable;
|
||||
}
|
||||
|
||||
/// <summary>Sidecar notice written beside an auto-minted last-resort master key.</summary>
|
||||
public const string LastResortMasterKeyNoticeFileName = "libation-master.key.NOTICE.txt";
|
||||
|
||||
/// <summary>
|
||||
/// Headless / Docker compatibility path when encryption is enabled but there is no OS secret store
|
||||
/// and the user did not supply a master key.
|
||||
/// Prefer setting <see cref="Configuration.TokenStorageMethod"/> to plaintext, or supplying an exported
|
||||
/// <see cref="DefaultMasterKeyFileName"/> / env key, instead of relying on this path.
|
||||
/// Creates <see cref="DefaultMasterKeyFileName"/> under Libation files if missing, then loads it.
|
||||
/// </summary>
|
||||
internal static IOsSecretStore TryCreateLastResortPortableMasterKeyStore(Configuration config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var keyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(keyPath))
|
||||
{
|
||||
MintRawMasterKeyFile(keyPath);
|
||||
AnnounceLastResortPortableMasterKey(keyPath);
|
||||
}
|
||||
|
||||
return LoadMasterKeyFileOrUnavailable(keyPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UnavailablePortableSecretStore(
|
||||
"Last-resort portable master key",
|
||||
$"Could not create or load last-resort portable master key ({keyPath}): {SafeMessage(ex)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warn that a last-resort portable master key was auto-created. Not the recommended setup.
|
||||
/// </summary>
|
||||
internal static void AnnounceLastResortPortableMasterKey(string keyPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyPath);
|
||||
|
||||
var fullKeyPath = Path.GetFullPath(keyPath);
|
||||
var noticePath = Path.Combine(
|
||||
Path.GetDirectoryName(fullKeyPath) ?? ".",
|
||||
LastResortMasterKeyNoticeFileName);
|
||||
|
||||
var message =
|
||||
"LAST-RESORT: Token encryption is enabled, but no OS secret store is available and no master key was supplied. "
|
||||
+ $"Libation created a portable master key at '{fullKeyPath}'. "
|
||||
+ "This is a compatibility fallback, not the recommended setup. "
|
||||
+ "Prefer: set TokenStorageMethod to Plaintext in Settings, "
|
||||
+ $"OR supply your own key (export from desktop and place '{DefaultMasterKeyFileName}' next to AccountsSettings.json, "
|
||||
+ $"or set {MasterKeyFileEnvVar} / {MasterKeyEnvVar}). "
|
||||
+ "Treat the key file like a password. It will not decrypt tokens encrypted under a different machine's key.";
|
||||
|
||||
try
|
||||
{
|
||||
AtomicFileWriter.WriteAllText(noticePath, message + Environment.NewLine);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Warning(ex, "Could not write last-resort master key notice file at {NoticePath}", noticePath);
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Warning(message);
|
||||
Console.Error.WriteLine(message);
|
||||
}
|
||||
|
||||
/// <summary>Write a new raw 32-byte master key file (same format as <c>export-master-key</c>).</summary>
|
||||
private static void MintRawMasterKeyFile(string keyPath)
|
||||
{
|
||||
var key = new byte[AesGcmSecretProtector.KeySizeBytes];
|
||||
RandomNumberGenerator.Fill(key);
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(keyPath));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
AtomicFileWriter.WriteAllBytes(keyPath, key);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static IOsSecretStore LoadMasterKeyFileOrUnavailable(string path)
|
||||
{
|
||||
var store = new MemoryOsSecretStore();
|
||||
try
|
||||
{
|
||||
MasterKeyPortability.ImportFromFile(store, path);
|
||||
return store;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UnavailablePortableSecretStore(
|
||||
"Portable master key file",
|
||||
$"Portable master key file is unusable ({path}): {SafeMessage(ex)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IOsSecretStore LoadMasterKeyBase64OrUnavailable(string base64)
|
||||
{
|
||||
byte[] key;
|
||||
try
|
||||
{
|
||||
key = Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return new UnavailablePortableSecretStore(
|
||||
"Portable master key env",
|
||||
$"{MasterKeyEnvVar} is not valid Base64: {SafeMessage(ex)}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (key.Length != AesGcmSecretProtector.KeySizeBytes)
|
||||
{
|
||||
return new UnavailablePortableSecretStore(
|
||||
"Portable master key env",
|
||||
$"{MasterKeyEnvVar} must decode to {AesGcmSecretProtector.KeySizeBytes} bytes.");
|
||||
}
|
||||
|
||||
var store = new MemoryOsSecretStore();
|
||||
store.Set(AesGcmSecretProtector.DefaultMasterKeyName, key);
|
||||
return store;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeMessage(Exception ex)
|
||||
=> string.IsNullOrWhiteSpace(ex.Message) ? ex.GetType().Name : ex.Message;
|
||||
|
||||
private static void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != nameof(Configuration.TokenStorageMethod))
|
||||
@@ -73,4 +261,26 @@ public static class IdentityTokenStorageWiring
|
||||
|
||||
ConfigureFrom(config);
|
||||
}
|
||||
|
||||
private sealed class UnavailablePortableSecretStore : IOsSecretStore
|
||||
{
|
||||
public UnavailablePortableSecretStore(string name, string reason)
|
||||
{
|
||||
Name = name;
|
||||
UnavailableReason = reason;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public bool IsAvailable => false;
|
||||
public string? UnavailableReason { get; }
|
||||
|
||||
public void Set(string key, ReadOnlySpan<byte> value)
|
||||
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
|
||||
|
||||
public bool TryGet(string key, out byte[] value)
|
||||
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
|
||||
|
||||
public void Delete(string key)
|
||||
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Dinah.Core.Security;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Export Libation's OS-bound AES-GCM master key for portable use (e.g. Docker).
|
||||
/// Never creates a new key; the desktop OS store must already hold one.
|
||||
/// </summary>
|
||||
public static class MasterKeyExport
|
||||
{
|
||||
/// <summary>
|
||||
/// Write the existing Libation master key as raw bytes to <paramref name="filePath"/>.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">OS secret store unavailable or master key missing.</exception>
|
||||
public static void ExportToFile(string filePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
|
||||
if (!IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out var unavailableReason))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Cannot export the encryption master key because the OS secret store is unavailable."
|
||||
+ (string.IsNullOrWhiteSpace(unavailableReason) ? "" : " " + unavailableReason));
|
||||
}
|
||||
|
||||
var store = OsSecretStore.Create(IdentityTokenStorageWiring.ApplicationName);
|
||||
try
|
||||
{
|
||||
MasterKeyPortability.ExportToFile(store, filePath);
|
||||
}
|
||||
catch (SecretProtectionException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Cannot export the encryption master key because it was not found. "
|
||||
+ "Encrypt tokens on this machine at least once (Settings -> Important, store tokens encrypted) so a key exists, then try again.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.0.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.2.0.1" />
|
||||
<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.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<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)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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,10 +6,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.2.0.1" />
|
||||
<PackageReference Include="Dinah.Core" Version="11.0.0.1" />
|
||||
<PackageReference Include="Polly" Version="8.6.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -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")]
|
||||
Loaded 100 of 432 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user