mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-08 11:47:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa829df265 | ||
|
|
e84a0a121a | ||
|
|
087c107685 | ||
|
|
af7a1e8f69 | ||
|
|
cbbd8b7955 | ||
|
|
edc737df4a | ||
|
|
8050e11a41 | ||
|
|
bdf55009b5 | ||
|
|
a5a28c9d62 | ||
|
|
70909a40dc | ||
|
|
a922cfdf40 | ||
|
|
bc806bada0 | ||
|
|
36207bbab3 | ||
|
|
f4dee94785 | ||
|
|
3d563a2dcc | ||
|
|
67b476eaa0 | ||
|
|
a0adf75b53 | ||
|
|
014beaf71f | ||
|
|
74a028acaa | ||
|
|
66bf8b9de9 | ||
|
|
635780a4cf | ||
|
|
82fb235de9 | ||
|
|
336a01db01 | ||
|
|
a9df4add33 | ||
|
|
129486ce89 | ||
|
|
3e7191adc7 | ||
|
|
4cd784787e | ||
|
|
5503a9d768 | ||
|
|
094e207c0b | ||
|
|
09be09bd1b | ||
|
|
19a867488b | ||
|
|
9bfdaa9632 | ||
|
|
eca013e780 | ||
|
|
5f814a55eb | ||
|
|
f980600a2c | ||
|
|
75c25fefe4 | ||
|
|
7538491f32 | ||
|
|
5268e0c375 | ||
|
|
e1449c9b1e | ||
|
|
c7f6815014 | ||
|
|
5b2c620bf5 | ||
|
|
72927943c2 | ||
|
|
b02aedb579 | ||
|
|
3ab22a9796 | ||
|
|
47980e1f86 | ||
|
|
673f8f3b06 | ||
|
|
52325e7ae8 | ||
|
|
ebb1d9ff4c | ||
|
|
3fbd0e1286 | ||
|
|
0c0b72cd87 | ||
|
|
c22efa2c0f | ||
|
|
bfaab98eff | ||
|
|
bc72aad30c | ||
|
|
31ad21f69d | ||
|
|
1ab18f5f60 | ||
|
|
77cb6bf78e | ||
|
|
1c32149c2b | ||
|
|
06356f249a | ||
|
|
c4b4fa6971 | ||
|
|
5c5d1bd946 | ||
|
|
23ecdf56ee | ||
|
|
1729f84dbb | ||
|
|
178b7ee1d8 | ||
|
|
5ff1a4f813 | ||
|
|
272241bed6 | ||
|
|
f39751da17 | ||
|
|
612ec05b19 | ||
|
|
e4dde1e6c8 | ||
|
|
c2f9392fa8 | ||
|
|
e965cc6c59 | ||
|
|
e0c780c998 | ||
|
|
f870612f92 | ||
|
|
2497e3a03e | ||
|
|
1431fd73a9 | ||
|
|
c01e83f844 | ||
|
|
04a7103ae8 | ||
|
|
df04be9dec | ||
|
|
1520647659 | ||
|
|
5956c83041 | ||
|
|
b1452dabc3 | ||
|
|
bf017ac28c | ||
|
|
798602c56f | ||
|
|
a0045156c1 | ||
|
|
8402010a2a | ||
|
|
59ab316358 | ||
|
|
5c61ebd741 | ||
|
|
b56e55bee2 | ||
|
|
cad813e28b | ||
|
|
0e6523e0ae | ||
|
|
0489be0350 | ||
|
|
bf78563746 | ||
|
|
7aecda4259 | ||
|
|
a0ef5c3d95 | ||
|
|
d4248200d1 | ||
|
|
aecffc4584 | ||
|
|
bb593fe8d1 | ||
|
|
819193fe03 | ||
|
|
76e036fb42 | ||
|
|
5e1ac3f9cc | ||
|
|
326e48c663 | ||
|
|
c376af9a95 | ||
|
|
c6e5e37f7f | ||
|
|
df9702997d | ||
|
|
edf0d32508 | ||
|
|
5f7df75c85 | ||
|
|
1839eebb20 | ||
|
|
75f1d753b1 | ||
|
|
cc40d5e494 | ||
|
|
5ef3f3e11e | ||
|
|
03dab18223 | ||
|
|
492d2e9ae3 | ||
|
|
b6875591f0 | ||
|
|
29a9cb3873 | ||
|
|
1f38ba8abf | ||
|
|
8b166312c6 | ||
|
|
f816ccf3e4 | ||
|
|
1eddccbe63 | ||
|
|
84c6f6fed0 | ||
|
|
d36369b321 | ||
|
|
5d0ed3af68 | ||
|
|
059b817b03 | ||
|
|
0c66a2ede9 | ||
|
|
b38e298ddc | ||
|
|
e9e56182b8 | ||
|
|
819a0960be | ||
|
|
0b03be5d02 | ||
|
|
7cb58c7912 | ||
|
|
10c1cc2a60 | ||
|
|
49e8164b6f | ||
|
|
a030853d6b | ||
|
|
1d9b41c2a4 | ||
|
|
1e81c86f65 | ||
|
|
5ce147ef83 | ||
|
|
7e89a96ffc | ||
|
|
8c1b754180 | ||
|
|
0cecaad686 | ||
|
|
63dc8fc988 | ||
|
|
6a166b23fb | ||
|
|
ed20356af9 | ||
|
|
316099b42f | ||
|
|
71e8d8eea1 | ||
|
|
cb8c1b3a3e | ||
|
|
d4b5a48b79 | ||
|
|
f0d344099a | ||
|
|
a60299efe6 | ||
|
|
e97c68257d | ||
|
|
afd970c672 | ||
|
|
4a138bb42f | ||
|
|
3b33ee2c43 | ||
|
|
88243825eb | ||
|
|
0ca01c338d | ||
|
|
d23b87d2ad | ||
|
|
955c5ca9b2 | ||
|
|
0934701d75 | ||
|
|
cb01b9b3b1 | ||
|
|
fc973d4f5f | ||
|
|
2aa6d8c078 | ||
|
|
bc297330ad | ||
|
|
b959d878aa | ||
|
|
cc50f183c0 | ||
|
|
bf9b85331b | ||
|
|
6288bc543e | ||
|
|
d000a8ee19 | ||
|
|
12cbc7e07d | ||
|
|
4df08d383a | ||
|
|
a42c332cf4 | ||
|
|
240454efa8 | ||
|
|
099b01af14 | ||
|
|
af163df6ca | ||
|
|
0efe50b57a | ||
|
|
a0ca574974 | ||
|
|
d240ab12ec | ||
|
|
cc90740de4 | ||
|
|
6fa65238db | ||
|
|
c827b9bb8d | ||
|
|
6fba3d61f3 | ||
|
|
294608d95c | ||
|
|
0d13200d58 | ||
|
|
e6e8ba73e0 | ||
|
|
4825acc2dd | ||
|
|
2e49f0ddb5 | ||
|
|
d27445a128 | ||
|
|
b9aa51cd00 | ||
|
|
5beaad0498 | ||
|
|
e6f8c6fae3 | ||
|
|
1e2c9450cf | ||
|
|
b80cf1b22d | ||
|
|
c11268c6b4 | ||
|
|
14c56e4052 | ||
|
|
bbb401ef87 | ||
|
|
9ea66ddaee | ||
|
|
e8d215e5eb | ||
|
|
2b3325ed0d | ||
|
|
c813d6ee98 | ||
|
|
f472334f09 | ||
|
|
4ac94d1ff2 | ||
|
|
742e58d2e8 | ||
|
|
d0cf459450 | ||
|
|
625c0f2330 | ||
|
|
337cb3169a | ||
|
|
63778135c6 | ||
|
|
383d768f46 | ||
|
|
78a044697a | ||
|
|
3a3ff42b2f | ||
|
|
14ec4804f4 | ||
|
|
537f53d856 | ||
|
|
6a6e1ce330 | ||
|
|
4187712c8d | ||
|
|
381cf307f7 | ||
|
|
d7a794e557 | ||
|
|
524e1ced50 | ||
|
|
2b7214e3dd | ||
|
|
325af3a491 | ||
|
|
ac19e9e80b | ||
|
|
e49f410544 | ||
|
|
9383a0456e | ||
|
|
5ca43895f2 | ||
|
|
986dda5eaa | ||
|
|
2db63a8edd | ||
|
|
ad59032693 | ||
|
|
e11186da99 | ||
|
|
e90850f16d | ||
|
|
d93d325913 | ||
|
|
bb0ece9ee4 | ||
|
|
209fd3aa4c | ||
|
|
a122a6917b | ||
|
|
9afcb09885 | ||
|
|
1999307dfa | ||
|
|
a297715b8a | ||
|
|
6daaf33dbc | ||
|
|
bb55fb40a3 | ||
|
|
d7a6ef0302 | ||
|
|
77bbc1b0a0 | ||
|
|
4409fb6801 | ||
|
|
b212c47cad | ||
|
|
8fd3b91858 | ||
|
|
87e2ea0ad3 | ||
|
|
870b596d3e | ||
|
|
54485c0825 | ||
|
|
0f4cfac3b0 | ||
|
|
79bdbe6d76 | ||
|
|
178715499f | ||
|
|
69c4f9f0d1 | ||
|
|
cacff3c71b | ||
|
|
252decb3ad | ||
|
|
854cb280a0 | ||
|
|
824ff10dd9 | ||
|
|
0aa9cb0019 | ||
|
|
eea9667987 | ||
|
|
4d8075927f | ||
|
|
4a200c6862 | ||
|
|
bc69d6fc22 | ||
|
|
5d2aad045d | ||
|
|
f0eddacca7 | ||
|
|
cf5903e5be | ||
|
|
12e1afeeaa | ||
|
|
2cceabb10e | ||
|
|
41de40e6cb | ||
|
|
219abe0126 | ||
|
|
d4a18aa0a5 | ||
|
|
031627d4a7 | ||
|
|
fe92737066 | ||
|
|
28e5ed3be2 | ||
|
|
d52037c398 | ||
|
|
f806af9632 | ||
|
|
562b563477 | ||
|
|
d51f816355 | ||
|
|
b35276c7a1 | ||
|
|
3129221531 | ||
|
|
59066b7139 | ||
|
|
818510d1b0 | ||
|
|
85191d2984 | ||
|
|
1652f63770 | ||
|
|
80e88cb303 | ||
|
|
5af29b6bed | ||
|
|
5cf16133d8 | ||
|
|
12d499a901 | ||
|
|
b6e0f4b6ab | ||
|
|
325be98bc0 | ||
|
|
739f1a273a | ||
|
|
3c35e63bc8 | ||
|
|
2eb12a26de | ||
|
|
71e2cbb271 | ||
|
|
edc73629be | ||
|
|
c5b7f836c1 | ||
|
|
ee94c6f3d6 | ||
|
|
69ab7f1c3f | ||
|
|
c09a08b7b6 | ||
|
|
99b52103a7 | ||
|
|
2a4442b5f4 | ||
|
|
7c21fc6d25 | ||
|
|
6e4f4bb67d | ||
|
|
ac4186ac25 | ||
|
|
f278b3314b | ||
|
|
dcaa42d21d | ||
|
|
7b0bd603a6 | ||
|
|
c04b24f722 | ||
|
|
8240d29eae | ||
|
|
877912e6e8 | ||
|
|
a9af8a71c6 | ||
|
|
26e603d94d | ||
|
|
2257cdbb9d | ||
|
|
fde3c98b98 | ||
|
|
5b8e6eaeac | ||
|
|
a0d1c59281 | ||
|
|
a5a20dc2c9 | ||
|
|
198d399803 | ||
|
|
abdc14c555 | ||
|
|
a5b1df0d29 | ||
|
|
f8bd2cee03 | ||
|
|
6ca78b04a4 | ||
|
|
958a31a306 | ||
|
|
90856b29a6 | ||
|
|
ac30cb0469 | ||
|
|
813b5eeb6e | ||
|
|
d49ba270cc | ||
|
|
31eb9b5e4b | ||
|
|
f36e302c93 | ||
|
|
895e47d513 | ||
|
|
44db619c0c | ||
|
|
d7c757ed57 | ||
|
|
cba29d94ba | ||
|
|
dc556d61b6 | ||
|
|
9ac1f64056 | ||
|
|
b88da2e993 | ||
|
|
2fb48bfd0c | ||
|
|
fdb259ebbd | ||
|
|
91c00d0970 | ||
|
|
3a61cae163 | ||
|
|
5664551bd2 | ||
|
|
4e6bba54b5 | ||
|
|
8f56046a11 | ||
|
|
a01fece235 | ||
|
|
791425670b | ||
|
|
a40e9e7cf7 | ||
|
|
e36db71534 | ||
|
|
da23a7ddb0 | ||
|
|
16e6a6f659 | ||
|
|
742d2b140a | ||
|
|
befd1a4c3c | ||
|
|
e859af0296 | ||
|
|
4a5c81b5e6 | ||
|
|
eebc96f439 | ||
|
|
065118cf6c | ||
|
|
8ea95ee812 | ||
|
|
c144c20c53 | ||
|
|
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 | ||
|
|
a079752c71 | ||
|
|
a2b4e432b5 | ||
|
|
272dcd87be | ||
|
|
6268fb0332 | ||
|
|
a7f6075a12 | ||
|
|
b5e87847ff | ||
|
|
ad61618cc9 | ||
|
|
3580f6d53a | ||
|
|
1493777396 | ||
|
|
0836a79990 | ||
|
|
2c52513ea9 | ||
|
|
20150cc4ed | ||
|
|
3e03fad490 | ||
|
|
abea874db8 | ||
|
|
bb18e9adc4 | ||
|
|
a9e6f75443 | ||
|
|
b5485f77bf | ||
|
|
936b307d3e | ||
|
|
1c33a253a2 | ||
|
|
b4f7cf4288 | ||
|
|
e1bede5a6a | ||
|
|
5c67351aba | ||
|
|
df6db12f77 | ||
|
|
89155e0b0b | ||
|
|
934e7c6618 | ||
|
|
e372bae144 | ||
|
|
270985341a | ||
|
|
fd03c1bd9c | ||
|
|
123da3c653 | ||
|
|
15838fb2cf | ||
|
|
746f620f95 | ||
|
|
1117eb378a | ||
|
|
07c2f2b2a1 | ||
|
|
a0c59f15e1 | ||
|
|
353a5b8800 | ||
|
|
190818323d | ||
|
|
f180430a3f | ||
|
|
dec86bdee1 | ||
|
|
6e4acd8046 | ||
|
|
80c6c6b9d4 | ||
|
|
3cdaa09aff | ||
|
|
ecc7e49a21 | ||
|
|
fb41955015 | ||
|
|
a8e3404500 | ||
|
|
9ff15a07aa | ||
|
|
674abd1707 | ||
|
|
d30086e53a | ||
|
|
82d4001f0a | ||
|
|
8ed4874c76 | ||
|
|
8898174507 | ||
|
|
2b4923112e | ||
|
|
d955cb7605 | ||
|
|
23f36e2ef1 | ||
|
|
46dc400b14 | ||
|
|
4f2e832327 | ||
|
|
4bd7796f70 | ||
|
|
b6d8378728 | ||
|
|
348056691f | ||
|
|
7bd14bd5af | ||
|
|
a85179f50a | ||
|
|
158ee3d5ba | ||
|
|
b654a8b551 | ||
|
|
bc28398e1c | ||
|
|
4d1b78f7e4 | ||
|
|
a50712256c | ||
|
|
4345971e81 | ||
|
|
1ad458fe5b | ||
|
|
1547e6bec3 | ||
|
|
81175e5427 | ||
|
|
14551c745f | ||
|
|
e3cfe3a8f0 | ||
|
|
51fbe73ea1 | ||
|
|
41fa7b7142 | ||
|
|
b7ce96e91d | ||
|
|
a25fd35f29 | ||
|
|
20a99c6e20 | ||
|
|
f39cfaccc7 | ||
|
|
6db78e4268 | ||
|
|
74e9cf24a8 | ||
|
|
6ce88072c5 | ||
|
|
9058c7368c | ||
|
|
37d5517756 | ||
|
|
0a33863635 | ||
|
|
d03955a2ca | ||
|
|
b2c5ae3c2e | ||
|
|
fb7e0857c6 | ||
|
|
60965902ca | ||
|
|
ef62ee15e2 | ||
|
|
c285522a0c | ||
|
|
0fb3eb152f | ||
|
|
63325b9cad | ||
|
|
37a60ca654 | ||
|
|
6623300883 | ||
|
|
329df0882a | ||
|
|
da000694d7 | ||
|
|
f1a7a837f3 | ||
|
|
869917e0ce | ||
|
|
d532e40fe9 | ||
|
|
9ef5e95801 | ||
|
|
c1c3c33ea8 | ||
|
|
b31e7cb819 | ||
|
|
314a46d59d | ||
|
|
721f09a62b | ||
|
|
545b3057d4 | ||
|
|
2c2ae2c8b0 | ||
|
|
f456c68176 | ||
|
|
e6c17053f7 | ||
|
|
0c22b821bf | ||
|
|
8902ddc565 | ||
|
|
9ed98cbfff | ||
|
|
3cf5d0b3fa | ||
|
|
6bd1ea7ca4 | ||
|
|
f8c5f0da68 | ||
|
|
a3f272a48e | ||
|
|
9ee59738eb | ||
|
|
f57839c104 | ||
|
|
d63fabd73d | ||
|
|
54a97cc3c4 | ||
|
|
1e3daa40dc | ||
|
|
8f3f069d54 | ||
|
|
88e3bbab89 | ||
|
|
ca52ed4b7a | ||
|
|
bb4a5298ae | ||
|
|
9512e62deb | ||
|
|
91174ed8dd | ||
|
|
b9490966bb | ||
|
|
d1114b410e | ||
|
|
49e9b0c9ce | ||
|
|
0b0d1afc48 | ||
|
|
77f518f8d3 | ||
|
|
9f1d4a383b | ||
|
|
74b18c170a | ||
|
|
c85c11b27a | ||
|
|
c8ac585ab3 | ||
|
|
55b45ae45d | ||
|
|
776cd5215f | ||
|
|
1b8840c3b0 | ||
|
|
97537df933 | ||
|
|
eb4ab59737 | ||
|
|
55c2bf3be7 | ||
|
|
ebce0c073b | ||
|
|
0499bbae7e | ||
|
|
4be0361e42 | ||
|
|
c2c7b04acd | ||
|
|
f194032139 | ||
|
|
a21bb8174d | ||
|
|
3fc5ab0f8d | ||
|
|
591a6d1594 | ||
|
|
c4ac900045 | ||
|
|
50fde66a4c | ||
|
|
597c8f7b3d | ||
|
|
4ffcbeb1db | ||
|
|
6996fb1b81 | ||
|
|
7ef10b1e7d | ||
|
|
0a644695d8 | ||
|
|
86b50b45ef | ||
|
|
727c869ba9 | ||
|
|
f330d8bed6 | ||
|
|
9aecd26aea | ||
|
|
0cc2ef773d |
No files matched your search
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -37,9 +37,9 @@ jobs:
|
||||
env:
|
||||
RUNTIME_ID: "linux-${{ inputs.architecture }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
- uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
|
||||
@@ -40,9 +40,9 @@ jobs:
|
||||
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
|
||||
p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
- uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
|
||||
@@ -16,6 +16,10 @@ on:
|
||||
type: boolean
|
||||
publish-r2r:
|
||||
type: boolean
|
||||
build-installer:
|
||||
type: boolean
|
||||
description: "Build *-setup.exe on all Windows matrix jobs when release (zips always built for .releaseindex.json)"
|
||||
default: false
|
||||
retention-days:
|
||||
type: number
|
||||
|
||||
@@ -28,18 +32,20 @@ jobs:
|
||||
ui: [Avalonia]
|
||||
architecture: [x64]
|
||||
release_name: [chardonnay]
|
||||
artifact_stem: [Libation]
|
||||
include:
|
||||
- architecture: x64
|
||||
ui: WinForms
|
||||
release_name: classic
|
||||
prefix: Classic-
|
||||
artifact_stem: Libation-Classic
|
||||
- architecture: arm64
|
||||
ui: Avalonia
|
||||
release_name: chardonnay
|
||||
artifact_stem: Libation
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
- uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
@@ -60,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
|
||||
@@ -75,7 +91,12 @@ jobs:
|
||||
"WindowsConfigApp.deps.json")
|
||||
|
||||
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
|
||||
$artifact="${{ matrix.prefix }}Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -85,3 +106,40 @@ jobs:
|
||||
path: ./bin/${{ steps.zip.outputs.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
|
||||
- name: Install Inno Setup
|
||||
if: ${{ inputs.build-installer }}
|
||||
run: choco install innosetup -y --no-progress
|
||||
|
||||
- name: Build Windows installer
|
||||
id: setup
|
||||
if: ${{ inputs.build-installer }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ("${{ matrix.ui }}" -eq "WinForms") {
|
||||
$setupName = "Libation-Classic.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}-setup.exe"
|
||||
$ui = "WinForms"
|
||||
} else {
|
||||
$setupName = "Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}-setup.exe"
|
||||
$ui = "Avalonia"
|
||||
}
|
||||
./Scripts/Windows/Build-WindowsInstaller.ps1 `
|
||||
-Version "${{ inputs.libation-version }}" `
|
||||
-Ui $ui `
|
||||
-Architecture "${{ matrix.architecture }}" `
|
||||
-SkipPublish `
|
||||
-BinDir "${{ github.workspace }}/bin"
|
||||
if (-not (Test-Path "${{ github.workspace }}/bin/$setupName")) {
|
||||
throw "Installer not found: bin/$setupName"
|
||||
}
|
||||
"setup=$setupName" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Upload installer artifact
|
||||
if: ${{ inputs.build-installer }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.setup.outputs.setup }}
|
||||
path: ./bin/${{ steps.setup.outputs.setup }}
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
|
||||
@@ -35,6 +35,7 @@ jobs:
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
run-unit-tests: ${{ inputs.run-unit-tests }}
|
||||
publish-r2r: ${{ inputs.publish-r2r }}
|
||||
build-installer: ${{ inputs.release }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
|
||||
macOS:
|
||||
@@ -67,3 +68,5 @@ jobs:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
OS: ${{ matrix.OS }}
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ on:
|
||||
- docs/**
|
||||
- .vitepress/**
|
||||
- index.md
|
||||
- package.json
|
||||
- package-lock.json
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
@@ -32,7 +34,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
||||
# - uses: pnpm/action-setup@v4 # Uncomment this block if you're using pnpm
|
||||
@@ -40,7 +42,7 @@ jobs:
|
||||
# version: 9 # Not needed if you've set "packageManager" in package.json
|
||||
# - uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm # or pnpm / yarn
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
@@ -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 }}
|
||||
@@ -55,9 +55,13 @@ jobs:
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
# Avoid default git context refs/pull/*/merge (breaks after PR merge or on some re-runs).
|
||||
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 }}
|
||||
@@ -41,11 +41,19 @@ jobs:
|
||||
needs: [prerelease, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
- name: Download Chardonnay, Linux, and Mac artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: "*(Classic-)Libation.*"
|
||||
pattern: "Libation.*"
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Classic Windows artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: "Libation-Classic.*"
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
id: release
|
||||
@@ -57,4 +65,4 @@ jobs:
|
||||
draft: true
|
||||
prerelease: false
|
||||
files: |
|
||||
artifacts/*/*
|
||||
artifacts/*
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v11
|
||||
with:
|
||||
days-before-stale: 30
|
||||
days-before-close: 14
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
sync-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Sync labels
|
||||
uses: micnncim/action-label-syncer@v1
|
||||
|
||||
@@ -17,6 +17,6 @@ jobs:
|
||||
container:
|
||||
image: ghcr.io/flathub/flatpak-builder-lint:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Check the MetaInfo file
|
||||
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
validate-desktop-file:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- run: sudo apt --yes install desktop-file-utils
|
||||
- name: Check the desktop file
|
||||
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
|
||||
"WindowsClassic": "(?:Classic-Libation|Libation-Classic)\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
|
||||
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
|
||||
"WindowsAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-arm64\\.zip",
|
||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
|
||||
|
||||
+49
-3
@@ -2,6 +2,29 @@ import { defineConfig } from "vitepress";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
// esbuild 0.28+ will not downlevel destructuring for safari14 / chrome87.
|
||||
// Raise the target so docs:dev and docs:build do not need that transform.
|
||||
build: {
|
||||
target: "es2022",
|
||||
},
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
target: "es2022",
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
supported: {
|
||||
destructuring: true,
|
||||
},
|
||||
},
|
||||
// Avoid watching the C# solution / VS locks (EBUSY on .vsidx files).
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ["**/Source/**", "**/.vs/**", "**/bin/**", "**/obj/**"],
|
||||
},
|
||||
},
|
||||
},
|
||||
title: "Libation",
|
||||
description:
|
||||
"Libation: Liberate your Library - A free application for downloading your Audible audiobooks",
|
||||
@@ -35,7 +58,7 @@ export default defineConfig({
|
||||
text: "Issues & Requests",
|
||||
link: "https://github.com/rmcrackan/Libation/issues",
|
||||
},
|
||||
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
|
||||
{ text: "Donate", link: "/donate" },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
@@ -47,7 +70,7 @@ export default defineConfig({
|
||||
text: "Issues & Requests",
|
||||
link: "https://github.com/rmcrackan/Libation/issues",
|
||||
},
|
||||
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
|
||||
{ text: "Donate", link: "/donate" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -67,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",
|
||||
@@ -76,6 +115,7 @@ export default defineConfig({
|
||||
text: "Easy guide to searching",
|
||||
link: "/docs/features/lucene",
|
||||
},
|
||||
{ text: "Trash Bin", link: "/docs/features/trash-bin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -84,7 +124,9 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: "Advanced Topics", link: "/docs/advanced/advanced" },
|
||||
{ text: "Command Line Interface", link: "/docs/advanced/command-line-interface" },
|
||||
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" }
|
||||
{ text: "Device registration", link: "/docs/advanced/device-registration" },
|
||||
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" },
|
||||
{ text: "Spatial Audio & DRM", link: "/docs/advanced/spatial-audio" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -96,6 +138,7 @@ export default defineConfig({
|
||||
link: "/docs/development/getting-started",
|
||||
},
|
||||
{ text: "Contribute", link: "/docs/development/contribute" },
|
||||
{ text: "Testing Changes", link: "/docs/development/testing" },
|
||||
{ text: "Website & Docs", link: "/docs/development/website" },
|
||||
{ text: "Linux Setup (Nix)", link: "/docs/development/nix-linux-setup" },
|
||||
],
|
||||
@@ -113,5 +156,8 @@ export default defineConfig({
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
|
||||
// Show the external-link arrow on outbound links in markdown (nav/sidebar already do).
|
||||
externalLinkIcon: true,
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,51 @@
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* Donate page: primary options */
|
||||
.donate-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.donate-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
min-width: 11rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
text-decoration: none !important;
|
||||
color: var(--vp-c-text-1) !important;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.donate-option:hover {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-bg-elv);
|
||||
}
|
||||
|
||||
.donate-option__icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.donate-option__label {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.donate-option__hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
/* Hide certain nav items on tablet devices to prevent horizontal scroll */
|
||||
@media (min-width: 640px) and (max-width: 959px) {
|
||||
/* Target specific nav items by their position */
|
||||
|
||||
@@ -102,6 +102,7 @@ export function getDetectOS() {
|
||||
if (lower.endsWith('.deb')) packageType = 'deb'
|
||||
else if (lower.endsWith('.rpm')) packageType = 'rpm'
|
||||
else if (lower.endsWith('.dmg')) packageType = 'dmg'
|
||||
else if (lower.endsWith('-setup.exe')) packageType = 'installer'
|
||||
else if (lower.endsWith('.zip') || lower.endsWith('.msi')) packageType = 'archive'
|
||||
return { platform, arch, packageType: packageType || 'unknown', name }
|
||||
}
|
||||
@@ -141,6 +142,8 @@ export function getDetectOS() {
|
||||
if (wantOs === 'linux' && p.packageType === 'deb' && preferDeb) s += 20
|
||||
if (wantOs === 'linux' && p.packageType === 'rpm' && preferRpm) s += 20
|
||||
if (wantOs === 'linux' && !preferDeb && !preferRpm && p.packageType === 'deb') s += 5
|
||||
if (wantOs === 'windows' && p.packageType === 'installer') s += 30
|
||||
if (wantOs === 'windows' && p.packageType === 'archive') s -= 10
|
||||
if (nameLower.includes('chardonnay')) s += 10
|
||||
if (nameLower.includes('classic')) s -= 5
|
||||
return s
|
||||
@@ -154,6 +157,20 @@ export function getDetectOS() {
|
||||
if (wantArch) altCandidates = altCandidates.filter(e => e.parsed.arch === wantArch)
|
||||
const alternatives = altCandidates.map(e => ({ name: e.name, url: e.url }))
|
||||
const classicVsChardonnayFaqUrl = 'https://getlibation.com/docs/frequently-asked-questions#what-s-the-difference-between-classic-and-chardonnay'
|
||||
function isWindowsSetup(name) {
|
||||
return (name || '').toLowerCase().endsWith('-setup.exe')
|
||||
}
|
||||
function preferWindowsAsset(current, candidate) {
|
||||
if (!candidate) return current
|
||||
if (!current) return candidate
|
||||
const candSetup = isWindowsSetup(candidate.name)
|
||||
const currSetup = isWindowsSetup(current.name)
|
||||
if (candSetup && !currSetup) return candidate
|
||||
return current
|
||||
}
|
||||
function isClassicWindowsAsset(nameLower) {
|
||||
return nameLower.includes('libation-classic') || nameLower.includes('classic-libation')
|
||||
}
|
||||
let windowsX64Choice = null
|
||||
if (wantOs === 'windows' && wantArch === 'x64') {
|
||||
let winChardonnay = null
|
||||
@@ -162,10 +179,15 @@ export function getDetectOS() {
|
||||
const e = parsed[j]
|
||||
if (e.parsed.platform !== 'windows' || e.parsed.arch !== 'x64') continue
|
||||
const n = (e.name || '').toLowerCase()
|
||||
if (n.includes('chardonnay')) winChardonnay = { name: e.name, url: e.url }
|
||||
if (n.includes('classic')) winClassic = { name: e.name, url: e.url }
|
||||
if (n.includes('chardonnay')) winChardonnay = preferWindowsAsset(winChardonnay, e)
|
||||
else if (isClassicWindowsAsset(n)) winClassic = preferWindowsAsset(winClassic, e)
|
||||
}
|
||||
if (winChardonnay && winClassic) {
|
||||
windowsX64Choice = {
|
||||
chardonnay: { name: winChardonnay.name, url: winChardonnay.url },
|
||||
classic: { name: winClassic.name, url: winClassic.url },
|
||||
}
|
||||
}
|
||||
if (winChardonnay && winClassic) windowsX64Choice = { chardonnay: winChardonnay, classic: winClassic }
|
||||
}
|
||||
if (!recommended && wantOs) {
|
||||
reason = 'No asset matched your system (OS: ' + os + ', arch: ' + arch + '). Try choosing a download manually.'
|
||||
|
||||
@@ -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"]
|
||||
@@ -32,6 +32,7 @@ Comprehensive documentation is available in the `docs` directory and on our [Doc
|
||||
|
||||
- [Getting Started](https://getlibation.com/docs/getting-started)
|
||||
- [FAQ](https://getlibation.com/docs/frequently-asked-questions)
|
||||
- [Spatial Audio & DRM](https://getlibation.com/docs/advanced/spatial-audio)
|
||||
|
||||
## Development & Contributing
|
||||
|
||||
@@ -39,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
|
||||
|
||||
@@ -53,5 +55,5 @@ Libation is released under the GPL-3.0 License
|
||||
|
||||
---
|
||||
|
||||
If you found this useful, tell a friend. If you found this REALLY useful, you can click here to PayPal.me
|
||||
If you found this useful, tell a friend. If you found this REALLY useful, you can [donate here](https://getlibation.com/donate)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain free and open source.
|
||||
+49
-9
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
BIN_DIR=$1; shift
|
||||
VERSION=$1; shift
|
||||
@@ -8,25 +9,25 @@ SIGN_WITH_KEY=$1; shift
|
||||
if [ -z "$BIN_DIR" ]
|
||||
then
|
||||
echo "This script must be called with a the Libation macos bins directory as an argument."
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$BIN_DIR" ]
|
||||
then
|
||||
echo "The directory \"$BIN_DIR\" does not exist."
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z $VERSION ]
|
||||
if [ -z "${VERSION:-}" ]
|
||||
then
|
||||
echo "This script must be called with the Libation version number as an argument."
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z $ARCH ]
|
||||
if [ -z "${ARCH:-}" ]
|
||||
then
|
||||
echo "This script must be called with the Libation cpu architecture as an argument."
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SIGN_WITH_KEY" != "true" ]
|
||||
@@ -40,9 +41,46 @@ echo "Bundle dir: $BUNDLE"
|
||||
if [[ -d $BUNDLE ]]
|
||||
then
|
||||
echo "$BUNDLE directory already exists, aborting."
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# hdiutil create intermittently fails on CI with "Resource busy" (macOS background scanning).
|
||||
create_dmg_with_retry() {
|
||||
local src_folder="$1"
|
||||
local output_path="$2"
|
||||
local max_attempts=10
|
||||
local attempt=1
|
||||
local delay=3
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
echo "Creating disk image (attempt ${attempt}/${max_attempts}): ${output_path}"
|
||||
rm -f "${output_path}"
|
||||
sync
|
||||
sleep 1
|
||||
|
||||
if hdiutil create -srcFolder "${src_folder}" -ov -format UDZO -o "${output_path}"; then
|
||||
if [ -f "${output_path}" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "hdiutil create failed on attempt ${attempt}"
|
||||
hdiutil detach -force /Volumes/Libation 2>/dev/null || true
|
||||
rm -f "${output_path}" "${output_path}.temp.dmg" 2>/dev/null || true
|
||||
sync
|
||||
|
||||
if [ "$attempt" -lt "$max_attempts" ]; then
|
||||
echo "Waiting ${delay}s before retry..."
|
||||
sleep "${delay}"
|
||||
delay=$((delay + 2))
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "Error: hdiutil create failed after ${max_attempts} attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
BUNDLE_CONTENTS=$BUNDLE/Contents
|
||||
echo "Bundle Contents dir: $BUNDLE_CONTENTS"
|
||||
|
||||
@@ -72,6 +110,9 @@ mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
||||
echo "Moving Info.plist file..."
|
||||
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
|
||||
|
||||
echo "Writing PkgInfo file..."
|
||||
printf 'APPL????' > $BUNDLE_CONTENTS/PkgInfo
|
||||
|
||||
echo "Moving Libation_DS_Store file..."
|
||||
mv $BUNDLE_MACOS/Libation_DS_Store ./Libation_DS_Store
|
||||
|
||||
@@ -118,8 +159,7 @@ mkdir Libation/.background
|
||||
mv background.png Libation/.background/
|
||||
ln -s /Applications "./Libation/ "
|
||||
mkdir ./bundle
|
||||
sync
|
||||
hdiutil create -srcFolder ./Libation -o "./bundle/$DMG_FILE"
|
||||
create_dmg_with_retry ./Libation "./bundle/$DMG_FILE"
|
||||
# Create a .DS_Store by:
|
||||
# - mounting an existing image in shadow mode (hdiutil attach Libation.dmg -shadow junk.dmg)
|
||||
# - Open the folder and edit it to your liking.
|
||||
|
||||
@@ -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,164 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds a Libation Windows setup.exe from a published bin directory using Inno Setup.
|
||||
|
||||
.DESCRIPTION
|
||||
Optionally runs dotnet publish (same projects as CI), removes standalone WindowsConfigApp
|
||||
artifacts, then compiles Scripts/Windows/Libation.iss.
|
||||
|
||||
.PARAMETER Version
|
||||
Libation version string (e.g. 13.4.1).
|
||||
|
||||
.PARAMETER Ui
|
||||
Avalonia (Chardonnay) or WinForms (Classic). Classic is x64 only.
|
||||
|
||||
.PARAMETER Architecture
|
||||
x64 or arm64 (Avalonia only).
|
||||
|
||||
.PARAMETER BinDir
|
||||
Folder containing published files. Defaults to repo-root bin.
|
||||
|
||||
.PARAMETER SkipPublish
|
||||
Skip dotnet publish; BinDir must already contain a complete publish output.
|
||||
|
||||
.EXAMPLE
|
||||
.\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture x64
|
||||
|
||||
.EXAMPLE
|
||||
.\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui WinForms
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Version,
|
||||
|
||||
[ValidateSet('Avalonia', 'WinForms')]
|
||||
[string] $Ui = 'Avalonia',
|
||||
|
||||
[ValidateSet('x64', 'arm64')]
|
||||
[string] $Architecture = 'x64',
|
||||
|
||||
[string] $BinDir = '',
|
||||
|
||||
[switch] $SkipPublish
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($Ui -eq 'WinForms' -and $Architecture -ne 'x64') {
|
||||
throw 'Classic (WinForms) is only published for x64.'
|
||||
}
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||
$sourceDir = Join-Path $repoRoot 'Source'
|
||||
if (-not $BinDir) {
|
||||
$BinDir = Join-Path $repoRoot 'bin'
|
||||
}
|
||||
if (-not (Test-Path $BinDir)) {
|
||||
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
|
||||
}
|
||||
$BinDir = (Resolve-Path $BinDir).Path
|
||||
|
||||
$runtime = "win-$Architecture"
|
||||
$releaseName = if ($Ui -eq 'WinForms') { 'classic' } else { 'chardonnay' }
|
||||
$outputBase = if ($Ui -eq 'WinForms') {
|
||||
"Libation-Classic.$Version-windows-$releaseName-$Architecture-setup"
|
||||
} else {
|
||||
"Libation.$Version-windows-$releaseName-$Architecture-setup"
|
||||
}
|
||||
$architecturesAllowed = if ($Architecture -eq 'arm64') { 'arm64' } else { 'x64compatible' }
|
||||
|
||||
function Find-ISCC {
|
||||
$candidates = @(
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
|
||||
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||
"${env:LocalAppData}\Programs\Inno Setup 6\ISCC.exe"
|
||||
)
|
||||
foreach ($path in $candidates) {
|
||||
if (Test-Path $path) { return $path }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $SkipPublish -and (Test-Path $BinDir)) {
|
||||
Write-Host "Clearing $BinDir before publish ..."
|
||||
Get-ChildItem -LiteralPath $BinDir -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if (-not $SkipPublish) {
|
||||
Write-Host "Publishing Libation ($Ui, $runtime) to $BinDir ..."
|
||||
Push-Location $sourceDir
|
||||
try {
|
||||
$publishArgs = @(
|
||||
'--runtime', $runtime,
|
||||
'--configuration', 'Release',
|
||||
'--output', $BinDir,
|
||||
'-p:PublishProtocol=FileSystem',
|
||||
'-p:PublishReadyToRun=false',
|
||||
'-p:SelfContained=true'
|
||||
)
|
||||
dotnet publish "Libation$Ui/Libation$Ui.csproj" @publishArgs
|
||||
dotnet publish 'LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj' @publishArgs
|
||||
dotnet publish 'LibationCli/LibationCli.csproj' @publishArgs
|
||||
dotnet publish "Hangover$Ui/Hangover$Ui.csproj" @publishArgs
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host 'Removing standalone WindowsConfigApp launcher artifacts ...'
|
||||
$removeFiles = @(
|
||||
'WindowsConfigApp.exe',
|
||||
'WindowsConfigApp.runtimeconfig.json',
|
||||
'WindowsConfigApp.deps.json'
|
||||
)
|
||||
Push-Location $BinDir
|
||||
try {
|
||||
foreach ($file in $removeFiles) {
|
||||
if (Test-Path $file) { Remove-Item $file -Force }
|
||||
}
|
||||
if (-not (Test-Path 'Libation.exe')) {
|
||||
throw "Libation.exe not found in $BinDir. Publish or point -BinDir at a complete output folder."
|
||||
}
|
||||
if (-not (Test-Path 'ZipExtractor.exe')) {
|
||||
throw "ZipExtractor.exe not found in $BinDir. It is required for in-app upgrades."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$iscc = Find-ISCC
|
||||
if (-not $iscc) {
|
||||
throw @"
|
||||
Inno Setup 6 (ISCC.exe) was not found. Install from https://jrsoftware.org/isdl.php
|
||||
and ensure ISCC.exe is under Program Files (x86)\Inno Setup 6\ or similar.
|
||||
"@
|
||||
}
|
||||
|
||||
$setupIcon = Join-Path $PSScriptRoot 'libation.ico'
|
||||
if (-not (Test-Path $setupIcon)) {
|
||||
throw "Installer icon not found: $setupIcon"
|
||||
}
|
||||
$iss = Join-Path $PSScriptRoot 'Libation.iss'
|
||||
$defines = @(
|
||||
"/DMyVersion=$Version",
|
||||
"/DMySetupIcon=$setupIcon",
|
||||
"/DMyReleaseName=$releaseName",
|
||||
"/DMyArchitecture=$Architecture",
|
||||
"/DOutputBaseFilename=$outputBase",
|
||||
"/DSourceDir=$BinDir",
|
||||
"/DOutputDir=$BinDir",
|
||||
"/DArchitecturesAllowed=$architecturesAllowed"
|
||||
)
|
||||
|
||||
Write-Host "Compiling $outputBase.exe ..."
|
||||
& $iscc $defines $iss
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ISCC failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
$setupPath = Join-Path $BinDir "$outputBase.exe"
|
||||
Write-Host "Done: $setupPath"
|
||||
@@ -0,0 +1,93 @@
|
||||
; Libation Windows installer (Inno Setup 6.x)
|
||||
;
|
||||
; Compile with ISCC and /D defines. See Scripts/Windows/README.md and
|
||||
; Build-WindowsInstaller.ps1 for local builds; CI will pass the same defines.
|
||||
;
|
||||
; Required /D defines from build script:
|
||||
; MyVersion, MyReleaseName, MyArchitecture, MySetupIcon,
|
||||
; OutputBaseFilename, SourceDir, OutputDir, ArchitecturesAllowed
|
||||
|
||||
#ifndef MyVersion
|
||||
#define MyVersion "0.0.0-dev"
|
||||
#endif
|
||||
#ifndef MyReleaseName
|
||||
#define MyReleaseName "chardonnay"
|
||||
#endif
|
||||
#ifndef MyArchitecture
|
||||
#define MyArchitecture "x64"
|
||||
#endif
|
||||
#ifndef MySetupIcon
|
||||
#define MySetupIcon "libation.ico"
|
||||
#endif
|
||||
#ifndef OutputBaseFilename
|
||||
#define OutputBaseFilename "Libation.0.0.0-dev-windows-chardonnay-x64-setup"
|
||||
#endif
|
||||
#ifndef SourceDir
|
||||
#define SourceDir "."
|
||||
#endif
|
||||
#ifndef OutputDir
|
||||
#define OutputDir "."
|
||||
#endif
|
||||
#ifndef ArchitecturesAllowed
|
||||
#define ArchitecturesAllowed "x64compatible"
|
||||
#endif
|
||||
|
||||
#if MyReleaseName == 'classic'
|
||||
#define InstallFolderName "Libation-Classic"
|
||||
#define AppName "Libation (Classic)"
|
||||
#else
|
||||
#define InstallFolderName "Libation"
|
||||
#define AppName "Libation (Chardonnay)"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
#if MyReleaseName == 'classic'
|
||||
AppId={{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}}
|
||||
#elif MyArchitecture == 'arm64'
|
||||
AppId={{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}}
|
||||
#else
|
||||
AppId={{A7B8C9D0-E1F2-4A3B-8C9D-0E1F2A3B4C5D}}
|
||||
#endif
|
||||
AppName={#AppName}
|
||||
AppVersion={#MyVersion}
|
||||
AppVerName={#AppName} {#MyVersion}
|
||||
AppPublisher=Libation
|
||||
AppPublisherURL=https://getlibation.com/
|
||||
AppSupportURL=https://github.com/rmcrackan/Libation/issues
|
||||
AppUpdatesURL=https://github.com/rmcrackan/Libation/releases
|
||||
DefaultDirName={localappdata}\{#InstallFolderName}
|
||||
DefaultGroupName={#AppName}
|
||||
PrivilegesRequired=lowest
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename={#OutputBaseFilename}
|
||||
SourceDir={#SourceDir}
|
||||
ArchitecturesAllowed={#ArchitecturesAllowed}
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
DisableProgramGroupPage=no
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
SetupIconFile={#MySetupIcon}
|
||||
UninstallDisplayIcon={app}\Libation.exe
|
||||
UninstallDisplayName={#AppName}
|
||||
VersionInfoVersion={#MyVersion}
|
||||
VersionInfoProductName={#AppName}
|
||||
VersionInfoCompany=Libation
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\Libation"; Filename: "{app}\Libation.exe"; Comment: "Libation audiobook manager"
|
||||
Name: "{group}\Hangover"; Filename: "{app}\Hangover.exe"; Comment: "Hangover license viewer"
|
||||
Name: "{group}\Libation CLI"; Filename: "{app}\LibationCli.exe"; Comment: "Libation command-line tools"
|
||||
Name: "{autodesktop}\Libation"; Filename: "{app}\Libation.exe"; Tasks: desktopicon; Comment: "Libation audiobook manager"
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\Libation.exe"; Description: "{cm:LaunchProgram,{#StringChange(AppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
@@ -0,0 +1,89 @@
|
||||
# Windows installer (Inno Setup)
|
||||
|
||||
Step 8.1 of the Windows installers plan: a parameterized Inno Setup script that installs the same flat publish layout as the release zip, under a per-user directory, so the in-app upgrader can still overlay files via `ZipExtractor.exe`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. [.NET SDK](https://dotnet.microsoft.com/download) (same version as the repo; see `build.yml`).
|
||||
2. [Inno Setup 6](https://jrsoftware.org/isdl.php) with `ISCC.exe` on PATH or in a default install location.
|
||||
|
||||
## Quick build (publish + installer)
|
||||
|
||||
From PowerShell, repo root or this folder:
|
||||
|
||||
```powershell
|
||||
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture x64
|
||||
```
|
||||
|
||||
Outputs:
|
||||
|
||||
- Publish tree: `bin\` (repo root)
|
||||
- Installer: `bin\Libation.13.4.1-windows-chardonnay-x64-setup.exe`
|
||||
|
||||
### Variants
|
||||
|
||||
| UI | `-Ui` | `-Architecture` | Install folder | Output prefix |
|
||||
|----|-------|-----------------|----------------|---------------|
|
||||
| Chardonnay x64 | `Avalonia` | `x64` | `%LocalAppData%\Libation` | `Libation.*` |
|
||||
| Chardonnay arm64 | `Avalonia` | `arm64` | `%LocalAppData%\Libation` | `Libation.*` |
|
||||
| Classic x64 | `WinForms` | `x64` (default) | `%LocalAppData%\Libation-Classic` | `Libation-Classic.*-setup.exe` |
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui WinForms
|
||||
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture arm64
|
||||
```
|
||||
|
||||
### Reuse an existing publish folder
|
||||
|
||||
```powershell
|
||||
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -BinDir C:\path\to\bin -SkipPublish
|
||||
```
|
||||
|
||||
`BinDir` must already match CI layout (including `ZipExtractor.exe`, without standalone `WindowsConfigApp.exe`).
|
||||
|
||||
## Manual ISCC (without the helper script)
|
||||
|
||||
After publish and cleanup (remove `WindowsConfigApp.exe`, `WindowsConfigApp.runtimeconfig.json`, `WindowsConfigApp.deps.json` from `bin`), from `Scripts\Windows`:
|
||||
|
||||
```text
|
||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" Libation.iss ^
|
||||
/DMyVersion=13.4.1 ^
|
||||
/DMyPrefix= ^
|
||||
/DMyReleaseName=chardonnay ^
|
||||
/DMyArchitecture=x64 ^
|
||||
/DInstallFolderName=Libation ^
|
||||
/DAppName=Libation ^
|
||||
/DOutputBaseFilename=Libation.13.4.1-windows-chardonnay-x64-setup ^
|
||||
/DSourceDir=C:\Dev\_rmcrackan\Libation\bin ^
|
||||
/DOutputDir=C:\Dev\_rmcrackan\Libation\bin ^
|
||||
/DArchitecturesAllowed=x64compatible
|
||||
```
|
||||
|
||||
Classic x64: use `MyReleaseName=classic` and matching `OutputBaseFilename` (`Libation-Classic.{version}-windows-classic-x64-setup`). Install folder and display name are set in `Libation.iss`. GitHub zip assets use `Libation-Classic.{version}-windows-classic-x64.zip`; `.releaseindex.json` also accepts legacy `Classic-Libation.*` zips on older releases.
|
||||
|
||||
arm64: set `MyArchitecture=arm64` and `ArchitecturesAllowed=arm64`.
|
||||
|
||||
## Installer behavior
|
||||
|
||||
- **Per-user install** (`PrivilegesRequired=lowest`, default dir under `{localappdata}`).
|
||||
- **Shortcuts** target `{app}\Libation.exe` (required for upgrade path detection).
|
||||
- **Does not** create `appsettings.json` or Libation Files; same as zip install.
|
||||
- **Includes** full publish output so `ZipExtractor.exe` remains for in-app updates.
|
||||
- **Settings -> Apps:** shows `Libation (Chardonnay)` or `Libation (Classic)` with version in `DisplayVersion` (Inno at install; synced after in-app zip upgrades via `WindowsUninstallRegistrySync`); icon from `SetupIconFile` / `UninstallDisplayIcon` (`Scripts/Windows/libation.ico` and `{app}\Libation.exe`).
|
||||
|
||||
## Next plan step (8.2)
|
||||
|
||||
Install via `*-setup.exe`, run the app, then verify an older release can upgrade in-app using the existing zip-based upgrader.
|
||||
|
||||
CI wiring (`build-windows.yml`) is step 8.3.
|
||||
|
||||
## Files in this folder
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Libation.iss` | Parameterized Inno Setup script |
|
||||
| `libation.ico` | Setup/uninstaller icon (copied from Avalonia assets) |
|
||||
| `Build-WindowsInstaller.ps1` | Local publish + ISCC helper |
|
||||
| `README.md` | This document |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -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.4.0.1</Version>
|
||||
<Version>14.2.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
@@ -16,6 +16,9 @@
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
|
||||
@@ -41,6 +41,7 @@ public static class LibationScaffolding
|
||||
{
|
||||
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
|
||||
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
|
||||
public const string DonateUrl = WebsiteUrl + "/donate";
|
||||
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
|
||||
/// <summary>Documentation for naming template syntax</summary>
|
||||
public const string NamingTemplatesDocUrl = WebsiteUrl + "/docs/features/naming-templates";
|
||||
@@ -69,6 +70,8 @@ public static class LibationScaffolding
|
||||
// // outdated. kept here as an example of what belongs in this area
|
||||
// // Migrations.migrate_to_v5_2_0__pre_config();
|
||||
|
||||
InstallFolderUnblock.TryUnblockProcessDirectoryIfWindows();
|
||||
|
||||
Configuration.SetLibationVersion(BuildVersion);
|
||||
|
||||
//***********************************************//
|
||||
@@ -91,16 +94,15 @@ public static class LibationScaffolding
|
||||
{
|
||||
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
|
||||
}
|
||||
config.ValidateEnumSettings();
|
||||
DeleteOpenSqliteFiles(config);
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
IdentityTokenStorageWiring.Apply(config);
|
||||
|
||||
//
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -111,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;
|
||||
@@ -159,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;
|
||||
@@ -225,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,
|
||||
@@ -300,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; }
|
||||
}
|
||||
|
||||
@@ -316,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(),
|
||||
@@ -325,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,
|
||||
@@ -347,11 +377,11 @@ public static class LibationScaffolding
|
||||
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
{
|
||||
LibraryCommands.LibrarySizeChanged += (object? _, List<DataLayer.LibraryBook> libraryBooks)
|
||||
=> SearchEngineCommands.FullReIndex(libraryBooks);
|
||||
LibraryCommands.LibrarySizeChanged += (_, libraryBooks)
|
||||
=> SearchEngineCommands.OnLibrarySizeChanged(libraryBooks);
|
||||
|
||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
|
||||
=> SearchEngineCommands.UpdateBooks(books);
|
||||
=> SearchEngineCommands.OnBookUserDefinedItemCommitted(books);
|
||||
}
|
||||
|
||||
public static VersionCheckResult GetLatestRelease()
|
||||
@@ -421,85 +451,44 @@ public static class LibationScaffolding
|
||||
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||
|
||||
string? regexPattern;
|
||||
string? releaseIdString = null;
|
||||
|
||||
try
|
||||
{
|
||||
regexPattern = releaseIndex.Value<string>(InteropFactory.Create().ReleaseIdString);
|
||||
releaseIdString = InteropFactory.Create().ReleaseIdString;
|
||||
regexPattern = releaseIndex.Value<string>(releaseIdString);
|
||||
}
|
||||
catch
|
||||
{
|
||||
regexPattern = null;
|
||||
}
|
||||
if (string.IsNullOrEmpty(regexPattern) && Configuration.IsLinux)
|
||||
{
|
||||
var baseId = ReleaseIdentifier.ToString();
|
||||
regexPattern = releaseIndex.Value<string>($"{baseId}_RPM")
|
||||
?? releaseIndex.Value<string>($"{baseId}_DEB");
|
||||
releaseIdString ??= $"{baseId}_RPM";
|
||||
}
|
||||
if (string.IsNullOrEmpty(regexPattern))
|
||||
regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
||||
if (string.IsNullOrEmpty(regexPattern))
|
||||
{
|
||||
Log.Logger.Warning("Release index has no entry for this platform (ReleaseIdentifier: {ReleaseId}). Version check inconclusive.", ReleaseIdentifier);
|
||||
Log.Logger.Warning("Release index has no entry for this platform (ReleaseIdentifier: {ReleaseId}, ReleaseIdString: {ReleaseIdString}). Version check inconclusive.", ReleaseIdentifier, releaseIdString);
|
||||
return (null, null, null, false);
|
||||
}
|
||||
|
||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
var zip = latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name));
|
||||
|
||||
if (zip is not null && !string.IsNullOrEmpty(releaseIdString))
|
||||
Log.Logger.Information("Update asset matched using {ReleaseIdString}: {AssetName}", releaseIdString, zip.Name);
|
||||
|
||||
return (releaseVersion, latestRelease, zip, true);
|
||||
}
|
||||
}
|
||||
|
||||
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; } = new();
|
||||
}
|
||||
|
||||
public static void migrate_to_v12_0_1(Configuration config)
|
||||
{
|
||||
//Migrate from version 1 file cache to the dictionary-based version 2 cache
|
||||
@@ -518,7 +507,7 @@ internal static class Migrations
|
||||
if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0)
|
||||
return;
|
||||
|
||||
Dictionary<string, JArray> cache = new();
|
||||
Dictionary<string, JArray> cache = [];
|
||||
|
||||
//Convert to c# objects to speed up searching by ID inside the iterator
|
||||
var allItems
|
||||
@@ -572,57 +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();
|
||||
QuickFilters.InMemoryState.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>
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
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.Threading.Tasks;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
public static class AudiobookshelfApiService
|
||||
{
|
||||
public record Library(string Id, string Name, string MediaType, List<Folder> Folders);
|
||||
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(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(normalizedUrl);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
|
||||
|
||||
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 CreateLibrariesFetchException(normalizedUrl, response.StatusCode, errorBody);
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var obj = JObject.Parse(json);
|
||||
var libraries = obj["libraries"] as JArray ?? new JArray();
|
||||
|
||||
var allLibraries = libraries.Select(l => new Library(
|
||||
l["id"]?.Value<string>() ?? "",
|
||||
l["name"]?.Value<string>() ?? "",
|
||||
l["mediaType"]?.Value<string>() ?? "book",
|
||||
(l["folders"] as JArray ?? new JArray())
|
||||
.Select(f => new Folder(
|
||||
f["id"]?.Value<string>() ?? "",
|
||||
f["fullPath"]?.Value<string>() ?? ""))
|
||||
.ToList()))
|
||||
.ToList();
|
||||
|
||||
// Only return libraries with book media type
|
||||
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) ?? "";
|
||||
using var client = CreateClient(serverUrl);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
var normalizedTitle = NormalizeTitle(title);
|
||||
var baseTitle = GetBaseTitle(normalizedTitle);
|
||||
|
||||
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: looking for '{Title}' (base: '{BaseTitle}') in library {LibraryId}", normalizedTitle, baseTitle, libraryId);
|
||||
|
||||
try
|
||||
{
|
||||
// Search with base title first (shorter = broader fuzzy matches, better recall)
|
||||
var candidates = await SearchLibraryAsync(client, libraryId, baseTitle, 20);
|
||||
|
||||
// Also search with full title to catch cases where ABS stores the longer/subtitled version
|
||||
if (!string.Equals(normalizedTitle, baseTitle, StringComparison.Ordinal))
|
||||
{
|
||||
var fullCandidates = await SearchLibraryAsync(client, libraryId, normalizedTitle, 10);
|
||||
foreach (var c in fullCandidates)
|
||||
{
|
||||
if (!candidates.Any(existing => string.Equals(existing.Id, c.Id, StringComparison.Ordinal)))
|
||||
candidates.Add(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: no candidates found for '{Title}' in library {LibraryId}", normalizedTitle, libraryId);
|
||||
return false;
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: found {Count} candidate(s) for '{Title}' in library {LibraryId}", candidates.Count, normalizedTitle, libraryId);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate.Title))
|
||||
continue;
|
||||
|
||||
bool titleMatch = false;
|
||||
|
||||
// 1) Exact match against normalized title
|
||||
if (string.Equals(candidate.Title, normalizedTitle, StringComparison.OrdinalIgnoreCase))
|
||||
titleMatch = true;
|
||||
|
||||
// 2) Exact match against base title (without subtitle)
|
||||
if (!titleMatch && string.Equals(candidate.Title, baseTitle, StringComparison.OrdinalIgnoreCase))
|
||||
titleMatch = true;
|
||||
|
||||
// 3) Exact match against combined title:subtitle
|
||||
if (!titleMatch && !string.IsNullOrWhiteSpace(candidate.FullTitle)
|
||||
&& string.Equals(candidate.FullTitle, normalizedTitle, StringComparison.OrdinalIgnoreCase))
|
||||
titleMatch = true;
|
||||
|
||||
if (titleMatch && AuthorsCompatible(author, candidate.Authors))
|
||||
{
|
||||
Serilog.Log.Logger.Information("Audiobookshelf duplicate check: found match for '{Title}' (id={ItemId})", title, candidate.Id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: '{Title}' not found in library {LibraryId}", normalizedTitle, libraryId);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Debug(ex, "Audiobookshelf duplicate check failed for '{Title}' in library {LibraryId}", title, libraryId);
|
||||
throw; // Let caller decide whether to proceed with upload
|
||||
}
|
||||
}
|
||||
|
||||
private record SearchCandidate(string Id, string Title, string FullTitle, List<string> Authors);
|
||||
|
||||
private static async Task<List<SearchCandidate>> SearchLibraryAsync(HttpClient client, string libraryId, string query, int limit)
|
||||
{
|
||||
var url = $"api/libraries/{Uri.EscapeDataString(libraryId)}/search?q={Uri.EscapeDataString(query)}&limit={limit}";
|
||||
var response = await client.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException($"Audiobookshelf search returned {(int)response.StatusCode} for query '{query}'. Body: {errorBody}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var obj = JObject.Parse(json);
|
||||
var books = obj["book"] as JArray ?? new JArray();
|
||||
|
||||
var results = new List<SearchCandidate>();
|
||||
foreach (var entry in books)
|
||||
{
|
||||
var item = entry["libraryItem"] ?? entry;
|
||||
var id = item["id"]?.Value<string>() ?? entry["id"]?.Value<string>() ?? "";
|
||||
var itemTitle = item["media"]?["metadata"]?["title"]?.Value<string>()?.Replace("\u00A0", " ").Trim();
|
||||
var itemSubtitle = item["media"]?["metadata"]?["subtitle"]?.Value<string>()?.Replace("\u00A0", " ").Trim();
|
||||
|
||||
var itemFullTitle = string.IsNullOrWhiteSpace(itemSubtitle)
|
||||
? itemTitle
|
||||
: $"{itemTitle}: {itemSubtitle}";
|
||||
|
||||
var authors = item["media"]?["metadata"]?["authors"] as JArray;
|
||||
var authorList = authors?.Select(a => a["name"]?.Value<string>()?.Trim() ?? "")
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||||
.ToList() ?? [];
|
||||
|
||||
results.Add(new SearchCandidate(
|
||||
id,
|
||||
NormalizeTitle(itemTitle),
|
||||
NormalizeTitle(itemFullTitle),
|
||||
authorList));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static bool AuthorsCompatible(string? libationAuthor, List<string> absAuthors)
|
||||
{
|
||||
// If either side has no author info, we can't use authors to disambiguate
|
||||
if (string.IsNullOrWhiteSpace(libationAuthor))
|
||||
return true;
|
||||
if (absAuthors == null || absAuthors.Count == 0)
|
||||
return true;
|
||||
|
||||
var libationAuthors = libationAuthor.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(a => NormalizeAuthor(a))
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||
.ToList();
|
||||
|
||||
if (libationAuthors.Count == 0)
|
||||
return true;
|
||||
|
||||
foreach (var absAuthor in absAuthors.Select(NormalizeAuthor))
|
||||
{
|
||||
foreach (var la in libationAuthors)
|
||||
{
|
||||
if (string.Equals(absAuthor, la, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeAuthor(string? author)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(author))
|
||||
return author ?? "";
|
||||
return author.Replace("\u00A0", " ").Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeTitle(string? title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return title ?? "";
|
||||
|
||||
var normalized = title
|
||||
.Replace("\u00A0", " ")
|
||||
.Replace("(Unabridged)", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("(Abridged)", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Trim();
|
||||
|
||||
// Collapse multiple spaces to single space
|
||||
while (normalized.Contains(" ", StringComparison.Ordinal))
|
||||
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string GetBaseTitle(string fullTitle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullTitle))
|
||||
return fullTitle;
|
||||
|
||||
var colonIndex = fullTitle.IndexOf(": ", StringComparison.Ordinal);
|
||||
return colonIndex > 0 ? fullTitle[..colonIndex].Trim() : fullTitle;
|
||||
}
|
||||
|
||||
public static async Task<UploadResult> UploadBookAsync(
|
||||
string serverUrl,
|
||||
string apiToken,
|
||||
string libraryId,
|
||||
string folderId,
|
||||
string title,
|
||||
string? author,
|
||||
string? series,
|
||||
IEnumerable<string> filePaths)
|
||||
{
|
||||
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
|
||||
|
||||
// Pre-check for existing item
|
||||
try
|
||||
{
|
||||
if (await BookExistsAsync(serverUrl, apiToken, libraryId, title, author))
|
||||
{
|
||||
Serilog.Log.Logger.Information("Skipping Audiobookshelf upload: book '{Title}' already exists in library {LibraryId}", title, libraryId);
|
||||
return UploadResult.AlreadyExists;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Warning(ex, "Pre-check for existing book on Audiobookshelf failed; will still attempt upload");
|
||||
// Continue to upload attempt below
|
||||
}
|
||||
|
||||
using var client = CreateClient(serverUrl);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
|
||||
client.Timeout = TimeSpan.FromMinutes(30);
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
form.Add(new StringContent(title), "title");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(author))
|
||||
form.Add(new StringContent(author), "author");
|
||||
if (!string.IsNullOrWhiteSpace(series))
|
||||
form.Add(new StringContent(series), "series");
|
||||
|
||||
form.Add(new StringContent(libraryId), "library");
|
||||
form.Add(new StringContent(folderId), "folder");
|
||||
|
||||
int fileIndex = 0;
|
||||
var streams = new List<Stream>();
|
||||
try
|
||||
{
|
||||
foreach (var path in filePaths.Where(File.Exists))
|
||||
{
|
||||
var stream = File.OpenRead(path);
|
||||
streams.Add(stream);
|
||||
var fileContent = new StreamContent(stream);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
form.Add(fileContent, fileIndex.ToString(), Path.GetFileName(path));
|
||||
fileIndex++;
|
||||
}
|
||||
|
||||
if (fileIndex == 0)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("No audio files found to upload to Audiobookshelf for '{Title}'", title);
|
||||
return UploadResult.Failed;
|
||||
}
|
||||
|
||||
var response = await client.PostAsync("api/upload", form);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return UploadResult.Success;
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
Serilog.Log.Logger.Error("Audiobookshelf upload failed for '{Title}' with status {(int)response.StatusCode} ({StatusCode}). Response body: {ResponseBody}",
|
||||
title, (int)response.StatusCode, response.StatusCode, responseBody);
|
||||
|
||||
// Treat "already exists" 500 errors as skip/success
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.InternalServerError
|
||||
&& responseBody.Contains("already exists", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return UploadResult.AlreadyExists;
|
||||
}
|
||||
|
||||
return UploadResult.Failed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var stream in streams)
|
||||
{
|
||||
try { stream.Dispose(); } catch { /* ignored */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using AudibleApi.Authorization;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
public static class AudiobookshelfTokenStorage
|
||||
{
|
||||
private const string EncryptedPrefix = "enc:";
|
||||
private const string AssociatedData = "audiobookshelf-api-token";
|
||||
|
||||
public static string? EncryptToken(string? plaintextToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plaintextToken))
|
||||
return plaintextToken;
|
||||
|
||||
// Guard against double-encrypting an already-encrypted value
|
||||
if (plaintextToken.StartsWith(EncryptedPrefix))
|
||||
return plaintextToken;
|
||||
|
||||
if (Configuration.Instance.TokenStorageMethod != TokenStorageMethod.Encrypted)
|
||||
return plaintextToken;
|
||||
|
||||
var protector = IdentityTokenStorage.Protector;
|
||||
if (protector is null)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("Audiobookshelf API token encryption requested but no protector is available; storing plaintext");
|
||||
return plaintextToken;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var encrypted = protector.Protect(plaintextToken, AssociatedData);
|
||||
return EncryptedPrefix + encrypted;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to encrypt Audiobookshelf API token; storing plaintext");
|
||||
return plaintextToken;
|
||||
}
|
||||
}
|
||||
|
||||
public static string? DecryptToken(string? storedToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(storedToken))
|
||||
return storedToken;
|
||||
|
||||
if (!storedToken.StartsWith(EncryptedPrefix))
|
||||
return storedToken;
|
||||
|
||||
var payload = storedToken[EncryptedPrefix.Length..];
|
||||
var protector = IdentityTokenStorage.Protector;
|
||||
if (protector is null)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("Audiobookshelf API token decryption requested but no protector is available; returning stored value");
|
||||
return storedToken;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return protector.Unprotect(payload, AssociatedData);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to decrypt Audiobookshelf API token; returning stored value");
|
||||
return storedToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,9 +132,26 @@ public static class DbContexts
|
||||
return context.GetDeletedLibraryBooks();
|
||||
}
|
||||
|
||||
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
|
||||
/// <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.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
|
||||
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();
|
||||
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative, account);
|
||||
}
|
||||
}
|
||||
@@ -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,7 +11,9 @@ 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;
|
||||
using static DtoImporterService.PerfLogger;
|
||||
|
||||
@@ -23,7 +25,12 @@ public static class LibraryCommands
|
||||
public static event EventHandler<int>? ScanEnd;
|
||||
|
||||
public static bool Scanning { get; private set; }
|
||||
private static object _lock { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Serializes library scan and import operations so only one path reads/writes
|
||||
/// <see cref="Book"/> / <see cref="LibraryBook"/> rows at a time (prevents duplicate ASIN inserts).
|
||||
/// </summary>
|
||||
private static readonly SemaphoreSlim ImportGate = new(1, 1);
|
||||
|
||||
static LibraryCommands()
|
||||
{
|
||||
@@ -35,151 +42,175 @@ public static class LibraryCommands
|
||||
{
|
||||
logRestart();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (Scanning)
|
||||
return new();
|
||||
}
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
//These are the minimum response groups required for the
|
||||
//library scanner to pass all validation and filtering.
|
||||
var libraryOptions = new LibraryOptions
|
||||
{
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.ProductAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships
|
||||
};
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return new List<LibraryBook>();
|
||||
|
||||
await ImportGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
var totalCount = libraryItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||
|
||||
return missingBookList;
|
||||
}
|
||||
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 scanning library. Login failed. {@DebugInfo}", new
|
||||
//These are the minimum response groups required for the
|
||||
//library scanner to pass all validation and filtering.
|
||||
var libraryOptions = new LibraryOptions
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error scanning library");
|
||||
throw;
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.ProductAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
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}");
|
||||
|
||||
var existing = existingLibrary?.ToList() ?? [];
|
||||
|
||||
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 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;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, 0);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, 0);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
ImportGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 async Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
|
||||
public static Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
|
||||
=> ImportAccountAsync(accounts, allowInteractiveLogin: true);
|
||||
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Account[]? accounts, bool allowInteractiveLogin)
|
||||
{
|
||||
logRestart();
|
||||
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return (0, 0);
|
||||
|
||||
await ImportGate.WaitAsync();
|
||||
int newCount = 0;
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (Scanning)
|
||||
return (0, 0);
|
||||
}
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryOptions = new LibraryOptions
|
||||
try
|
||||
{
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
|
||||
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
|
||||
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
|
||||
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||
};
|
||||
var importItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryOptions = new LibraryOptions
|
||||
{
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
|
||||
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
|
||||
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
|
||||
| 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
|
||||
};
|
||||
//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;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
var totalCount = importItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
if (totalCount == 0)
|
||||
return default;
|
||||
if (totalCount == 0)
|
||||
return default;
|
||||
|
||||
Log.Logger.Information("Begin long-running import");
|
||||
logTime($"pre {nameof(ImportIntoDbAsync)}");
|
||||
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
|
||||
logTime($"post {nameof(ImportIntoDbAsync)}");
|
||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||
Log.Logger.Information("Begin long-running import");
|
||||
logTime($"pre {nameof(ImportIntoDbAsync)}");
|
||||
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
|
||||
logTime($"post {nameof(ImportIntoDbAsync)}");
|
||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||
|
||||
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
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (Exception lfEx) when (AuthenticationExceptionHelper.IsAuthenticationFailure(lfEx))
|
||||
{
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Authentication failed.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, newCount);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, newCount);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
ImportGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) => Task.Run(() => importSingleToDb(item, accountId, localeName));
|
||||
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
|
||||
{
|
||||
await ImportGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
return importSingleToDb(item, accountId, localeName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ImportGate.Release();
|
||||
}
|
||||
}
|
||||
private static int importSingleToDb(AudibleApi.Common.Item item, string accountId, string localeName)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(item, nameof(item));
|
||||
@@ -250,9 +281,18 @@ public static class LibraryCommands
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions)
|
||||
/// <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()
|
||||
@@ -265,42 +305,64 @@ 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);
|
||||
// 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))
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//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);
|
||||
|
||||
@@ -317,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") },
|
||||
};
|
||||
|
||||
@@ -375,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>())
|
||||
@@ -390,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));
|
||||
@@ -399,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)
|
||||
@@ -408,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)
|
||||
{
|
||||
@@ -423,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)
|
||||
{
|
||||
@@ -550,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;
|
||||
@@ -567,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;
|
||||
}
|
||||
@@ -629,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();
|
||||
|
||||
@@ -645,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()
|
||||
@@ -655,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);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using AudibleApi.Authorization;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Alignment and conversion helpers for identity tokens in <c>AccountsSettings.json</c>.
|
||||
/// Preference changes alone do not convert; conversion is explicit via <see cref="ConvertAllAccounts"/>.
|
||||
/// </summary>
|
||||
public static class AccountTokenStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Compare persisted encryption metadata for all account identities to <paramref name="method"/>.
|
||||
/// </summary>
|
||||
public static TokenStorageAlignment GetAccountsAlignment(TokenStorageMethod method, string? accountsSettingsFile = null)
|
||||
{
|
||||
var path = accountsSettingsFile ?? AudibleApiStorage.AccountsSettingsFile;
|
||||
if (!File.Exists(path))
|
||||
return TokenStorageAlignment.NoApplicableTokens;
|
||||
|
||||
using var persister = new AccountsSettingsPersister(path);
|
||||
var alignments = new List<TokenStorageAlignment>();
|
||||
|
||||
foreach (var account in persister.AccountsSettings.Accounts)
|
||||
{
|
||||
if (account.IdentityTokens is null)
|
||||
continue;
|
||||
|
||||
alignments.Add(IdentityTokenConversion.GetAlignment(account.IdentityTokens, method));
|
||||
}
|
||||
|
||||
return CombineAlignments(alignments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert every identity in the accounts settings file to <paramref name="method"/>.
|
||||
/// All-or-nothing: failure leaves the original file unchanged.
|
||||
/// </summary>
|
||||
public static IdentityTokenConversionResult ConvertAllAccounts(TokenStorageMethod method, string? accountsSettingsFile = null)
|
||||
{
|
||||
var path = accountsSettingsFile ?? AudibleApiStorage.AccountsSettingsFile;
|
||||
return IdentityTokenConversion.ConvertAllIdentitiesInFile(path, method);
|
||||
}
|
||||
|
||||
private static TokenStorageAlignment CombineAlignments(IReadOnlyList<TokenStorageAlignment> alignments)
|
||||
{
|
||||
if (alignments.Count == 0)
|
||||
return TokenStorageAlignment.NoApplicableTokens;
|
||||
if (alignments.Any(a => a == TokenStorageAlignment.Indeterminate))
|
||||
return TokenStorageAlignment.Indeterminate;
|
||||
if (alignments.All(a => a is TokenStorageAlignment.AllMatch or TokenStorageAlignment.NoApplicableTokens))
|
||||
{
|
||||
return alignments.Any(a => a == TokenStorageAlignment.AllMatch)
|
||||
? TokenStorageAlignment.AllMatch
|
||||
: TokenStorageAlignment.NoApplicableTokens;
|
||||
}
|
||||
|
||||
return TokenStorageAlignment.SomeMismatch;
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,8 @@ public class AccountsSettings : IUpdatable
|
||||
var l = Localization.Get(locale);
|
||||
var id = new Identity(l);
|
||||
|
||||
var account = new Account(accountId) { IdentityTokens = id };
|
||||
// Match GUI default for new rows (WinForms/Avalonia): include account in library scans.
|
||||
var account = new Account(accountId) { IdentityTokens = id, LibraryScan = true };
|
||||
Add(account);
|
||||
return account;
|
||||
}
|
||||
@@ -96,6 +97,7 @@ public class AccountsSettings : IUpdatable
|
||||
public void Add(Account account)
|
||||
{
|
||||
_add(account);
|
||||
Serilog.Log.Logger.Information("Added Audible account {Account}", account.MaskedLogEntry);
|
||||
update_no_validate();
|
||||
}
|
||||
|
||||
@@ -107,12 +109,46 @@ public class AccountsSettings : IUpdatable
|
||||
account.Updated += update;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account that can speak to <paramref name="locale"/> for this login: the one registered with that
|
||||
/// marketplace, or failing that the one carrying it as an extra marketplace. Callers hand this a book's
|
||||
/// marketplace and get back the credentials that can license it, which is why extras have to resolve here
|
||||
/// as well as the registered marketplace does.
|
||||
/// </summary>
|
||||
public Account? GetAccount(string accountId, string? locale)
|
||||
{
|
||||
if (locale is null)
|
||||
return null;
|
||||
|
||||
return Accounts.SingleOrDefault(a => a.AccountId == accountId && a.Locale?.Name == locale);
|
||||
// AccountId is compared case-insensitively: Audible/library data has been observed to differ
|
||||
// only by letter case (e.g. a stored id capitalized differently than settings), which caused
|
||||
// spurious "No account found" failures that blocked every affected book. See issue #1931.
|
||||
var registered = Accounts.SingleOrDefault(a =>
|
||||
a.AccountId.EqualsInsensitive(accountId)
|
||||
&& a.Locale?.Name == locale);
|
||||
|
||||
if (registered is not null)
|
||||
return registered;
|
||||
|
||||
return Accounts.FirstOrDefault(a =>
|
||||
a.AccountId.EqualsInsensitive(accountId)
|
||||
&& a.HasMarketplace(locale));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account already scanning <paramref name="localeName"/> for this login, if any. Adding a marketplace,
|
||||
/// importing an audible-cli file, and probing all need to know whether a marketplace is spoken for - including
|
||||
/// by a second row for the same login, which is how multiple marketplaces were handled before one account
|
||||
/// could hold several.
|
||||
/// </summary>
|
||||
public Account? GetAccountClaimingMarketplace(string accountId, string? localeName, Account? excluding = null)
|
||||
{
|
||||
var name = Localization.Get(localeName).Name;
|
||||
|
||||
return Accounts.FirstOrDefault(a =>
|
||||
!ReferenceEquals(a, excluding)
|
||||
&& a.AccountId.EqualsInsensitive(accountId)
|
||||
&& a.HasMarketplace(name));
|
||||
}
|
||||
|
||||
public bool Delete(string accountId, string locale)
|
||||
@@ -130,28 +166,23 @@ public class AccountsSettings : IUpdatable
|
||||
|
||||
account.Updated -= update;
|
||||
var result = _accounts_backing.Remove(account);
|
||||
if (result)
|
||||
Serilog.Log.Logger.Information("Removed Audible account {Account}", account.MaskedLogEntry);
|
||||
update_no_validate();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No two rows for one login may scan the same marketplace, or a scan would import it twice and a download
|
||||
/// would have two accounts to choose from. Extra marketplaces count: claiming 'us' as an extra collides with
|
||||
/// another row registered with 'us' just as surely as two 'us' registrations would.
|
||||
/// </summary>
|
||||
private void validate(Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
var accountId = account.AccountId;
|
||||
var locale = account?.IdentityTokens?.Locale?.Name;
|
||||
|
||||
var acct = GetAccount(accountId, locale);
|
||||
|
||||
// new: ok
|
||||
if (acct is null)
|
||||
return;
|
||||
|
||||
// same account instance: ok
|
||||
if (acct == account)
|
||||
return;
|
||||
|
||||
// same account id + locale, different instance: bad
|
||||
throw new InvalidOperationException("Cannot add an account with the same account Id and Locale");
|
||||
foreach (var locale in account.ScanLocales)
|
||||
if (GetAccountClaimingMarketplace(account.AccountId, locale.Name, excluding: account) is not null)
|
||||
throw new InvalidOperationException("Cannot add an account with the same account Id and Locale");
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,9 @@ using LibationFileManager;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
@@ -23,11 +19,24 @@ 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;
|
||||
|
||||
private static readonly SemaphoreSlim InteractiveLoginGate = new(1, 1);
|
||||
|
||||
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||
public static Task<ApiExtended> CreateAsync(Account account)
|
||||
=> CreateAsync(account, allowInteractiveLogin: true);
|
||||
|
||||
/// <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));
|
||||
@@ -37,34 +46,82 @@ 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
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (LoginChoiceFactory is null)
|
||||
throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
|
||||
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
if (!allowInteractiveLogin || LoginChoiceFactory is null)
|
||||
{
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name
|
||||
});
|
||||
// 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);
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
LoginChoiceFactory(account),
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
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
|
||||
});
|
||||
|
||||
return new ApiExtended(api);
|
||||
// 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
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
LoginChoiceFactory(account),
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath(),
|
||||
Configuration.Instance.GetDeviceRegistrationProfile());
|
||||
|
||||
// login happens against the account's own marketplace, so the api it hands back reads that one.
|
||||
// re-create once the tokens exist if some other marketplace is the one actually wanted.
|
||||
if (storeLocale is not null && storeLocale.Name != locale.Name)
|
||||
api = await EzApiCreator.GetApiAsync(
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath(),
|
||||
storeLocale);
|
||||
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
finally
|
||||
{
|
||||
InteractiveLoginGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +160,14 @@ public class ApiExtended
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Beginning library scan.");
|
||||
|
||||
List<Item> items = new();
|
||||
//Read the import filters once so a settings change mid-scan can't produce a half-filtered library.
|
||||
var importEpisodes = Configuration.Instance.ImportEpisodes;
|
||||
var importPlusTitles = Configuration.Instance.ImportPlusTitles;
|
||||
|
||||
var items = new List<Item>();
|
||||
var libraryItemCount = 0;
|
||||
var episodeItemsExcluded = 0;
|
||||
var plusTitlesExcluded = 0;
|
||||
var sw = Stopwatch.StartNew();
|
||||
var totalTime = TimeSpan.Zero;
|
||||
using var semaphore = new SemaphoreSlim(MaxConcurrency);
|
||||
@@ -115,7 +179,9 @@ public class ApiExtended
|
||||
//Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried.
|
||||
await foreach (var itemsBatch in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore))
|
||||
{
|
||||
if (Configuration.Instance.ImportEpisodes)
|
||||
libraryItemCount += itemsBatch.Length;
|
||||
|
||||
if (importEpisodes)
|
||||
{
|
||||
var episodes = itemsBatch.Where(i => i.IsEpisodes).ToList();
|
||||
var series = itemsBatch.Where(i => i.IsSeriesParent).ToList();
|
||||
@@ -138,11 +204,18 @@ public class ApiExtended
|
||||
items.AddRange(episodes);
|
||||
items.AddRange(series);
|
||||
}
|
||||
else
|
||||
episodeItemsExcluded += itemsBatch.Count(i => i.IsSeriesParent || i.IsEpisodes);
|
||||
|
||||
var booksInBatch = itemsBatch.Where(i => !i.IsSeriesParent && !i.IsEpisodes).ToList();
|
||||
|
||||
if (!importPlusTitles)
|
||||
{
|
||||
var ownedInBatch = booksInBatch.Where(i => i.IsAyce is not true).ToList();
|
||||
plusTitlesExcluded += booksInBatch.Count - ownedInBatch.Count;
|
||||
booksInBatch = ownedInBatch;
|
||||
}
|
||||
|
||||
var booksInBatch
|
||||
= itemsBatch
|
||||
.Where(i => !i.IsSeriesParent && !i.IsEpisodes)
|
||||
.Where(i => i.IsAyce is not true || Configuration.Instance.ImportPlusTitles);
|
||||
items.AddRange(booksInBatch);
|
||||
}
|
||||
|
||||
@@ -165,21 +238,30 @@ public class ApiExtended
|
||||
Serilog.Log.Logger.Debug("Begin indexing series episodes");
|
||||
items.AddRange(allEps);
|
||||
|
||||
//Set the Item.Series info for episodes and parents.
|
||||
foreach (var parent in items.Where(i => i.IsSeriesParent))
|
||||
{
|
||||
var children = items.Where(i => i.IsEpisodes && i.Relationships?.Any(r => r.Asin == parent.Asin) is true);
|
||||
SetSeries(parent, children);
|
||||
}
|
||||
|
||||
int orphansRemoved = items.RemoveAll(i => (i.IsEpisodes || i.IsSeriesParent) && i.Series is null);
|
||||
if (orphansRemoved > 0)
|
||||
Serilog.Log.Debug("{orphansRemoved} podcast orphans not imported", orphansRemoved);
|
||||
//Name the casualties: an unlinked episode silently vanishes from the library, and without this
|
||||
//there is nothing in the log to explain why.
|
||||
var orphans = LinkEpisodesToSeries(items).Select(describe).Distinct().ToList();
|
||||
if (orphans.Count > 0)
|
||||
Serilog.Log.Logger.Warning(
|
||||
"{orphansRemoved} podcast episodes were not imported because their series parent was missing from this scan. {@DebugInfo}",
|
||||
orphans.Count,
|
||||
new { Orphans = orphans.Take(MaxLoggedTitles).ToList(), Truncated = orphans.Count > MaxLoggedTitles });
|
||||
|
||||
sw.Stop();
|
||||
totalTime += sw.Elapsed;
|
||||
Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds);
|
||||
Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms.");
|
||||
Serilog.Log.Logger.Information("Library scan tally. {@DebugInfo}", new
|
||||
{
|
||||
LibraryItems = libraryItemCount,
|
||||
EpisodesFetched = allEps.Count,
|
||||
OrphanedEpisodesDropped = orphans.Count,
|
||||
ImportEpisodes = importEpisodes,
|
||||
EpisodeItemsExcluded = episodeItemsExcluded,
|
||||
ImportPlusTitles = importPlusTitles,
|
||||
PlusTitlesExcluded = plusTitlesExcluded,
|
||||
ItemsToImport = items.Count
|
||||
});
|
||||
|
||||
Array.ForEach(ISanitizer.GetAllSanitizers(), s => s.Sanitize(items));
|
||||
var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)).ToList();
|
||||
@@ -187,6 +269,32 @@ public class ApiExtended
|
||||
throw new ImportValidationException(items, allExceptions);
|
||||
|
||||
return items;
|
||||
|
||||
static string describe(Item item) => $"[{item.Asin}] {item.Title}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set <see cref="Item.Series"/> on every series parent in <paramref name="items"/> and on the episodes
|
||||
/// belonging to it, then drop the episodes and parents left unlinked. An episode is left unlinked when
|
||||
/// its parent is not among <paramref name="items"/>, which happens when Audible omits the parent from a
|
||||
/// catalog response or when the parent is a container Libation does not treat as a series (e.g. a season).
|
||||
/// </summary>
|
||||
/// <returns>The unlinked items removed from <paramref name="items"/>.</returns>
|
||||
public static List<Item> LinkEpisodesToSeries(List<Item> items)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(items, nameof(items));
|
||||
|
||||
foreach (var parent in items.Where(i => i.IsSeriesParent))
|
||||
{
|
||||
var children = items.Where(i => i.IsEpisodes && i.Relationships?.Any(r => r.Asin == parent.Asin) is true);
|
||||
SetSeries(parent, children);
|
||||
}
|
||||
|
||||
var unlinked = items.Where(isUnlinked).ToList();
|
||||
items.RemoveAll(isUnlinked);
|
||||
return unlinked;
|
||||
|
||||
static bool isUnlinked(Item item) => (item.IsEpisodes || item.IsSeriesParent) && item.Series is null;
|
||||
}
|
||||
|
||||
#region episodes and podcasts
|
||||
@@ -228,15 +336,27 @@ public class ApiExtended
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media
|
||||
| CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc
|
||||
| CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview
|
||||
| CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series
|
||||
| CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs);
|
||||
|
||||
//Audible sometimes omits products from a response that is otherwise successful. Those episodes
|
||||
//would silently disappear from the library, so re-request them before accepting the loss.
|
||||
var (items, missing) = await FetchRetryingMissingAsync(
|
||||
asins,
|
||||
getCatalogProductsAsync,
|
||||
MissingAsinRetries,
|
||||
attempt => Serilog.Log.Logger.Debug($"Batch {batchNum} Retry {attempt}: Re-fetching asins Audible did not return"));
|
||||
|
||||
sw.Stop();
|
||||
|
||||
Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms");
|
||||
|
||||
if (missing.Count > 0)
|
||||
Serilog.Log.Logger.Warning(
|
||||
"Audible did not return {missingCount} of the {requestedCount} catalog products requested in batch {batchNum}. Those titles are missing from this scan. {@DebugInfo}",
|
||||
missing.Count,
|
||||
asins.Count,
|
||||
batchNum,
|
||||
new { MissingAsins = missing });
|
||||
|
||||
return items;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -247,6 +367,52 @@ public class ApiExtended
|
||||
finally { semaphore.Release(); }
|
||||
}
|
||||
|
||||
private Task<List<Item>> getCatalogProductsAsync(List<string> asins)
|
||||
=> Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media
|
||||
| CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc
|
||||
| CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview
|
||||
| CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series
|
||||
| CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs);
|
||||
|
||||
/// <summary>Requested asins for which <paramref name="received"/> holds no matching <see cref="Item"/>.</summary>
|
||||
public static List<string> GetMissingAsins(IEnumerable<string> requested, IEnumerable<Item> received)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(requested, nameof(requested));
|
||||
ArgumentValidator.EnsureNotNull(received, nameof(received));
|
||||
|
||||
var found = received.Select(i => i.Asin).OfType<string>().ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return requested.Where(a => !found.Contains(a)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch <paramref name="asins"/>, re-requesting any that <paramref name="fetch"/> fails to return.
|
||||
/// Audible's catalog endpoint can answer 200 while quietly omitting products.
|
||||
/// </summary>
|
||||
/// <returns>Everything that was returned, plus the asins still unaccounted for after the last attempt.</returns>
|
||||
public static async Task<(List<Item> Items, List<string> Missing)> FetchRetryingMissingAsync(
|
||||
List<string> asins,
|
||||
Func<List<string>, Task<List<Item>>> fetch,
|
||||
int maxRetries,
|
||||
Action<int>? onRetry = null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(asins, nameof(asins));
|
||||
ArgumentValidator.EnsureNotNull(fetch, nameof(fetch));
|
||||
|
||||
var items = await fetch(asins);
|
||||
var missing = GetMissingAsins(asins, items);
|
||||
|
||||
for (var attempt = 1; attempt <= maxRetries && missing.Count > 0; attempt++)
|
||||
{
|
||||
onRetry?.Invoke(attempt);
|
||||
|
||||
var retriedItems = await fetch(missing);
|
||||
items.AddRange(retriedItems);
|
||||
missing = GetMissingAsins(missing, retriedItems);
|
||||
}
|
||||
|
||||
return (items, missing);
|
||||
}
|
||||
|
||||
public static void SetSeries(Item parent, IEnumerable<Item> children)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(parent, nameof(parent));
|
||||
@@ -275,21 +441,21 @@ public class ApiExtended
|
||||
}
|
||||
|
||||
int lastEpNum = -1, dupeCount = 0;
|
||||
foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime))
|
||||
foreach (var child in children.OrderBy(i => UsableEpisodeNumber(i)).ThenBy(i => i.PublicationDateTime))
|
||||
{
|
||||
string sequence;
|
||||
if (child.EpisodeNumber is null)
|
||||
var episodeNumber = UsableEpisodeNumber(child);
|
||||
if (episodeNumber is null)
|
||||
{
|
||||
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||
sequence = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0";
|
||||
sequence = FallbackSeriesSequence(parent, child);
|
||||
}
|
||||
else
|
||||
{
|
||||
//multipart episodes may have the same episode number
|
||||
if (child.EpisodeNumber == lastEpNum)
|
||||
if (episodeNumber == lastEpNum)
|
||||
dupeCount++;
|
||||
else
|
||||
lastEpNum = child.EpisodeNumber.Value;
|
||||
lastEpNum = episodeNumber.Value;
|
||||
|
||||
sequence = (lastEpNum + dupeCount).ToString();
|
||||
}
|
||||
@@ -308,5 +474,69 @@ public class ApiExtended
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nine digits. Big enough for YYYYMMDD-style numbering; too small for unix timestamps,
|
||||
/// Integer.MAX_VALUE, and the other sentinel integers Audible has sent as episode order (issue #2024).
|
||||
/// </summary>
|
||||
private const long MaxPlausibleSeriesOrder = 1_000_000_000;
|
||||
|
||||
private static bool IsPlausibleSeriesOrder(long n)
|
||||
=> n >= 0 && n < MaxPlausibleSeriesOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Audible sometimes serializes a missing episode_number as a sentinel integer (Integer.MAX_VALUE
|
||||
/// was the one in #2024) instead of omitting the field. Treat any implausibly large value the same way.
|
||||
/// </summary>
|
||||
private static int? UsableEpisodeNumber(Item child)
|
||||
=> child.EpisodeNumber is int n && IsPlausibleSeriesOrder(n) ? n : null;
|
||||
|
||||
/// <summary>
|
||||
/// When episode_number is missing or implausibly large, use relationship sort/sequence
|
||||
/// or the catalog series sequence. Prefer the parent's child relationship (the historical source),
|
||||
/// then the child's parent relationship, then any series sequence Audible already attached.
|
||||
/// </summary>
|
||||
private static string FallbackSeriesSequence(Item parent, Item child)
|
||||
{
|
||||
var fromParent = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin);
|
||||
if (UsableRelationshipOrder(fromParent) is string parentOrder)
|
||||
return parentOrder;
|
||||
|
||||
var fromChild = child.Relationships?.FirstOrDefault(r => r.Asin == parent.Asin);
|
||||
if (UsableRelationshipOrder(fromChild) is string childOrder)
|
||||
return childOrder;
|
||||
|
||||
var catalogSequence = child.Series?.FirstOrDefault(s => s.Asin == parent.Asin)?.Sequence;
|
||||
if (IsUsableOrderString(catalogSequence))
|
||||
return catalogSequence!;
|
||||
|
||||
return "0";
|
||||
}
|
||||
|
||||
private static string? UsableRelationshipOrder(Relationship? relationship)
|
||||
{
|
||||
if (relationship is null)
|
||||
return null;
|
||||
if (IsUsableSort(relationship.Sort))
|
||||
return relationship.Sort!.Value.ToString();
|
||||
if (IsUsableOrderString(relationship.Sequence))
|
||||
return relationship.Sequence;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsUsableSort(long? sort)
|
||||
=> sort is long n && IsPlausibleSeriesOrder(n);
|
||||
|
||||
/// <summary>
|
||||
/// Bare integers that are too large are sentinels or timestamps. Mixed forms like "1-6" or "2.1"
|
||||
/// are real series orders and are left alone.
|
||||
/// </summary>
|
||||
private static bool IsUsableOrderString(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value == "-1")
|
||||
return false;
|
||||
return !long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
|
||||
|| IsPlausibleSeriesOrder(n);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -62,24 +62,16 @@ public static class AudibleApiStorage
|
||||
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
|
||||
public static string GetIdentityTokensJsonPath(string username, string? localeName)
|
||||
{
|
||||
var usernameSanitized = trimSurroundingQuotes(JsonConvert.ToString(username));
|
||||
var localeNameSanitized = trimSurroundingQuotes(JsonConvert.ToString(localeName));
|
||||
var usernameEscaped = EscapeNewtonsoftJsonPathSingleQuotedLiteral(username);
|
||||
var localeNameEscaped = EscapeNewtonsoftJsonPathSingleQuotedLiteral(localeName ?? string.Empty);
|
||||
|
||||
return $"$.Accounts[?(@.AccountId == '{usernameSanitized}' && @.IdentityTokens.LocaleName == '{localeNameSanitized}')].IdentityTokens";
|
||||
return $"$.Accounts[?(@.AccountId == '{usernameEscaped}' && @.IdentityTokens.LocaleName == '{localeNameEscaped}')].IdentityTokens";
|
||||
}
|
||||
private static string trimSurroundingQuotes(string str)
|
||||
{
|
||||
// SubString algo is better than .Trim("\"")
|
||||
// orig string "
|
||||
// json string "\""
|
||||
// Eg:
|
||||
// => str.Trim("\"")
|
||||
// output \
|
||||
// vs
|
||||
// => str.Substring(1, str.Length - 2)
|
||||
// output \"
|
||||
// also works with surrounding single quotes
|
||||
|
||||
return str.Substring(1, str.Length - 2);
|
||||
}
|
||||
/// <summary>
|
||||
/// Escape a value for use inside single-quoted Newtonsoft JSONPath filter literals.
|
||||
/// See https://www.newtonsoft.com/json/help/html/QueryJsonSelectTokenEscaped.htm
|
||||
/// </summary>
|
||||
internal static string EscapeNewtonsoftJsonPathSingleQuotedLiteral(string value)
|
||||
=> value.Replace("\\", @"\\").Replace("'", @"\'");
|
||||
}
|
||||
@@ -3,11 +3,14 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="10.1.5.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.36.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<PublicAssets>runtime</PublicAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -27,4 +30,8 @@
|
||||
<DependentUpon>Cdm.cs</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public static class AuthenticationExceptionHelper
|
||||
{
|
||||
public static bool IsAuthenticationFailure(Exception ex)
|
||||
{
|
||||
if (ex is AggregateException aggregate)
|
||||
{
|
||||
return aggregate.InnerExceptions.Any(IsAuthenticationFailure)
|
||||
|| (aggregate.InnerException is not null && IsAuthenticationFailure(aggregate.InnerException));
|
||||
}
|
||||
|
||||
for (var current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is AuthenticationRequiredException)
|
||||
return true;
|
||||
|
||||
if (current is InvalidOperationException { Message: var message }
|
||||
&& message.Contains("ADP token is null", StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Stored Audible credentials are missing or invalid and interactive login is required.
|
||||
/// Thrown instead of opening login UI when the caller disallows interactive login (e.g. auto-scan).
|
||||
/// </summary>
|
||||
public sealed class AuthenticationRequiredException : Exception
|
||||
{
|
||||
/// <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)
|
||||
=> AccountInfo = AccountSummary.From(account);
|
||||
}
|
||||
@@ -20,5 +20,25 @@ public static class Extensions
|
||||
.Select(p => p.EndDate)
|
||||
.FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now)
|
||||
?.DateTime;
|
||||
|
||||
/// <summary>
|
||||
/// The book's summary with Audible's HTML markup flattened away, ready to be stored and used
|
||||
/// as-is. See <see cref="HtmlText.ToPlainText"/>.
|
||||
/// </summary>
|
||||
public string PlainTextDescription()
|
||||
=> HtmlText.ToPlainText(item.Description);
|
||||
|
||||
/// <summary>
|
||||
/// The publisher's copyright line, e.g. "©2024 Bentley Little (P)2025 Journalstone".
|
||||
/// Only present when the product_details response group was requested, and often null anyway.
|
||||
/// </summary>
|
||||
/// <remarks><see cref="Item.Copyright"/> is typed as <see cref="object"/> because Audible has
|
||||
/// never been observed returning anything but a string or null there. Anything else is
|
||||
/// discarded rather than stringified, so a shape we do not understand cannot reach a file tag.
|
||||
/// </remarks>
|
||||
public string? CopyrightString()
|
||||
=> item.Copyright is string copyright && !string.IsNullOrWhiteSpace(copyright)
|
||||
? copyright.Trim()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Audible returns a book's summary as HTML. Nothing downstream of the API renders markup - not the
|
||||
/// library grid, not the exports, and least of all the metadata tags written into an audio file - so
|
||||
/// the markup is flattened here, on the way in, rather than carried around and stripped at each use.
|
||||
/// </summary>
|
||||
public static partial class HtmlText
|
||||
{
|
||||
/// <param name="paragraphSeparator">Joins the block-level runs. A single newline matches what
|
||||
/// Audible itself embeds in the description tags of its .aaxc files.</param>
|
||||
public static string ToPlainText(string? html, string paragraphSeparator = "\n")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return "";
|
||||
|
||||
// Not every summary is marked up. Running the unmarked ones through the parser risks a stray
|
||||
// '<' swallowing the rest of the sentence, and buys nothing.
|
||||
if (!html.Contains('<'))
|
||||
return (WebUtility.HtmlDecode(html) ?? html).Trim();
|
||||
|
||||
// Replace block-level boundaries with newlines, then strip all remaining tags.
|
||||
var stripped = StripTags().Replace(BlockBoundary().Replace(html, "\n"), "");
|
||||
|
||||
var text = WebUtility.HtmlDecode(stripped) ?? stripped;
|
||||
|
||||
var paragraphs = text
|
||||
.Replace("\r\n", "\n")
|
||||
.Replace('\r', '\n')
|
||||
.Split('\n')
|
||||
.Select(line => line.Trim())
|
||||
.Where(line => line.Length > 0);
|
||||
|
||||
return string.Join(paragraphSeparator, paragraphs);
|
||||
}
|
||||
|
||||
/// <summary>Every tag that ends a run of text. A line break counts as a paragraph break because
|
||||
/// Audible's summaries mark up paragraphs and nothing finer.</summary>
|
||||
[GeneratedRegex(@"<\s*br\s*/?\s*>|<\s*/\s*(?:p|div|li|tr|blockquote|h[1-6])\s*>", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BlockBoundary();
|
||||
|
||||
/// <summary>Matches any HTML/XML tag.</summary>
|
||||
[GeneratedRegex(@"<[^>]+>")]
|
||||
private static partial Regex StripTags();
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core.IO;
|
||||
using Dinah.Core.Security;
|
||||
using LibationFileManager;
|
||||
using System.ComponentModel;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Applies Libation's <see cref="Configuration.TokenStorageMethod"/> to AudibleApi identity persistence.
|
||||
/// Changing the preference alone does not convert existing tokens.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Configure AudibleApi token persistence from <paramref name="config"/> and keep it in sync when the preference changes.
|
||||
/// </summary>
|
||||
public static void Apply(Configuration config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
ConfigureFrom(config);
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (ReferenceEquals(_wiredConfig, config))
|
||||
return;
|
||||
|
||||
if (_wiredConfig is not null)
|
||||
_wiredConfig.PropertyChanged -= OnPropertyChanged;
|
||||
|
||||
_wiredConfig = config;
|
||||
_wiredConfig.PropertyChanged += OnPropertyChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure AudibleApi from the current preference without attaching change listeners.
|
||||
/// Prefer <see cref="Apply"/> at application startup.
|
||||
/// </summary>
|
||||
public static void ConfigureFrom(Configuration config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var method = config.TokenStorageMethod;
|
||||
var store = ResolveSecretStore(config);
|
||||
AesGcmSecretProtector? protector = store.IsAvailable
|
||||
? new AesGcmSecretProtector(store)
|
||||
: null;
|
||||
|
||||
// Encrypted + unavailable store => protector null => fail-closed on encrypt/decrypt.
|
||||
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)
|
||||
{
|
||||
var store = OsSecretStore.Create(ApplicationName);
|
||||
unavailableReason = store.IsAvailable ? null : store.UnavailableReason;
|
||||
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))
|
||||
return;
|
||||
if (sender is not Configuration config)
|
||||
return;
|
||||
|
||||
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);
|
||||
@@ -217,7 +218,8 @@ public partial class Mkb79Auth
|
||||
{
|
||||
DecryptKey = ActivationBytes,
|
||||
AccountName = $"{email} - {Locale.Name}",
|
||||
IdentityTokens = new Identity(Locale)
|
||||
IdentityTokens = new Identity(Locale),
|
||||
LibraryScan = true,
|
||||
};
|
||||
|
||||
account.IdentityTokens.Update(
|
||||
@@ -225,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,
|
||||
@@ -235,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",
|
||||
@@ -255,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);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using AudibleApi;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Libation-facing helpers when Audible returns HTML instead of JSON (library scan, catalog, etc.).
|
||||
/// </summary>
|
||||
public static class NonJsonResponseExceptionExtensions
|
||||
{
|
||||
public const string LibraryScanFailedCaption = "Library scan failed";
|
||||
|
||||
private static readonly string[] ThingsToTryBullets =
|
||||
[
|
||||
"Scan again after a few minutes.",
|
||||
"Sign in to Audible in a browser on the same network.",
|
||||
"Disable VPN or proxy and scan again.",
|
||||
"Remove and re-add the account in Libation.",
|
||||
"If it still fails, check the log for Full body: near the error and attach it to a bug report.",
|
||||
];
|
||||
|
||||
public static IEnumerable<string> GetExplainerLines(this NonJsonResponseException ex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
yield return getIntro(ex);
|
||||
yield return string.Empty;
|
||||
yield return "Things to try:";
|
||||
foreach (var bullet in ThingsToTryBullets)
|
||||
yield return "• " + bullet;
|
||||
}
|
||||
|
||||
public static string GetExplainerBody(this NonJsonResponseException ex)
|
||||
=> string.Join("\r\n", ex.GetExplainerLines());
|
||||
|
||||
public static bool TryFindInTree(Exception ex, out NonJsonResponseException? match)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
NonJsonResponseException? 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 NonJsonResponseException nonJson)
|
||||
found = nonJson;
|
||||
|
||||
if (found is not null)
|
||||
return;
|
||||
|
||||
if (e is AggregateException agg)
|
||||
{
|
||||
foreach (var inner in agg.InnerExceptions)
|
||||
walk(inner);
|
||||
}
|
||||
|
||||
walk(e.InnerException);
|
||||
}
|
||||
}
|
||||
|
||||
private static string getIntro(NonJsonResponseException ex)
|
||||
=> ex.HtmlTitle is null
|
||||
? "Audible returned an HTML page instead of the expected library data."
|
||||
: $"Audible returned an HTML page ({ex.HtmlTitle}) instead of the expected library data.";
|
||||
}
|
||||
@@ -290,7 +290,7 @@ public partial class Cdm
|
||||
private static uint RandomUint()
|
||||
{
|
||||
var bts = new byte[4];
|
||||
new Random().NextBytes(bts);
|
||||
RandomNumberGenerator.Fill(bts);
|
||||
return BitConverter.ToUInt32(bts, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -11,15 +11,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.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="Dinah.Core" Version="11.0.0.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="11.0.0.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -35,6 +35,10 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -52,6 +52,9 @@ public class Book
|
||||
public bool IsSpatial { get; private set; }
|
||||
public DateTime? DatePublished { get; private set; }
|
||||
public string? Language { get; private set; }
|
||||
/// <summary>The publisher's copyright line. Only returned by the API's product_details response
|
||||
/// group, and null for plenty of titles even then.</summary>
|
||||
public string? Copyright { get; private set; }
|
||||
|
||||
// is owned, not optional 1:1
|
||||
public UserDefinedItem UserDefinedItem { get; private set; }
|
||||
@@ -119,6 +122,13 @@ public class Book
|
||||
public void UpdateLengthInMinutes(int lengthInMinutes)
|
||||
=> LengthInMinutes = lengthInMinutes;
|
||||
|
||||
public void UpdateDescription(string? description)
|
||||
{
|
||||
// don't overwrite with default values
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
Description = description.Trim();
|
||||
}
|
||||
|
||||
#region contributors, authors, narrators
|
||||
internal HashSet<BookContributor> ContributorsLink { get; private set; }
|
||||
|
||||
@@ -232,31 +242,67 @@ public class Book
|
||||
private readonly HashSet<Supplement>? _supplements;
|
||||
public IEnumerable<Supplement> Supplements => _supplements?.ToList() ?? [];
|
||||
|
||||
public void AddSupplementDownloadUrl(string url)
|
||||
/// <summary>
|
||||
/// Records where Audible says this book's supplement can be found, replacing anything recorded before.
|
||||
/// <para>
|
||||
/// Audible reports one supplement per title, so this replaces rather than accumulates. It used to add, and
|
||||
/// guarded against duplicates by comparing the incoming url to itself - which came to the same thing only
|
||||
/// because the always-true comparison meant "this book already has a supplement", so a url that changed was
|
||||
/// silently ignored.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void SetSupplementDownloadUrl(string url)
|
||||
{
|
||||
// supplements are owned by Book, so no need to Load():
|
||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner.
|
||||
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
|
||||
|
||||
if (_supplements?.Any(s => url.EqualsInsensitive(url)) is true)
|
||||
if (_supplements is null)
|
||||
return;
|
||||
|
||||
_supplements?.Add(new Supplement(this, url));
|
||||
if (_supplements.Count == 1 && _supplements.First().Url.EqualsInsensitive(url))
|
||||
return;
|
||||
|
||||
_supplements.Clear();
|
||||
_supplements.Add(new Supplement(this, url));
|
||||
|
||||
// Only when the book had no supplement at all. A url Audible has changed says nothing about whether the
|
||||
// file behind it is already downloaded.
|
||||
UserDefinedItem.PdfStatus ??= LiberatedStatus.NotLiberated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets that this book has a supplement, for when Audible says it has none after all.
|
||||
/// <para>
|
||||
/// The status goes back to null rather than to NotLiberated, because null is what "this title has no PDF"
|
||||
/// is recorded as everywhere else: it is the state of a book that never had one, and it is what stops the
|
||||
/// grid from showing a PDF marker and the counts from expecting one.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>Whether anything was removed.</returns>
|
||||
public bool RemoveSupplements()
|
||||
{
|
||||
if (_supplements is null || _supplements.Count == 0)
|
||||
return false;
|
||||
|
||||
_supplements.Clear();
|
||||
UserDefinedItem.PdfStatus = null;
|
||||
return true;
|
||||
}
|
||||
#endregion
|
||||
|
||||
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
||||
|
||||
public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language)
|
||||
public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language, string? copyright = null)
|
||||
{
|
||||
// don't overwrite with default values
|
||||
IsAbridged |= isAbridged;
|
||||
IsSpatial = isSpatial ?? IsSpatial;
|
||||
DatePublished = datePublished ?? DatePublished;
|
||||
Language = language?.Trim().FirstCharToUpper() ?? Language;
|
||||
Copyright = copyright?.Trim() ?? Copyright;
|
||||
}
|
||||
|
||||
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
|
||||
|
||||
@@ -16,11 +16,12 @@ public class Contributor
|
||||
|
||||
// id.s
|
||||
// ----
|
||||
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
|
||||
// the name in the path is decorative; the id is what resolves it. Both of these reach the same page:
|
||||
// https://www.audible.com/author/Robert-Bevan/B0EXAMPLE1 == https://www.audible.com/author/B0EXAMPLE1
|
||||
// goes to summary page
|
||||
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
|
||||
// at bottom "See all titles by Robert Bevan" goes to https://www.audible.com/search?searchAuthor=Robert+Bevan
|
||||
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
|
||||
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
|
||||
// all narrators have no id: https://www.audible.com/search?searchNarrator=Robert+Bevan
|
||||
|
||||
internal int ContributorId { get; private set; }
|
||||
public string Name { get; private set; }
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
|
||||
namespace DataLayer;
|
||||
|
||||
/// <summary>
|
||||
/// Why a download attempt failed, coarse enough to choose how long to wait before trying again.
|
||||
/// Persisted as an int; do not renumber.
|
||||
/// </summary>
|
||||
public enum DownloadFailureKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Audible refused a content license and named an eligibility reason: the title is not owned, is not in
|
||||
/// the Plus catalog, or the account is not entitled to it. Changes only when the account or the catalog
|
||||
/// changes, so this is worth waiting a long time on.
|
||||
/// </summary>
|
||||
LicenseDenied = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Audible accepted the request but has no downloadable asset, as for a preorder that has not been
|
||||
/// released. Expected to start working by itself once the title is published.
|
||||
/// </summary>
|
||||
AssetUnavailable = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Looks like a service interruption or throttling rather than a decision about this title. Retried soon.
|
||||
/// </summary>
|
||||
ServiceInterruption = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The most recent failed attempt to download one title, so that a title Audible has just refused is not
|
||||
/// requested again on every run. One row per (account, title): the same ASIN can be refused on one account
|
||||
/// and downloadable on another.
|
||||
/// <para>
|
||||
/// Nothing here is permanent. <see cref="RetryAfterUtcTicks"/> always names a time, so a title held back
|
||||
/// because of an outage, throttling or an unreleased preorder starts being attempted again on its own.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The database is deliberately the home for this instead of a file under LibationFiles: in Docker,
|
||||
/// LibationFiles is a throwaway directory inside the container and only the database is on a volume, so a
|
||||
/// file-based record would forget every failure on each container start - exactly the case this fixes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DownloadAttemptFailure
|
||||
{
|
||||
internal int DownloadAttemptFailureId { get; private set; }
|
||||
|
||||
public string AudibleProductId { get; private set; }
|
||||
|
||||
/// <summary>The <see cref="LibraryBook.Account"/> the attempt was made with.</summary>
|
||||
public string Account { get; private set; }
|
||||
|
||||
public DownloadFailureKind Kind { get; private set; }
|
||||
|
||||
/// <summary>Failures in a row without an intervening success. Drives how long the next wait is.</summary>
|
||||
public int ConsecutiveFailures { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC ticks rather than a DateTime so range queries mean the same thing on SQLite and PostgreSQL.
|
||||
/// Local time is for display only.
|
||||
/// </summary>
|
||||
public long LastFailedAtUtcTicks { get; private set; }
|
||||
|
||||
/// <summary>When this title becomes eligible for another automatic attempt, in UTC ticks.</summary>
|
||||
public long RetryAfterUtcTicks { get; private set; }
|
||||
|
||||
/// <summary>One line from Audible, kept so the user can be told why without re-requesting a license.</summary>
|
||||
public string? Reason { get; private set; }
|
||||
|
||||
public DateTimeOffset LastFailedAt => new(LastFailedAtUtcTicks, TimeSpan.Zero);
|
||||
public DateTimeOffset RetryAfter => new(RetryAfterUtcTicks, TimeSpan.Zero);
|
||||
|
||||
private DownloadAttemptFailure()
|
||||
{
|
||||
// for EF
|
||||
AudibleProductId = null!;
|
||||
Account = null!;
|
||||
}
|
||||
|
||||
public DownloadAttemptFailure(string audibleProductId, string account, DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset lastFailedAt, DateTimeOffset retryAfter, string? reason)
|
||||
{
|
||||
AudibleProductId = audibleProductId;
|
||||
Account = account;
|
||||
Record(kind, consecutiveFailures, lastFailedAt, retryAfter, reason);
|
||||
}
|
||||
|
||||
public void Record(DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset lastFailedAt, DateTimeOffset retryAfter, string? reason)
|
||||
{
|
||||
Kind = kind;
|
||||
ConsecutiveFailures = consecutiveFailures;
|
||||
LastFailedAtUtcTicks = lastFailedAt.UtcTicks;
|
||||
RetryAfterUtcTicks = retryAfter.UtcTicks;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
=> $"{AudibleProductId} {Kind} x{ConsecutiveFailures}, retry after {RetryAfter.ToLocalTime()}";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
|
||||
namespace DataLayer;
|
||||
|
||||
/// <summary>
|
||||
/// One successful audiobook download, recorded so the opt-in daily download limit survives restarts.
|
||||
/// Recorded for every download regardless of whether a limit is configured: a user who turns the limit
|
||||
/// on after a heavy session gets a limit that reflects what actually happened.
|
||||
/// <para>
|
||||
/// The database is deliberately the home for this instead of a file under LibationFiles: in Docker,
|
||||
/// LibationFiles is a throwaway directory inside the container and only the database is on a volume.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DownloadHistory
|
||||
{
|
||||
internal int DownloadHistoryId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC ticks rather than a DateTime so range queries mean the same thing on SQLite and PostgreSQL, and so a
|
||||
/// window that spans a DST change stays exactly 24 hours. Local time is for display only.
|
||||
/// </summary>
|
||||
public long CompletedAtUtcTicks { get; private set; }
|
||||
|
||||
public string? AudibleProductId { get; private set; }
|
||||
|
||||
public bool IsAudiblePlus { get; private set; }
|
||||
|
||||
/// <summary>Size on disk of the files written to the Books directory for this title.</summary>
|
||||
public long Bytes { get; private set; }
|
||||
|
||||
public DateTimeOffset CompletedAt => new(CompletedAtUtcTicks, TimeSpan.Zero);
|
||||
|
||||
private DownloadHistory() { }
|
||||
|
||||
public DownloadHistory(DateTimeOffset completedAt, string? audibleProductId, bool isAudiblePlus, long bytes)
|
||||
{
|
||||
CompletedAtUtcTicks = completedAt.UtcTicks;
|
||||
AudibleProductId = audibleProductId;
|
||||
IsAudiblePlus = isAudiblePlus;
|
||||
Bytes = bytes;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
=> $"{AudibleProductId} {CompletedAt.ToLocalTime()} {(IsAudiblePlus ? "Plus" : "owned")} {Bytes} bytes";
|
||||
}
|
||||
@@ -26,6 +26,8 @@ public class LibationContext : DbContext, INotifyDisposed
|
||||
public DbSet<Series> Series { get; private set; }
|
||||
public DbSet<Category> Categories { get; private set; }
|
||||
public DbSet<CategoryLadder> CategoryLadders { get; private set; }
|
||||
public DbSet<DownloadHistory> DownloadHistory { get; private set; }
|
||||
public DbSet<DownloadAttemptFailure> DownloadAttemptFailures { get; private set; }
|
||||
|
||||
public event EventHandler? ObjectDisposed;
|
||||
public override void Dispose()
|
||||
@@ -56,6 +58,8 @@ public class LibationContext : DbContext, INotifyDisposed
|
||||
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
||||
modelBuilder.ApplyConfiguration(new CategoryLadderConfig());
|
||||
modelBuilder.ApplyConfiguration(new BookCategoryConfig());
|
||||
modelBuilder.ApplyConfiguration(new DownloadHistoryConfig());
|
||||
modelBuilder.ApplyConfiguration(new DownloadAttemptFailureConfig());
|
||||
|
||||
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
|
||||
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -49,7 +50,15 @@ public class LibationContextFactory
|
||||
{
|
||||
try
|
||||
{
|
||||
context.Database.Migrate();
|
||||
// Migrate() acquires the EF migration lock before checking whether anything is pending.
|
||||
// For SQLite that lock is a row in __EFMigrationsLock with no timeout (SQLite has no
|
||||
// connection-scoped lock that frees on disconnect). A process killed after acquiring the
|
||||
// lock but before releasing it - even during an otherwise no-op Migrate() - orphans the row,
|
||||
// and every later Migrate() then spins forever in SqliteHistoryRepository.AcquireDatabaseLock().
|
||||
// The row is persisted in the db file, so restarting does not clear it. Skipping Migrate()
|
||||
// when nothing is pending keeps the steady-state path off the lock entirely. See #1729.
|
||||
if (context.Database.GetPendingMigrations().Any())
|
||||
context.Database.Migrate();
|
||||
}
|
||||
// SQLITE_READONLY == 8 (https://www.sqlite.org/rescode.html)
|
||||
catch (SqliteException ex) when (ex.SqliteErrorCode == 8 && sqliteDatabaseFilePath is not null)
|
||||
@@ -63,7 +72,9 @@ public class LibationContextFactory
|
||||
{
|
||||
try
|
||||
{
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
// See ApplyMigrations for why pending migrations are checked before acquiring the lock (#1729).
|
||||
if ((await context.Database.GetPendingMigrationsAsync(cancellationToken)).Any())
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
}
|
||||
// SQLITE_READONLY == 8 (https://www.sqlite.org/rescode.html)
|
||||
catch (SqliteException ex) when (ex.SqliteErrorCode == 8 && sqliteDatabaseFilePath is not null)
|
||||
|
||||
Loaded 100 of 550 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user