mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-12 13:47:16 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
78371e3522 | ||
|
|
24a896e61b | ||
|
|
e4daa5b8e8 | ||
|
|
505c614210 | ||
|
|
df531de255 | ||
|
|
7b1dd64936 | ||
|
|
c226e8ca38 | ||
|
|
673ff153b0 | ||
|
|
5b898dcd48 | ||
|
|
b1df70f66c | ||
|
|
220c916258 | ||
|
|
c3e0247940 | ||
|
|
0891fcf256 | ||
|
|
b259fdc9a5 | ||
|
|
ecee08aaf7 | ||
|
|
b53a092327 | ||
|
|
0c838a483e | ||
|
|
d934511c32 | ||
|
|
8c88739644 | ||
|
|
05e1de78da | ||
|
|
dd059a0ce9 | ||
|
|
4f401fe6c7 | ||
|
|
849f5f508e | ||
|
|
ac2b5ef3e2 | ||
|
|
13bdebbe9d | ||
|
|
6445a758a2 | ||
|
|
f6b0765dbc | ||
|
|
95dfee130e | ||
|
|
b8616a6da0 | ||
|
|
0b975a168e | ||
|
|
f610ccdf58 | ||
|
|
f0c61ec7db | ||
|
|
14bd9d9deb | ||
|
|
01487690d4 | ||
|
|
acfde519f5 | ||
|
|
7fb6ea4701 | ||
|
|
db1566a32d | ||
|
|
ca48767cc1 | ||
|
|
310abe951a | ||
|
|
aed2aec38e | ||
|
|
531d98a0d5 | ||
|
|
dd8bba959d | ||
|
|
9d6f0f7700 | ||
|
|
274cbf37ca | ||
|
|
2b25ced65b | ||
|
|
417e654799 | ||
|
|
e9550055c6 | ||
|
|
fa64be7ad0 | ||
|
|
e3508b2998 | ||
|
|
30809fb38d | ||
|
|
5b89f29a4f | ||
|
|
ea33d8933a | ||
|
|
c1e9911ade | ||
|
|
962a5055b2 | ||
|
|
3108bfa469 | ||
|
|
ccc74cef56 | ||
|
|
1de778adef | ||
|
|
4a30acf0eb | ||
|
|
6d326ebabc | ||
|
|
f2ef616203 | ||
|
|
87e8c6a32a | ||
|
|
62dfc20255 | ||
|
|
9f255a3bc1 | ||
|
|
6d7af0a8ae | ||
|
|
650b9c7f5b | ||
|
|
dc353aa7e5 | ||
|
|
d5258b7e64 | ||
|
|
475f20b3cb | ||
|
|
6efc64b927 | ||
|
|
d2cc9ff26f | ||
|
|
cfe4770387 | ||
|
|
33967adb15 | ||
|
|
9ef8dca4d5 | ||
|
|
af1029f4b5 | ||
|
|
63e7f0f44e | ||
|
|
65623e8e81 | ||
|
|
a5912efa0c | ||
|
|
dba3e6ca8e | ||
|
|
a4bc39ee6b | ||
|
|
675fecec67 | ||
|
|
be1eac5692 | ||
|
|
8bad7d90c3 | ||
|
|
c72e5a3568 | ||
|
|
e145e46579 | ||
|
|
5fd8ac481b | ||
|
|
b6c7612ec4 | ||
|
|
3456eb6c35 | ||
|
|
c1b8cc894a | ||
|
|
56c15d3108 | ||
|
|
810ea90770 | ||
|
|
b90cc67b09 | ||
|
|
550a99ac90 | ||
|
|
9ed89eca9e | ||
|
|
197d645e10 | ||
|
|
bdfc99f209 | ||
|
|
a010da5251 | ||
|
|
4c212e752f | ||
|
|
e8d2ac5c62 | ||
|
|
6c552623d9 | ||
|
|
5a0d62687c | ||
|
|
f4e61d0445 | ||
|
|
54765fc181 | ||
|
|
d292cb0472 | ||
|
|
b4f48cb54c | ||
|
|
e7d08d30b0 | ||
|
|
26946e8a87 | ||
|
|
ecf1cf4404 | ||
|
|
8213521404 | ||
|
|
0ef73464b8 | ||
|
|
3030b748d7 | ||
|
|
ccef50d681 | ||
|
|
78e6d7c5c1 | ||
|
|
846b93c97c | ||
|
|
b0e79faacf | ||
|
|
3fece6aa44 | ||
|
|
a7c7bd9925 | ||
|
|
3373b0979a | ||
|
|
3e3da2298e | ||
|
|
a9cb560f95 | ||
|
|
e4282f014e | ||
|
|
b694e319f7 | ||
|
|
67e3e41b15 | ||
|
|
ad2101ab8c | ||
|
|
d7ea8eb8f1 | ||
|
|
5c1153e187 | ||
|
|
e929ab3062 | ||
|
|
d64fd564ab | ||
|
|
340b0a0d29 | ||
|
|
1f217b3966 | ||
|
|
accb43456b | ||
|
|
b3a781de5e | ||
|
|
34613f7e97 | ||
|
|
1def16a157 | ||
|
|
33a209df3c | ||
|
|
2daf2da30a | ||
|
|
dd2bc7c461 | ||
|
|
1d1936734b | ||
|
|
e7f6e71d3b | ||
|
|
0da8af3b8f | ||
|
|
f159bcdbb9 | ||
|
|
fc85aa457c | ||
|
|
687a51b7bc | ||
|
|
477d0088e2 | ||
|
|
38138d8272 | ||
|
|
fc92c633e1 | ||
|
|
3c71cb10b2 | ||
|
|
65d8adc018 | ||
|
|
9e739e48d7 | ||
|
|
6b26280aa8 | ||
|
|
14d8bb2205 | ||
|
|
0960edeafb | ||
|
|
6d2b0b952d | ||
|
|
fb277cff81 | ||
|
|
dbd5af8ab0 | ||
|
|
02b9b4fa1c | ||
|
|
7ecd571364 | ||
|
|
32f3e6a886 | ||
|
|
e477c29890 | ||
|
|
a8621699c1 | ||
|
|
cd9a070784 | ||
|
|
d8ce7cc9b0 | ||
|
|
1d6d4ff9ac | ||
|
|
db3a810f47 | ||
|
|
508ea1032d | ||
|
|
10fc5837cb | ||
|
|
812e0c3b60 | ||
|
|
d161bdfaeb | ||
|
|
a6aa14c7f8 | ||
|
|
7dd55a2545 | ||
|
|
1081dfe8df | ||
|
|
3f716cd1ac | ||
|
|
338715d52a | ||
|
|
d275ad034b | ||
|
|
b5ecff7e27 | ||
|
|
3144506ad5 | ||
|
|
99629c3c25 | ||
|
|
3fdb8ef15d | ||
|
|
a43a700e37 | ||
|
|
80823bf15c | ||
|
|
0a576069df | ||
|
|
0b0f5184d2 | ||
|
|
24d825607d | ||
|
|
14e4ea4592 | ||
|
|
56720124b8 | ||
|
|
20c7d72181 | ||
|
|
0d00cf2b10 | ||
|
|
a79da66b0e | ||
|
|
fdc56e9ad6 | ||
|
|
ddef3b649b | ||
|
|
01c2a3b1c3 | ||
|
|
4eec61a659 | ||
|
|
c983513f45 | ||
|
|
0172c7b8eb | ||
|
|
5cf710756f | ||
|
|
760b0dcb6b | ||
|
|
6c602286c0 | ||
|
|
8e69191bbf | ||
|
|
58095f291b | ||
|
|
50319defa9 | ||
|
|
fd35b6dd85 | ||
|
|
2da2d1c29e | ||
|
|
64c5807daa | ||
|
|
1e8358eeeb | ||
|
|
2868039772 | ||
|
|
45af5aa913 | ||
|
|
487fcb007c | ||
|
|
76d4a44040 | ||
|
|
07d9fff2b3 | ||
|
|
bf1e90c33c | ||
|
|
c30a79d97b | ||
|
|
737121963f | ||
|
|
6b319fe1a7 | ||
|
|
70ef1c52a4 | ||
|
|
bdf5f2faea | ||
|
|
5ff3dc7581 | ||
|
|
41fcef207b | ||
|
|
4edc77aa00 | ||
|
|
6d05391151 | ||
|
|
f9e2af6916 | ||
|
|
75e9a7db1f | ||
|
|
ee92988125 | ||
|
|
c005af2457 | ||
|
|
89ae5709a9 | ||
|
|
9c9f1c67c8 | ||
|
|
d637068f64 | ||
|
|
490d292c18 | ||
|
|
8417b121a4 | ||
|
|
72edd1c896 | ||
|
|
4e8306470c | ||
|
|
bdbeeb8935 | ||
|
|
1b12dd04ff | ||
|
|
589b806aff | ||
|
|
c248bc0a04 | ||
|
|
db4ef4b321 | ||
|
|
ca7d3feaf9 | ||
|
|
2b0b1ac798 | ||
|
|
41e0aa0dfb | ||
|
|
f3eb4d9f87 | ||
|
|
ae157a1506 | ||
|
|
a7654745b6 | ||
|
|
8dad104121 | ||
|
|
5bf1438e00 | ||
|
|
da3d057372 | ||
|
|
9fec47e8ab | ||
|
|
a218ed5e66 | ||
|
|
4f28268785 | ||
|
|
e3e55a30e1 | ||
|
|
b157d760ec | ||
|
|
60c532a3bc | ||
|
|
2a348df4ac | ||
|
|
bd881d4dc4 | ||
|
|
a6a9b33d01 | ||
|
|
a77fe73153 | ||
|
|
8d77787df7 | ||
|
|
44fe5b95f1 | ||
|
|
b8d7a956cd | ||
|
|
002bdde444 | ||
|
|
642eeecc0a | ||
|
|
3343ef263c | ||
|
|
6cf51c63c6 | ||
|
|
c34342ecb5 | ||
|
|
53e5b635a3 | ||
|
|
e6b12914a4 | ||
|
|
28327efd7b | ||
|
|
f17af04908 | ||
|
|
3ab1edc076 | ||
|
|
d67692355f | ||
|
|
f15e073204 | ||
|
|
f00951d615 | ||
|
|
c7e844a54c | ||
|
|
178cc82d65 | ||
|
|
054f7437d1 | ||
|
|
99051b6975 | ||
|
|
eed7f1811b | ||
|
|
33cd6b8639 | ||
|
|
06014f467e | ||
|
|
b2eef18217 | ||
|
|
443d1f64ca | ||
|
|
c43e88d269 | ||
|
|
dc1919f411 | ||
|
|
fdfae2f806 | ||
|
|
5c56e1d39b | ||
|
|
b39e2c3e0b | ||
|
|
946d4779a9 | ||
|
|
e620d0be24 | ||
|
|
d58f5abe35 | ||
|
|
d957d6d5d7 | ||
|
|
cb159336a6 | ||
|
|
807bb56c49 | ||
|
|
3d56554aa5 | ||
|
|
f720127a68 | ||
|
|
c6c3b39e9c | ||
|
|
e70cc5342c | ||
|
|
bf0d380dd2 | ||
|
|
471fd1e757 | ||
|
|
a43f25db23 | ||
|
|
e1f4168599 | ||
|
|
29501bddf3 | ||
|
|
e9016ace03 | ||
|
|
d498c094bf | ||
|
|
ce92e79cd8 | ||
|
|
f54a789ae8 | ||
|
|
3ef0bce909 | ||
|
|
413da72bf0 | ||
|
|
db9c810c2d | ||
|
|
ae18ae1b8d | ||
|
|
c1298e9ff6 | ||
|
|
647eb8b9d9 | ||
|
|
fd64d394c2 | ||
|
|
f026d415bd | ||
|
|
3948e25c99 | ||
|
|
6df47d4d9c | ||
|
|
0103c879f2 | ||
|
|
79e2bca8fc | ||
|
|
7bbc681767 | ||
|
|
c95dccd246 | ||
|
|
94cf665be7 | ||
|
|
7d38874257 | ||
|
|
659f793eb8 | ||
|
|
6f7cd4d5b5 | ||
|
|
068f37319f | ||
|
|
dc58a101af | ||
|
|
7b68415b02 | ||
|
|
1514de54da | ||
|
|
804bac5c4c | ||
|
|
3fa805d51f | ||
|
|
1eff725125 | ||
|
|
af1b1a70ae | ||
|
|
042f2e135a | ||
|
|
930fc3da58 | ||
|
|
90e8d03590 | ||
|
|
ee908a4f13 | ||
|
|
70ec31303b | ||
|
|
319d547aa0 | ||
|
|
a59e42e7c9 | ||
|
|
af2e89dd1e | ||
|
|
b2c5884e11 | ||
|
|
11d9cdefe2 | ||
|
|
54485ae150 | ||
|
|
4bd641ee50 | ||
|
|
6e56297434 | ||
|
|
e1f59eadbd | ||
|
|
cfda065219 | ||
|
|
e3ad4a2c32 | ||
|
|
2b1c772df7 | ||
|
|
9b3e4f8762 | ||
|
|
396d2c8a95 | ||
|
|
91090b74ab | ||
|
|
32979b5905 | ||
|
|
f6b96fc210 | ||
|
|
09e610fe08 | ||
|
|
e50d8c74de | ||
|
|
2b1ca13249 | ||
|
|
7d30a3036d | ||
|
|
bb8b435810 | ||
|
|
e850465ec1 | ||
|
|
29a5c943cb | ||
|
|
31087c0855 | ||
|
|
c91d359017 | ||
|
|
7dfdc0688a | ||
|
|
c6c36c74f1 | ||
|
|
d932b57853 | ||
|
|
a29da7318b | ||
|
|
678c3e6bcd | ||
|
|
a8466e38d4 | ||
|
|
802ccf25e8 | ||
|
|
c243b9c913 | ||
|
|
4d47ab3ebe | ||
|
|
8dea6200ce | ||
|
|
a578777352 | ||
|
|
e58e5165cf | ||
|
|
87c2cb6e19 | ||
|
|
4f44c26b57 | ||
|
|
03534773ab | ||
|
|
37f223fb77 | ||
|
|
cf932bd66c | ||
|
|
f0dc33a01e | ||
|
|
315d76e061 | ||
|
|
6e78145adc | ||
|
|
200a334f86 | ||
|
|
4dd4a1495a | ||
|
|
b3ce0e0af0 | ||
|
|
1299d91d08 | ||
|
|
ad3a767057 | ||
|
|
a59c73caf8 | ||
|
|
442a688b85 | ||
|
|
0c85ea4d11 | ||
|
|
03ed8e6b57 | ||
|
|
3eca508a26 | ||
|
|
770adf33f3 | ||
|
|
1087ffb150 | ||
|
|
f620234e7d | ||
|
|
2b6b5d082e | ||
|
|
cbbc45c3c5 | ||
|
|
28de1a6cb6 | ||
|
|
1615c6ef77 | ||
|
|
6961bd72fa | ||
|
|
68846a90e5 | ||
|
|
d60ec0702c | ||
|
|
1c55c8533a | ||
|
|
6fa69b603e | ||
|
|
3df8a97463 | ||
|
|
0bd7bd80b9 | ||
|
|
13bb4238b4 | ||
|
|
d5021e4f74 | ||
|
|
5e1458cfb4 | ||
|
|
e1d4533887 | ||
|
|
c1bd1d983b | ||
|
|
b567c38a98 | ||
|
|
348ec22465 | ||
|
|
90bb4d9176 | ||
|
|
7944154ea6 | ||
|
|
01fc7f3fb9 | ||
|
|
b70f973994 | ||
|
|
98d3f85579 | ||
|
|
bdae155af6 | ||
|
|
c8b44193ac | ||
|
|
9545b3a874 | ||
|
|
e932c9fab9 | ||
|
|
c8f4c1e751 | ||
|
|
0303db153f | ||
|
|
a7e9479eab | ||
|
|
d339dbc906 | ||
|
|
5fe6f931ad | ||
|
|
ca9fe9fc32 | ||
|
|
986dbd678f | ||
|
|
ea3716f48a | ||
|
|
426d5a87b4 | ||
|
|
c893bbe52e | ||
|
|
ad5a9874af | ||
|
|
3b70c08439 | ||
|
|
a230605ed5 | ||
|
|
d48ce39773 | ||
|
|
368e695214 | ||
|
|
9c3881c67d | ||
|
|
4c5fdf05f5 | ||
|
|
4bd491f5b9 | ||
|
|
c34b1e752e | ||
|
|
fa30c10435 | ||
|
|
cdb91ae2ca | ||
|
|
7852067b81 | ||
|
|
3708515df9 | ||
|
|
530aca4f4d | ||
|
|
cf571148bc | ||
|
|
2c2a720ba9 | ||
|
|
b577ef7187 | ||
|
|
ffbb3c3516 | ||
|
|
2a6cf38677 | ||
|
|
d8104a4d7c | ||
|
|
af85ea9219 | ||
|
|
c30e149a36 | ||
|
|
050a4867b7 | ||
|
|
2bf6f7a4f2 | ||
|
|
788a768271 | ||
|
|
022a6e979d | ||
|
|
9fc5a7d834 | ||
|
|
b72e5039b1 | ||
|
|
e992b49da2 | ||
|
|
74afbbf581 | ||
|
|
d82ffe1467 | ||
|
|
8a84a083d1 | ||
|
|
04827f81da | ||
|
|
805f42b1cc | ||
|
|
b9a1709284 | ||
|
|
b0a40e12b7 | ||
|
|
dfbc5ec9db | ||
|
|
649ef5f864 | ||
|
|
4345bf2ee2 | ||
|
|
441d430dea | ||
|
|
85ee0bcddb | ||
|
|
0f1fc0f11d | ||
|
|
75aa17df11 | ||
|
|
913019cdfd | ||
|
|
a55da5f187 | ||
|
|
f9ac0253fb | ||
|
|
fde78f4167 | ||
|
|
22159b79d8 | ||
|
|
7fe170acdf | ||
|
|
c0898a288b | ||
|
|
ce2b81036f | ||
|
|
65d24ce223 | ||
|
|
e8c911e603 | ||
|
|
59f66ff480 | ||
|
|
e05dcd6f54 | ||
|
|
d1ce9d5a83 | ||
|
|
2213f5c86a | ||
|
|
d6b232f342 | ||
|
|
f29c19beb8 | ||
|
|
9f6d08fc1f | ||
|
|
717dfcd923 | ||
|
|
a3d181b2ec | ||
|
|
d16eeea56b | ||
|
|
5d2513ec33 | ||
|
|
e5043dcf40 | ||
|
|
c61bfb4134 | ||
|
|
d47a2595b9 | ||
|
|
55e74db4fb | ||
|
|
0a171222bc | ||
|
|
c2093157ca | ||
|
|
8e073800cd | ||
|
|
1daf07b882 | ||
|
|
27a23a16d6 | ||
|
|
c878b9fec0 | ||
|
|
7a01f075ac | ||
|
|
23d391485d | ||
|
|
46be532740 | ||
|
|
e2fd88d075 | ||
|
|
bb0dea3fa9 | ||
|
|
def0b1f611 | ||
|
|
bfee579719 | ||
|
|
d4139861f3 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*.md]
|
||||
charset = utf-8
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -40,4 +40,6 @@ Attach your Libation log file here. If your user folder contains the file "Libat
|
||||
|macOS|`~/Library/Application Support/Libation`|
|
||||
|Linux|`~/.local/share/Libation`|
|
||||
|
||||
**macOS:** If that folder does not exist because Libation never starts, try launching from Terminal and say whether it works: `/Applications/Libation.app/Contents/MacOS/Libation` (see [Troubleshooting for macOS](https://github.com/rmcrackan/Libation/blob/main/docs/advanced/troubleshoot.md#macos)).
|
||||
|
||||
Alternative, you may open the log file folder from within Libation. Open Libation's settings, and on the first tab in Settings you can click the button 'Open log folder'.
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1,10 @@
|
||||
# Labels required by exempt-creators.yml and stale.yml. Sync with sync-labels workflow.
|
||||
# (Other repo labels are left unchanged when prune: false.)
|
||||
---
|
||||
- name: stale
|
||||
color: 'eeeeee'
|
||||
description: 'No activity for an extended period'
|
||||
|
||||
- name: exempt
|
||||
color: '0e8a16'
|
||||
description: 'Excluded from automatic stale closure (e.g. maintainer-opened)'
|
||||
@@ -6,63 +6,46 @@ name: build
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_override:
|
||||
libation-version:
|
||||
type: string
|
||||
description: "Version number override"
|
||||
required: false
|
||||
run_unit_tests:
|
||||
required: true
|
||||
dotnet-version:
|
||||
type: string
|
||||
required: true
|
||||
run-unit-tests:
|
||||
type: boolean
|
||||
description: "Skip running unit tests"
|
||||
required: false
|
||||
default: true
|
||||
runs_on:
|
||||
publish-r2r:
|
||||
type: boolean
|
||||
retention-days:
|
||||
type: number
|
||||
architecture:
|
||||
type: string
|
||||
description: "The GitHub hosted runner to use"
|
||||
description: "CPU architecture targeted by the build."
|
||||
required: true
|
||||
OS:
|
||||
type: string
|
||||
description: >
|
||||
The operating system targeted by the build.
|
||||
|
||||
|
||||
There must be a corresponding Bundle_$OS.sh script file in ./Scripts
|
||||
required: true
|
||||
architecture:
|
||||
type: string
|
||||
description: "CPU architecture targeted by the build."
|
||||
required: true
|
||||
|
||||
env:
|
||||
DOTNET_CONFIGURATION: "Release"
|
||||
DOTNET_VERSION: "9.0.x"
|
||||
RELEASE_NAME: "chardonnay"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
|
||||
runs-on: ${{ inputs.runs_on }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUNTIME_ID: "linux-${{ inputs.architecture }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
env:
|
||||
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
inputVersion="${{ inputs.version_override }}"
|
||||
if [[ "${#inputVersion}" -gt 0 ]]
|
||||
then
|
||||
version="${inputVersion}"
|
||||
else
|
||||
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
|
||||
fi
|
||||
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
|
||||
- name: Unit test
|
||||
if: ${{ inputs.run_unit_tests }}
|
||||
if: ${{ inputs.run-unit-tests }}
|
||||
working-directory: ./Source
|
||||
run: dotnet test
|
||||
|
||||
@@ -70,63 +53,31 @@ jobs:
|
||||
id: publish
|
||||
working-directory: ./Source
|
||||
run: |
|
||||
if [[ "${{ inputs.OS }}" == "MacOS" ]]
|
||||
then
|
||||
display_os="macOS"
|
||||
RUNTIME_ID="osx-${{ inputs.architecture }}"
|
||||
else
|
||||
display_os="Linux"
|
||||
RUNTIME_ID="linux-${{ inputs.architecture }}"
|
||||
fi
|
||||
|
||||
OUTPUT="bin/Publish/${display_os}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}"
|
||||
|
||||
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
|
||||
echo "Runtime Identifier: $RUNTIME_ID"
|
||||
echo "Output Directory: $OUTPUT"
|
||||
|
||||
dotnet publish \
|
||||
LibationAvalonia/LibationAvalonia.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||
dotnet publish \
|
||||
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
|
||||
dotnet publish \
|
||||
LibationCli/LibationCli.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||
dotnet publish \
|
||||
HangoverAvalonia/HangoverAvalonia.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||
PUBLISH_ARGS=(
|
||||
'--runtime' '${{ env.RUNTIME_ID }}'
|
||||
'--configuration' 'Release'
|
||||
'--output' '../bin'
|
||||
'-p:PublishProtocol=FileSystem'
|
||||
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
|
||||
'-p:SelfContained=true')
|
||||
|
||||
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
|
||||
|
||||
- name: Build bundle
|
||||
id: bundle
|
||||
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}
|
||||
run: |
|
||||
BUNDLE_DIR=$(pwd)
|
||||
echo "Bundle dir: ${BUNDLE_DIR}"
|
||||
cd ..
|
||||
SCRIPT=../../../Scripts/Bundle_${{ inputs.OS }}.sh
|
||||
SCRIPT=./Scripts/Bundle_${{ inputs.OS }}.sh
|
||||
chmod +rx ${SCRIPT}
|
||||
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}"
|
||||
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}"
|
||||
artifact=$(ls ./bundle)
|
||||
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Publish bundle
|
||||
uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.bundle.outputs.artifact }}
|
||||
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
|
||||
path: ./bundle/${{ steps.bundle.outputs.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
@@ -0,0 +1,113 @@
|
||||
# build-mac.yml
|
||||
# Reusable workflow that builds the MacOS (x64 and arm64) versions of Libation.
|
||||
---
|
||||
name: build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
libation-version:
|
||||
type: string
|
||||
required: true
|
||||
dotnet-version:
|
||||
type: string
|
||||
required: true
|
||||
run-unit-tests:
|
||||
type: boolean
|
||||
publish-r2r:
|
||||
type: boolean
|
||||
retention-days:
|
||||
type: number
|
||||
sign-app:
|
||||
type: boolean
|
||||
description: "Wheather to sign an notorize the app bundle and dmg."
|
||||
architecture:
|
||||
type: string
|
||||
description: "CPU architecture targeted by the build."
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "macOS-${{ inputs.architecture }}"
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RUNTIME_ID: "osx-${{ inputs.architecture }}"
|
||||
WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }}
|
||||
steps:
|
||||
- uses: apple-actions/import-codesign-certs@v7
|
||||
if: ${{ inputs.sign-app }}
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
|
||||
p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }}
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
|
||||
- name: Unit test
|
||||
if: ${{ inputs.run-unit-tests }}
|
||||
working-directory: ./Source
|
||||
run: dotnet test
|
||||
|
||||
- name: Publish
|
||||
id: publish
|
||||
working-directory: ./Source
|
||||
run: |
|
||||
PUBLISH_ARGS=(
|
||||
'--runtime' '${{ env.RUNTIME_ID }}'
|
||||
'--configuration' 'Release'
|
||||
'--output' '../bin'
|
||||
'-p:PublishProtocol=FileSystem'
|
||||
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
|
||||
'-p:SelfContained=true')
|
||||
|
||||
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
|
||||
|
||||
- name: Build bundle
|
||||
id: bundle
|
||||
run: |
|
||||
SCRIPT=./Scripts/Bundle_MacOS.sh
|
||||
chmod +rx ${SCRIPT}
|
||||
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}" "${{ inputs.sign-app }}"
|
||||
artifact=$(ls ./bundle)
|
||||
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Notarize bundle
|
||||
if: ${{ inputs.sign-app }}
|
||||
run: |
|
||||
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
|
||||
WAIT="--wait"
|
||||
fi
|
||||
echo "::debug::Submitting the disk image for notarization"
|
||||
# Capture stdout+stderr (2>&1). Use || true so that when notarytool fails (e.g. Apple TOS
|
||||
# agreement required), the script does not exit before we can print RESPONSE—otherwise the
|
||||
# job would fail with no visible error message in the workflow log.
|
||||
RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1) || true
|
||||
echo "$RESPONSE"
|
||||
SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }')
|
||||
echo "::notice::Notary Submission Id: $SUBMISSION_ID"
|
||||
|
||||
# Re-fail the step if submit failed (e.g. no submission id). The job still fails, but the
|
||||
# output above is now visible in the log so we can see the real error (e.g. sign agreement).
|
||||
if [ -z "$SUBMISSION_ID" ]; then
|
||||
echo "::error::Notarization submit failed. See output above for details (e.g. Apple TOS agreement)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
|
||||
echo "::debug::Stapling the notarization ticket to the disk image"
|
||||
xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.bundle.outputs.artifact }}
|
||||
path: ./bundle/${{ steps.bundle.outputs.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
@@ -6,113 +6,140 @@ name: build
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_override:
|
||||
libation-version:
|
||||
type: string
|
||||
description: "Version number override"
|
||||
required: false
|
||||
run_unit_tests:
|
||||
type: boolean
|
||||
description: "Skip running unit tests"
|
||||
required: false
|
||||
default: true
|
||||
architecture:
|
||||
type: string
|
||||
description: "CPU architecture targeted by the build."
|
||||
required: true
|
||||
|
||||
env:
|
||||
DOTNET_CONFIGURATION: "Release"
|
||||
DOTNET_VERSION: "9.0.x"
|
||||
dotnet-version:
|
||||
type: string
|
||||
required: true
|
||||
run-unit-tests:
|
||||
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
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||
name: "Windows-${{ matrix.release_name }}-${{ matrix.architecture }} (${{ matrix.ui }})"
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||
RUNTIME_ID: "win-${{ inputs.architecture }}"
|
||||
strategy:
|
||||
matrix:
|
||||
os: [Windows]
|
||||
ui: [Avalonia]
|
||||
architecture: [x64]
|
||||
release_name: [chardonnay]
|
||||
artifact_stem: [Libation]
|
||||
include:
|
||||
- os: Windows
|
||||
- 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@v5
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
env:
|
||||
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
if ("${{ inputs.version_override }}".length -gt 0) {
|
||||
$version = "${{ inputs.version_override }}"
|
||||
} else {
|
||||
$version = (Select-Xml -Path "./Source/AppScaffolding/AppScaffolding.csproj" -XPath "/Project/PropertyGroup/Version").Node.InnerXML.Trim()
|
||||
}
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
|
||||
- name: Unit test
|
||||
if: ${{ inputs.run_unit_tests }}
|
||||
if: ${{ inputs.run-unit-tests }}
|
||||
working-directory: ./Source
|
||||
run: dotnet test
|
||||
|
||||
|
||||
- name: Publish
|
||||
working-directory: ./Source
|
||||
run: |
|
||||
dotnet publish `
|
||||
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
LibationCli/LibationCli.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:DefineConstants="${{ matrix.release_name }}" `
|
||||
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
$PUBLISH_ARGS=@(
|
||||
"--runtime", "win-${{ matrix.architecture }}",
|
||||
"--configuration", "Release",
|
||||
"--output", "../bin",
|
||||
"-p:PublishProtocol=FileSystem",
|
||||
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}",
|
||||
"-p:SelfContained=true")
|
||||
|
||||
# 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
|
||||
working-directory: ./Source/bin/Publish
|
||||
working-directory: ./bin
|
||||
run: |
|
||||
$bin_dir = "${{ env.OUTPUT_NAME }}\"
|
||||
$delfiles = @(
|
||||
"WindowsConfigApp.exe",
|
||||
"WindowsConfigApp.runtimeconfig.json",
|
||||
"WindowsConfigApp.deps.json"
|
||||
)
|
||||
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
|
||||
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||
"WindowsConfigApp.deps.json")
|
||||
|
||||
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
|
||||
|
||||
# Both UIs publish as Libation.exe. upload-artifact's if-no-files-found only proves the zip exists,
|
||||
# which it does whether or not the app is inside it, so say outright that it has to be.
|
||||
if (-not (Test-Path "Libation.exe")) { throw "Libation.exe is missing from the publish output" }
|
||||
|
||||
$artifact="${{ matrix.artifact_stem }}.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
|
||||
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
||||
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
|
||||
Compress-Archive -Path * -DestinationPath "$artifact"
|
||||
|
||||
- name: Publish artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.zip.outputs.artifact }}.zip
|
||||
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
|
||||
name: ${{ steps.zip.outputs.artifact }}
|
||||
path: ./bin/${{ steps.zip.outputs.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
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 }}
|
||||
|
||||
+47
-28
@@ -6,26 +6,52 @@ name: build
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_override:
|
||||
libation-version:
|
||||
type: string
|
||||
description: "Version number override"
|
||||
required: false
|
||||
run_unit_tests:
|
||||
description: "Libation version number"
|
||||
required: true
|
||||
dotnet-version:
|
||||
type: string
|
||||
default: "10.x"
|
||||
description: ".NET version to target"
|
||||
run-unit-tests:
|
||||
type: boolean
|
||||
description: "Skip running unit tests"
|
||||
required: false
|
||||
default: true
|
||||
description: "Whether to run unit tests prior to publishing."
|
||||
publish-r2r:
|
||||
type: boolean
|
||||
description: "Whether to publish assemblies as ReadyToRun."
|
||||
release:
|
||||
type: boolean
|
||||
description: "Whether this workflow is being called as a release"
|
||||
retention-days:
|
||||
type: number
|
||||
description: "Number of days the artifacts are to be retained."
|
||||
|
||||
jobs:
|
||||
jobs:
|
||||
windows:
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [x64]
|
||||
uses: ./.github/workflows/build-windows.yml
|
||||
with:
|
||||
version_override: ${{ inputs.version_override }}
|
||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||
libation-version: ${{ inputs.libation-version }}
|
||||
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:
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [x64, arm64]
|
||||
uses: ./.github/workflows/build-mac.yml
|
||||
with:
|
||||
libation-version: ${{ inputs.libation-version }}
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
run-unit-tests: ${{ inputs.run-unit-tests }}
|
||||
publish-r2r: ${{ inputs.publish-r2r }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
architecture: ${{ matrix.architecture }}
|
||||
sign-app: ${{ inputs.release || vars.SIGN_MAC_APP_ON_VALIDATE == 'true' }}
|
||||
secrets: inherit
|
||||
|
||||
linux:
|
||||
strategy:
|
||||
@@ -34,20 +60,13 @@ jobs:
|
||||
architecture: [x64, arm64]
|
||||
uses: ./.github/workflows/build-linux.yml
|
||||
with:
|
||||
version_override: ${{ inputs.version_override }}
|
||||
runs_on: ubuntu-latest
|
||||
libation-version: ${{ inputs.libation-version }}
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
run-unit-tests: ${{ inputs.run-unit-tests }}
|
||||
publish-r2r: ${{ inputs.publish-r2r }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
architecture: ${{ matrix.architecture }}
|
||||
OS: ${{ matrix.OS }}
|
||||
architecture: ${{ matrix.architecture }}
|
||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||
|
||||
macos:
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [x64, arm64]
|
||||
uses: ./.github/workflows/build-linux.yml
|
||||
with:
|
||||
version_override: ${{ inputs.version_override }}
|
||||
runs_on: macos-latest
|
||||
OS: MacOS
|
||||
architecture: ${{ matrix.architecture }}
|
||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Deploy VitePress site to Pages
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the `main` branch. Change this to `master` if you're
|
||||
# using the `master` branch as the default branch.
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- .github/workflows/deploy.yml
|
||||
- docs/**
|
||||
- .vitepress/**
|
||||
- index.md
|
||||
- package.json
|
||||
- package-lock.json
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Build job
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
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
|
||||
# with:
|
||||
# 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@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Install dependencies
|
||||
run: npm ci # or pnpm install / yarn install / bun install
|
||||
- name: Build with VitePress
|
||||
run: npm run docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: .vitepress/dist
|
||||
|
||||
# Deployment job
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -26,24 +26,24 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ inputs.release }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.docker_username }}
|
||||
password: ${{ secrets.docker_token }}
|
||||
|
||||
- name: Generate docker image tags
|
||||
id: metadata
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
flavor: |
|
||||
latest=true
|
||||
@@ -53,11 +53,15 @@ jobs:
|
||||
type=raw,value=${{ inputs.version }},enable=${{ inputs.release }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
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 }}
|
||||
@@ -0,0 +1,67 @@
|
||||
# One-time or occasional backfill: add `exempt` to open issues/PRs opened by
|
||||
# rmcrackan or Mbucari that predate exempt-creators.yml. Run from Actions →
|
||||
# "Exempt creators backfill" → Run workflow.
|
||||
---
|
||||
name: Exempt creators backfill
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
backfill:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const exemptLabel = 'exempt';
|
||||
const creators = new Set(['rmcrackan', 'mbucari']);
|
||||
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const items = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let labeled = 0;
|
||||
let notCreator = 0;
|
||||
let alreadyExempt = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const login = item.user && item.user.login;
|
||||
if (!login || !creators.has(login.toLowerCase())) {
|
||||
notCreator++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const names = item.labels.map((l) => l.name);
|
||||
if (names.some((n) => n.toLowerCase() === exemptLabel)) {
|
||||
alreadyExempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
labels: [exemptLabel],
|
||||
});
|
||||
labeled++;
|
||||
} catch (e) {
|
||||
core.warning(`#${item.number}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Backfill done: added exempt to ${labeled} item(s); ` +
|
||||
`${alreadyExempt} already had exempt; ${notCreator} not from rmcrackan/Mbucari.`
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
# Assigns the `exempt` label to issues and PRs opened by maintainer accounts so
|
||||
# stale.yml does not mark them stale. See issue #1532.
|
||||
# For issues/PRs opened before this existed, run workflow "Exempt creators backfill".
|
||||
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/exempt-creators.yml
|
||||
---
|
||||
name: Exempt specific creators
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
exempt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add exempt label
|
||||
if: >-
|
||||
(github.event_name == 'issues' &&
|
||||
contains(fromJSON('["rmcrackan","Mbucari"]'), github.event.issue.user.login)) ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(fromJSON('["rmcrackan","Mbucari"]'), github.event.pull_request.user.login))
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const number = context.issue?.number || context.payload.pull_request?.number;
|
||||
const repo = context.repo;
|
||||
if (number) {
|
||||
await github.rest.issues.addLabels({
|
||||
...repo,
|
||||
issue_number: number,
|
||||
labels: ['exempt'],
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
- "v*"
|
||||
jobs:
|
||||
prerelease:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-slim
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
steps:
|
||||
@@ -31,28 +31,38 @@ jobs:
|
||||
build:
|
||||
needs: [prerelease]
|
||||
uses: ./.github/workflows/build.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
version_override: ${{ needs.prerelease.outputs.version }}
|
||||
run_unit_tests: false
|
||||
libation-version: ${{ needs.prerelease.outputs.version }}
|
||||
publish-r2r: true
|
||||
release: true
|
||||
|
||||
release:
|
||||
needs: [prerelease, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
- 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
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Libation ${{ needs.prerelease.outputs.version }}
|
||||
body: <Put a body here>
|
||||
body: "<Put a body here>"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
files: |
|
||||
artifacts/*/*
|
||||
artifacts/*
|
||||
@@ -0,0 +1,66 @@
|
||||
# Marks inactive issues/PRs stale and closes them after a further quiet period.
|
||||
# Exempt labels include `enhancement`, `exempt` (from exempt-creators.yml), and others below.
|
||||
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/stale.yml
|
||||
---
|
||||
name: Close stale issues and PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v11
|
||||
with:
|
||||
days-before-stale: 30
|
||||
days-before-close: 14
|
||||
operations-per-run: 100
|
||||
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale because it has not had any activity for 30 days.
|
||||
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
|
||||
If this issue is still relevant, please leave a comment to keep it open.
|
||||
Thank you for your contributions to Libation.
|
||||
|
||||
close-issue-message: |
|
||||
This issue has been automatically closed due to inactivity.
|
||||
|
||||
If you believe this issue is still relevant, please reopen it or create a new issue with updated information.
|
||||
Thank you for your understanding.
|
||||
|
||||
stale-issue-label: stale
|
||||
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale because it has not had any activity for 30 days.
|
||||
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
|
||||
If you're still working on this PR, please leave a comment to keep it open.
|
||||
Thank you for your contribution.
|
||||
|
||||
close-pr-message: |
|
||||
This pull request has been automatically closed due to inactivity.
|
||||
|
||||
If you'd like to continue working on this, please reopen the PR or submit a new one.
|
||||
Thank you for your contribution.
|
||||
|
||||
stale-pr-label: stale
|
||||
|
||||
exempt-issue-labels: 'priority: critical,priority: high,security,help wanted,good first issue,needs-discussion,enhancement,exempt'
|
||||
exempt-pr-labels: 'priority: critical,priority: high,security,enhancement,exempt'
|
||||
|
||||
exempt-all-milestones: true
|
||||
exempt-all-assignees: false
|
||||
exempt-draft-pr: true
|
||||
ascending: true
|
||||
remove-stale-when-updated: true
|
||||
debug-only: false
|
||||
@@ -0,0 +1,29 @@
|
||||
# Ensures labels referenced by exempt-creators / stale workflows exist.
|
||||
# Optional per #1532 — run workflow_dispatch if labels are missing.
|
||||
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/sync-labels.yml
|
||||
---
|
||||
name: Sync labels
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- '.github/labels.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Sync labels
|
||||
uses: micnncim/action-label-syncer@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
manifest: .github/labels.yml
|
||||
prune: false
|
||||
@@ -17,6 +17,6 @@ jobs:
|
||||
container:
|
||||
image: ghcr.io/flathub/flatpak-builder-lint:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- 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@v5
|
||||
- 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
|
||||
@@ -6,16 +6,41 @@ name: validate
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- Source/**
|
||||
- .github/workflows/**
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- Source/**
|
||||
- .github/workflows/**
|
||||
|
||||
jobs:
|
||||
get_version:
|
||||
runs-on: ubuntu-slim
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
steps:
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
wget "https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/Source/AppScaffolding/AppScaffolding.csproj"
|
||||
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
|
||||
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||
build:
|
||||
needs: [get_version]
|
||||
uses: ./.github/workflows/build.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
libation-version: ${{ needs.get_version.outputs.version }}
|
||||
retention-days: 14
|
||||
run-unit-tests: true
|
||||
|
||||
docker:
|
||||
needs: [get_version]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
version: ${GITHUB_SHA}
|
||||
version: ${{ needs.get_version.outputs.version }}
|
||||
release: false
|
||||
secrets:
|
||||
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
+12
-1
@@ -370,4 +370,15 @@ FodyWeavers.xsd
|
||||
|
||||
/__TODO.txt
|
||||
/DataLayer/LibationContext.db
|
||||
*/bin-Avalonia
|
||||
*/bin-Avalonia
|
||||
|
||||
# macOS Directory Info
|
||||
.DS_Store
|
||||
|
||||
# JetBrains Rider Settings
|
||||
**/.idea/
|
||||
|
||||
# VitePress
|
||||
node_modules
|
||||
.vitepress/cache
|
||||
.vitepress/dist
|
||||
+5
-3
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"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",
|
||||
"LinuxAvalonia_DEB": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
|
||||
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
|
||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz",
|
||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.dmg",
|
||||
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
|
||||
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
|
||||
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz"
|
||||
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.dmg"
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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",
|
||||
head: [["link", { rel: "icon", href: "/favicon.ico" }]],
|
||||
cleanUrls: true,
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
logo: {
|
||||
light: "/libation_logo_light.svg",
|
||||
dark: "/libation_logo_dark.svg",
|
||||
},
|
||||
|
||||
footer: {
|
||||
message: "Released under the GPLv3 License",
|
||||
},
|
||||
|
||||
editLink: {
|
||||
pattern: "https://github.com/rmcrackan/Libation/edit/master/:path",
|
||||
},
|
||||
|
||||
lastUpdated: true,
|
||||
|
||||
nav: [
|
||||
{ text: "Getting Started", link: "/docs/getting-started" },
|
||||
{ text: "Docs", link: "/docs/index" },
|
||||
{
|
||||
text: "Download",
|
||||
link: "https://github.com/rmcrackan/Libation/releases/latest",
|
||||
},
|
||||
{
|
||||
text: "Issues & Requests",
|
||||
link: "https://github.com/rmcrackan/Libation/issues",
|
||||
},
|
||||
{ text: "Donate", link: "/donate" },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
items: [
|
||||
{ text: "Overview", link: "/docs/index" },
|
||||
{ text: "Getting Started", link: "/docs/getting-started" },
|
||||
{ text: "FAQ", link: "/docs/frequently-asked-questions" },
|
||||
{
|
||||
text: "Issues & Requests",
|
||||
link: "https://github.com/rmcrackan/Libation/issues",
|
||||
},
|
||||
{ text: "Donate", link: "/donate" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Installation",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Linux", link: "/docs/installation/linux" },
|
||||
{ text: "Mac", link: "/docs/installation/mac" },
|
||||
{ text: "Docker", link: "/docs/installation/docker" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Features",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
text: "Easy guide to searching",
|
||||
link: "/docs/features/lucene",
|
||||
},
|
||||
{ text: "Trash Bin", link: "/docs/features/trash-bin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Advanced",
|
||||
collapsed: false,
|
||||
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: "Spatial Audio & DRM", link: "/docs/advanced/spatial-audio" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Development",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Getting Started",
|
||||
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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
outline: {
|
||||
level: "deep",
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/rmcrackan/Libation" },
|
||||
],
|
||||
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
|
||||
// Show the external-link arrow on outbound links in markdown (nav/sidebar already do).
|
||||
externalLinkIcon: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { getDetectOS } from './download-detection.js'
|
||||
|
||||
const PREVIEW_OPTIONS = [
|
||||
{ value: '', label: 'Current system' },
|
||||
{ value: 'windows|x64', label: 'Windows (x64)' },
|
||||
{ value: 'windows|arm64', label: 'Windows (arm64)' },
|
||||
{ value: 'mac|x64', label: 'Mac (x64)' },
|
||||
{ value: 'mac|arm64', label: 'Mac (arm64)' },
|
||||
{ value: 'linux|x64|', label: 'Linux (x64)' },
|
||||
{ value: 'linux|x64|Ubuntu', label: 'Linux Ubuntu (x64)' },
|
||||
{ value: 'linux|x64|Fedora', label: 'Linux Fedora (x64)' },
|
||||
{ value: 'linux|arm64|', label: 'Linux (arm64)' },
|
||||
{ value: 'linux|arm64|Ubuntu', label: 'Linux Ubuntu (arm64)' },
|
||||
{ value: 'linux|arm64|Fedora', label: 'Linux Fedora (arm64)' },
|
||||
]
|
||||
|
||||
const INSTALL_MAC_URL = 'https://getlibation.com/docs/installation/mac'
|
||||
const INSTALL_LINUX_URL = 'https://getlibation.com/docs/installation/linux'
|
||||
|
||||
const status = ref('loading') // 'loading' | 'ready' | 'error'
|
||||
const errorMessage = ref('')
|
||||
const cachedAssets = ref([])
|
||||
const rec = ref(null)
|
||||
const blockHead = ref('')
|
||||
const previewValue = ref('')
|
||||
const installGuideUrl = ref(null)
|
||||
const installGuideLabel = ref('')
|
||||
const d = ref(null)
|
||||
|
||||
function getPreviewOverrides() {
|
||||
if (!previewValue.value) return null
|
||||
const parts = previewValue.value.split('|')
|
||||
const overrides = { os: parts[0], architecture: parts[1] }
|
||||
if (parts.length > 2 && parts[2]) overrides.linuxFlavor = parts[2]
|
||||
return overrides
|
||||
}
|
||||
|
||||
function recommendedForLabel(overrides) {
|
||||
if (!d.value) return 'Recommended download for your system'
|
||||
const os = (overrides && overrides.os) || d.value.os
|
||||
const ver = (overrides && overrides.osVersion) || d.value.osVersion
|
||||
const arch = (overrides && overrides.architecture) || d.value.architecture
|
||||
const flavor = (overrides && overrides.linuxFlavor !== undefined) ? overrides.linuxFlavor : d.value.linuxFlavor
|
||||
if (os === 'windows') return 'Recommended download for Windows (' + arch + ')'
|
||||
if (os === 'mac') return 'Recommended download for Mac ' + (ver ? ver + ' ' : '') + '(' + arch + ')'
|
||||
if (os === 'linux') return 'Recommended download for ' + (flavor ? flavor + ' ' : '') + 'Linux (' + arch + ')'
|
||||
return 'Recommended download for your system'
|
||||
}
|
||||
|
||||
function updateRecommendation() {
|
||||
if (!cachedAssets.value.length || !d.value) return
|
||||
const overrides = getPreviewOverrides()
|
||||
rec.value = d.value.recommendDownload(cachedAssets.value, overrides)
|
||||
blockHead.value = recommendedForLabel(overrides)
|
||||
const os = (overrides && overrides.os) || d.value.os
|
||||
if (os === 'mac') {
|
||||
installGuideUrl.value = INSTALL_MAC_URL
|
||||
installGuideLabel.value = 'Install on macOS'
|
||||
} else if (os === 'linux') {
|
||||
installGuideUrl.value = INSTALL_LINUX_URL
|
||||
installGuideLabel.value = 'Install on Linux'
|
||||
} else {
|
||||
installGuideUrl.value = null
|
||||
installGuideLabel.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const showReasonWarn = computed(() => {
|
||||
return rec.value?.reason && rec.value.reason !== 'Recommended for your system.'
|
||||
})
|
||||
|
||||
const showAssets = computed(() => {
|
||||
if (!rec.value || !d.value || status.value !== 'ready') return []
|
||||
const libationAssets = cachedAssets.value
|
||||
const overrides = getPreviewOverrides()
|
||||
const os = (overrides && overrides.os) || d.value.os
|
||||
const arch = (overrides && overrides.architecture) || d.value.architecture
|
||||
return libationAssets.filter((a) => {
|
||||
const p = d.value.parseAssetFilename(a.name)
|
||||
if (!p) return false
|
||||
if (os === 'windows' || os === 'mac' || os === 'linux') {
|
||||
if (p.platform !== os) return false
|
||||
}
|
||||
if (arch === 'x64' || arch === 'arm64') {
|
||||
if (p.arch !== arch) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
watch(previewValue, () => updateRecommendation())
|
||||
|
||||
onMounted(() => {
|
||||
d.value = getDetectOS()
|
||||
fetch('https://api.github.com/repos/rmcrackan/Libation/releases/latest')
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Failed to load release'))))
|
||||
.then((release) => {
|
||||
cachedAssets.value = (release.assets || []).map((a) => ({
|
||||
name: a.name,
|
||||
url: a.browser_download_url,
|
||||
}))
|
||||
status.value = 'ready'
|
||||
updateRecommendation()
|
||||
})
|
||||
.catch((err) => {
|
||||
status.value = 'error'
|
||||
errorMessage.value = err.message || 'Could not load latest release.'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="download-widget-wrapper">
|
||||
<div v-if="status === 'loading'" class="download-widget recommended">
|
||||
Loading latest release…
|
||||
</div>
|
||||
<div v-else-if="status === 'error'" class="download-widget recommended">
|
||||
<p class="warn">{{ errorMessage }}</p>
|
||||
</div>
|
||||
<div v-else class="download-widget recommended">
|
||||
<div class="block-head">
|
||||
<svg class="icon-download" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<span>{{ blockHead }}{{ rec?.windowsX64Choice ? ':' : rec?.recommended ? ':' : rec ? '.' : '' }}</span>
|
||||
<select v-model="previewValue" aria-label="Find recommended download for" class="preview-select">
|
||||
<option v-for="o in PREVIEW_OPTIONS" :key="o.value || 'current'" :value="o.value">
|
||||
{{ o.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Windows x64: Chardonnay + Classic -->
|
||||
<template v-if="rec?.windowsX64Choice">
|
||||
<ul class="widget-list">
|
||||
<li><strong>Chardonnay</strong> (modern UI): <a :href="rec.windowsX64Choice.chardonnay.url" download>{{ rec.windowsX64Choice.chardonnay.name }}</a></li>
|
||||
<li><strong>Classic</strong> (compact, screenreader-friendly): <a :href="rec.windowsX64Choice.classic.url" download>{{ rec.windowsX64Choice.classic.name }}</a></li>
|
||||
</ul>
|
||||
<p class="widget-link"><a :href="rec.classicVsChardonnayFaqUrl" target="_blank" rel="noopener">What's the difference between Classic and Chardonnay?</a></p>
|
||||
<p v-if="showReasonWarn" class="warn">{{ rec.reason }}</p>
|
||||
</template>
|
||||
|
||||
<!-- Single recommended -->
|
||||
<template v-else-if="rec?.recommended">
|
||||
<p class="widget-download"><a :href="rec.recommended.url" download>{{ rec.recommended.name }}</a></p>
|
||||
<p v-if="installGuideUrl" class="widget-link">
|
||||
<a :href="installGuideUrl" target="_blank" rel="noopener">{{ installGuideLabel }}</a>
|
||||
</p>
|
||||
<p v-if="showReasonWarn" class="warn">{{ rec.reason }}</p>
|
||||
<div v-if="rec.alternatives?.length" class="alternatives">
|
||||
{{ rec.alternatives.length === 1 ? 'Another option:' : 'Other options:' }}
|
||||
<ul>
|
||||
<li v-for="a in rec.alternatives" :key="a.name">
|
||||
<a :href="a.url" download>{{ a.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- No match -->
|
||||
<template v-else-if="rec">
|
||||
<p class="warn">{{ rec.reason || 'Could not recommend a download.' }}</p>
|
||||
<div class="alternatives">
|
||||
Available:
|
||||
<ul>
|
||||
<li v-for="a in showAssets" :key="a.name">
|
||||
<a :href="a.url" download>{{ a.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.download-widget-wrapper {
|
||||
margin: 1.5rem auto;
|
||||
max-width: 42rem;
|
||||
}
|
||||
.download-widget.recommended {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.download-widget.recommended a {
|
||||
color: var(--vp-c-brand-1);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.download-widget.recommended a:hover {
|
||||
text-decoration: underline;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.block-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.block-head .icon-download {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
.preview-select {
|
||||
margin-left: auto;
|
||||
font-size: 0.85rem;
|
||||
padding-right: 1.75rem;
|
||||
padding-left: 0.5rem;
|
||||
appearance: none;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.35rem center;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.widget-list {
|
||||
margin: 0 0 0 1rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
.widget-list li strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
.widget-download {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.widget-link {
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
.warn {
|
||||
background: var(--vp-custom-block-warning-bg);
|
||||
color: var(--vp-custom-block-warning-text);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
.alternatives {
|
||||
font-size: 0.9rem;
|
||||
font-weight: normal;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.alternatives ul {
|
||||
margin: 0.25rem 0 0 1rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, defineAsyncComponent } from 'vue'
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import { useData } from 'vitepress'
|
||||
|
||||
const { frontmatter } = useData()
|
||||
const { Layout: DefaultLayout } = DefaultTheme
|
||||
const mounted = ref(false)
|
||||
onMounted(() => { mounted.value = true })
|
||||
|
||||
// Load widget only on client so download-detection.js (navigator) never runs during SSR
|
||||
const DownloadSuggestionWidget = defineAsyncComponent(() =>
|
||||
import('./DownloadSuggestionWidget.vue')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<template #home-hero-after>
|
||||
<DownloadSuggestionWidget v-if="frontmatter.layout === 'home' && mounted" />
|
||||
</template>
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Custom styles for Libation documentation */
|
||||
|
||||
/* Naming Templates page: monospace in the doc column + outline only.
|
||||
pageClass is applied to .Layout in VitePress; limit scope to #VPContent
|
||||
so the top nav, sidebar, and site footer keep the default font. */
|
||||
.Layout.naming-templates-mono #VPContent,
|
||||
.Layout.naming-templates-mono #VPContent * {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"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 */
|
||||
/* Hide "Issues & Requests" and "Donate" links on tablet */
|
||||
.VPNav .VPNavBar .nav .VPNavBarMenu .VPMenu:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Alternative: Use a more specific selector if needed */
|
||||
.VPNavBarMenuLink[href*="issues"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Client-side OS and system detection for download recommendation.
|
||||
* Uses navigator.userAgent and (when available) navigator.userAgentData.
|
||||
* Call getDetectOS() in the browser only (e.g. in Vue onMounted).
|
||||
*/
|
||||
export function getDetectOS() {
|
||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
|
||||
const uaData = typeof navigator !== 'undefined' && navigator.userAgentData
|
||||
|
||||
function getOS() {
|
||||
if (uaData?.platform) {
|
||||
const p = uaData.platform.toLowerCase()
|
||||
if (p === 'windows') return 'windows'
|
||||
if (p === 'macos') return 'mac'
|
||||
if (p === 'linux') return 'linux'
|
||||
if (p === 'android') return 'android'
|
||||
if (p === 'ios') return 'ios'
|
||||
}
|
||||
if (/\bWindows\b/i.test(ua)) return 'windows'
|
||||
if (/\bMac\b/i.test(ua) || /\bMacintosh\b/i.test(ua)) return 'mac'
|
||||
if (/\bLinux\b/i.test(ua) && !/\bAndroid\b/i.test(ua)) return 'linux'
|
||||
if (/\bAndroid\b/i.test(ua)) return 'android'
|
||||
if (/\b(iPhone|iPad|iPod)\b/i.test(ua)) return 'ios'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function getOSVersion() {
|
||||
const os = getOS()
|
||||
if (os === 'windows') {
|
||||
const ntMatch = ua.match(/Windows NT (\d+\.\d+)/)
|
||||
if (ntMatch) {
|
||||
const nt = ntMatch[1]
|
||||
if (nt === '10.0') return '10 or 11 (NT 10.0)'
|
||||
if (nt === '6.3') return '8.1'
|
||||
if (nt === '6.2') return '8'
|
||||
if (nt === '6.1') return '7'
|
||||
return `NT ${nt}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (os === 'mac') {
|
||||
const macMatch = ua.match(/Mac OS X (\d+[._]\d+(?:[._]\d+)?)/)
|
||||
return macMatch ? macMatch[1].replace(/_/g, '.') : null
|
||||
}
|
||||
if (os === 'linux') {
|
||||
const linuxMatch = ua.match(/Linux ([^\s;)]+)/)
|
||||
return linuxMatch ? linuxMatch[1] : null
|
||||
}
|
||||
if (os === 'android') {
|
||||
const m = ua.match(/Android (\d+(?:\.\d+)*)/)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
if (os === 'ios') {
|
||||
const m = ua.match(/OS (\d+[._]\d+(?:[._]\d+)?)/)
|
||||
return m ? m[1].replace(/_/g, '.') : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getArchitecture() {
|
||||
if (uaData?.architecture) {
|
||||
const a = uaData.architecture.toLowerCase()
|
||||
if (a === 'x86' || a === 'amd64') return a === 'amd64' ? 'x64' : 'x86'
|
||||
if (a === 'arm' || a === 'arm64' || a === 'aarch64') return a === 'aarch64' ? 'arm64' : a
|
||||
return a
|
||||
}
|
||||
if (/\b(aarch64|arm64|ARM64)\b/i.test(ua)) return 'arm64'
|
||||
if (/\b(arm|aarch32)\b/i.test(ua) && !/arm64/i.test(ua)) return 'arm'
|
||||
if (/\b(Wow64|Win64|x64|x86_64|amd64)\b/i.test(ua)) return 'x64'
|
||||
if (/\b(WOW32|Win32|x86)\b/i.test(ua)) return 'x86'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function getLinuxFlavor() {
|
||||
if (getOS() !== 'linux') return null
|
||||
if (/\bUbuntu\b/i.test(ua)) return 'Ubuntu'
|
||||
if (/\bFedora\b/i.test(ua)) return 'Fedora'
|
||||
if (/\bDebian\b/i.test(ua)) return 'Debian'
|
||||
if (/\bLinux Mint\b/i.test(ua)) return 'Linux Mint'
|
||||
if (/\bOpenSUSE\b/i.test(ua)) return 'OpenSUSE'
|
||||
if (/\bArch\b/i.test(ua)) return 'Arch'
|
||||
if (/\bCentOS\b/i.test(ua)) return 'CentOS'
|
||||
if (/\bRed Hat\b/i.test(ua)) return 'Red Hat'
|
||||
if (/\bChrome OS\b/i.test(ua) || /\bCrOS\b/i.test(ua)) return 'Chrome OS'
|
||||
return null
|
||||
}
|
||||
|
||||
function parseAssetFilename(name) {
|
||||
if (!name || typeof name !== 'string') return null
|
||||
const lower = name.toLowerCase()
|
||||
let platform = null
|
||||
if (lower.includes('windows')) platform = 'windows'
|
||||
else if (lower.includes('macos') || (lower.includes('mac') && !lower.includes('macos'))) platform = 'mac'
|
||||
else if (lower.includes('linux')) platform = 'linux'
|
||||
if (!platform) return null
|
||||
let arch = null
|
||||
if (lower.includes('arm64') || lower.includes('aarch64')) arch = 'arm64'
|
||||
else if (lower.includes('amd64') || lower.includes('x64')) arch = 'x64'
|
||||
else if (lower.includes('x86') || lower.includes('i386')) arch = 'x86'
|
||||
if (!arch) return null
|
||||
let packageType = null
|
||||
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 }
|
||||
}
|
||||
|
||||
function recommendDownload(assets, overrides) {
|
||||
const os = (overrides && overrides.os != null) ? overrides.os : getOS()
|
||||
const arch = (overrides && overrides.architecture != null) ? overrides.architecture : getArchitecture()
|
||||
const linuxFlavor = (overrides && overrides.linuxFlavor !== undefined) ? overrides.linuxFlavor : getLinuxFlavor()
|
||||
let reason = ''
|
||||
if (!Array.isArray(assets) || assets.length === 0) {
|
||||
return { recommended: null, alternatives: [], reason: reason || 'No assets provided.' }
|
||||
}
|
||||
const wantArch = (arch === 'x64' || arch === 'x86') ? 'x64' : (arch === 'arm64' || arch === 'arm') ? 'arm64' : null
|
||||
const wantOs = os === 'windows' ? 'windows' : os === 'mac' ? 'mac' : os === 'linux' ? 'linux' : null
|
||||
const preferDeb = linuxFlavor === 'Ubuntu' || linuxFlavor === 'Debian' || linuxFlavor === 'Linux Mint' || linuxFlavor === 'Chrome OS'
|
||||
const preferRpm = linuxFlavor === 'Fedora' || linuxFlavor === 'Red Hat' || linuxFlavor === 'CentOS' || linuxFlavor === 'OpenSUSE'
|
||||
const parsed = []
|
||||
for (let i = 0; i < assets.length; i++) {
|
||||
const a = assets[i]
|
||||
const name = a && (a.name != null ? a.name : a)
|
||||
const url = a && a.url
|
||||
const p = parseAssetFilename(name)
|
||||
if (!p) continue
|
||||
parsed.push({ name, url, parsed: p })
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
return { recommended: null, alternatives: [], reason: reason || 'No recognizable asset filenames.' }
|
||||
}
|
||||
function score(entry) {
|
||||
const p = entry.parsed
|
||||
const nameLower = (entry.name || '').toLowerCase()
|
||||
let s = 0
|
||||
if (wantOs && p.platform === wantOs) s += 100
|
||||
else if (wantOs) s -= 50
|
||||
if (wantArch && p.arch === wantArch) s += 50
|
||||
else if (wantArch) s -= 30
|
||||
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
|
||||
}
|
||||
parsed.sort((a, b) => score(b) - score(a))
|
||||
const best = parsed[0]
|
||||
const bestScore = score(best)
|
||||
const recommended = bestScore >= 50 ? { name: best.name, url: best.url } : null
|
||||
let altCandidates = parsed.slice(1, 4)
|
||||
if (wantOs) altCandidates = altCandidates.filter(e => e.parsed.platform === wantOs)
|
||||
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
|
||||
let winClassic = null
|
||||
for (let j = 0; j < parsed.length; j++) {
|
||||
const e = parsed[j]
|
||||
if (e.parsed.platform !== 'windows' || e.parsed.arch !== 'x64') continue
|
||||
const n = (e.name || '').toLowerCase()
|
||||
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 (!recommended && wantOs) {
|
||||
reason = 'No asset matched your system (OS: ' + os + ', arch: ' + arch + '). Try choosing a download manually.'
|
||||
} else if (recommended && wantOs && !wantArch) {
|
||||
reason = 'Matched your OS; architecture could not be detected. If this download is wrong, pick another (e.g. arm64 vs x64).'
|
||||
}
|
||||
return {
|
||||
recommended,
|
||||
alternatives,
|
||||
reason: reason || (recommended ? 'Recommended for your system.' : ''),
|
||||
windowsX64Choice,
|
||||
classicVsChardonnayFaqUrl: windowsX64Choice ? classicVsChardonnayFaqUrl : null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
os: getOS(),
|
||||
osVersion: getOSVersion(),
|
||||
architecture: getArchitecture(),
|
||||
linuxFlavor: getLinuxFlavor(),
|
||||
getOS,
|
||||
getOSVersion,
|
||||
getArchitecture,
|
||||
getLinuxFlavor,
|
||||
parseAssetFilename,
|
||||
recommendDownload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import Layout from './Layout.vue'
|
||||
import './custom.css'
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout,
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project>
|
||||
<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>
|
||||
+81
-22
@@ -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
|
||||
@@ -72,48 +91,80 @@ create_db() {
|
||||
}
|
||||
|
||||
setup_db() {
|
||||
DBPATH=$1
|
||||
dbpattern="*.db"
|
||||
local DBPATH=$1
|
||||
local dbpattern="*.db"
|
||||
local FILE LINK LINK_ORIGIN
|
||||
local -a files
|
||||
|
||||
debug "using database directory ${DBPATH}"
|
||||
|
||||
# Figure out the right databse file
|
||||
if [[ -z "${LIBATION_DB_FILE}" ]];
|
||||
if [[ -z $LIBATION_DB_FILE ]]
|
||||
then
|
||||
dbCount=$(find "${DBPATH}" -maxdepth 1 -type f -name "${dbpattern}" | wc -l)
|
||||
if [ "${dbCount}" -gt 1 ];
|
||||
then
|
||||
error "too many database files found, set LIBATION_DB_FILE to the filename you wish to use"
|
||||
exit 1
|
||||
elif [ "${dbCount}" -eq 1 ];
|
||||
then
|
||||
files=( ${DBPATH}/${dbpattern} )
|
||||
FILE=${files[0]}
|
||||
else
|
||||
FILE="${DBPATH}/LibationContext.db"
|
||||
fi
|
||||
shopt -s nullglob # files shall be empty if no match is found
|
||||
files=( "${DBPATH}"/${dbpattern} )
|
||||
shopt -u nullglob
|
||||
|
||||
case ${#files[@]} in
|
||||
0)
|
||||
FILE="${DBPATH}/LibationContext.db"
|
||||
;;
|
||||
1)
|
||||
FILE="${files[0]}"
|
||||
;;
|
||||
*)
|
||||
debug "found ${#files[@]} database files matching '${dbpattern}'"
|
||||
error "too many database files found, set LIBATION_DB_FILE to the filename you wish to use"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
FILE="${DBPATH}/${LIBATION_DB_FILE}"
|
||||
fi
|
||||
|
||||
debug "planning to use database ${FILE}"
|
||||
debug "planning to use database '${FILE}'"
|
||||
|
||||
if [ -f "${FILE}" ]; then
|
||||
if [[ -f $FILE ]]
|
||||
then
|
||||
info "database found at ${FILE}"
|
||||
elif [ ${LIBATION_CREATE_DB} = "true" ];
|
||||
elif [[ $LIBATION_CREATE_DB == "true" ]]
|
||||
then
|
||||
warn "database not found, creating one at ${FILE}"
|
||||
create_db ${FILE}
|
||||
create_db "${FILE}"
|
||||
else
|
||||
error "database not found and creation is disabled"
|
||||
exit 1
|
||||
fi
|
||||
ln -s "${FILE}" "${LIBATION_CONFIG_INTERNAL}/LibationContext.db"
|
||||
|
||||
LINK="${LIBATION_CONFIG_INTERNAL}/LibationContext.db"
|
||||
if [[ -L $LINK ]]
|
||||
then
|
||||
# check if the link is correct
|
||||
LINK_ORIGIN=$(readlink -fn "$LINK")
|
||||
if [[ $LINK_ORIGIN == $(readlink -fn "$FILE") ]]
|
||||
then
|
||||
warn "symlink '${LINK_ORIGIN}' to '${LINK}' already established"
|
||||
return 0
|
||||
fi
|
||||
warn "removing existing symlink '${LINK_ORIGIN}' to '${LINK}'"
|
||||
rm -f "$LINK"
|
||||
elif [[ -e $LINK ]]
|
||||
then
|
||||
error "found blocking file at '${LINK}' - can't create symlink"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ln -s "${FILE}" "${LINK}"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -122,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
|
||||
@@ -144,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
|
||||
@@ -171,4 +230,4 @@ main() {
|
||||
info "exiting"
|
||||
}
|
||||
|
||||
main
|
||||
main "$@"
|
||||
+9
-4
@@ -1,16 +1,19 @@
|
||||
# Dockerfile
|
||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG TARGETARCH
|
||||
|
||||
COPY Source /Source
|
||||
RUN dotnet publish \
|
||||
/Source/LibationCli/LibationCli.csproj \
|
||||
--os linux \
|
||||
--arch ${TARGETARCH} \
|
||||
--configuration Release \
|
||||
--output /Source/bin/Publish/Linux-chardonnay \
|
||||
-p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
|
||||
-p:PublishProtocol=FileSystem \
|
||||
-p:PublishReadyToRun=true \
|
||||
-p:SelfContained=true
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:9.0
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0
|
||||
ARG USER_UID=1001
|
||||
ARG USER_GID=1001
|
||||
|
||||
@@ -34,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"]
|
||||
+2
-115
@@ -1,116 +1,3 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
|
||||
# Advanced: Table of Contents
|
||||
|
||||
- [Files and folders](#files-and-folders)
|
||||
- [Settings](#settings)
|
||||
- [Custom File Naming](NamingTemplates.md)
|
||||
- [Command Line Interface](#command-line-interface)
|
||||
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
|
||||
- [Audio Formats (Dolby Atmos, Widevine, Spacial Audio)](AudioFileFormats.md)
|
||||
|
||||
|
||||
|
||||
### Files and folders
|
||||
|
||||
To make upgrades and reinstalls easier, Libation separates all of its responsibilities to a few different folders. If you don't want to mess with this stuff: ignore it. Read on if you like a little more control over your files.
|
||||
|
||||
* In Libation's initial folder are the files that make up the program. Since nothing else is here, just copy new files here to upgrade the program. Delete this folder to delete Libation.
|
||||
|
||||
* In a separate folder, Libation keeps track of all of the files it creates like settings and downloaded images. After an upgrade, Libation might think that's its being run for the first time. Just click ADVANCED SETUP and point to this folder. Libation will reload your library and settings.
|
||||
|
||||
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
|
||||
|
||||
### Settings
|
||||
|
||||
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
|
||||
|
||||
In addition to the options that are enabled if you allow Libation to "fix up" the audiobook, it does the following:
|
||||
|
||||
* Adds the `TCOM` (`@wrt` in M4B files) metadata tag for the narrators.
|
||||
* Sets the `©gen` metadata tag for the genres.
|
||||
* Unescapes the copyright symbol (replace `©` with `©`)
|
||||
* Replaces the recording copyright `(P)` string with `℗`
|
||||
* Replaces the chapter markers embedded in the aax file with the chapter markers retrieved from Audible's API.
|
||||
* Sets the embedded cover art image with the 500x500 px cover art retrieved from Audible
|
||||
|
||||
### Command Line Interface
|
||||
|
||||
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
|
||||
|
||||
Warnings about relying solely on on the CLI:
|
||||
* CLI will not perform any upgrades.
|
||||
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
|
||||
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
|
||||
|
||||
```
|
||||
help
|
||||
libationcli --help
|
||||
|
||||
verb-specific help
|
||||
libationcli scan --help
|
||||
|
||||
scan all libraries
|
||||
libationcli scan
|
||||
scan only libraries for specific accounts
|
||||
libationcli scan nickname1 nickname2
|
||||
|
||||
convert all m4b files to mp3
|
||||
libationcli convert
|
||||
|
||||
liberate all books and pdfs
|
||||
libationcli liberate
|
||||
liberate pdfs only
|
||||
libationcli liberate --pdf
|
||||
libationcli liberate -p
|
||||
|
||||
Copy the local sqlite database to postgres
|
||||
libationcli copydb --connectionString "my postgres connection string"
|
||||
libationcli copydb -c "my postgres connection string"
|
||||
|
||||
export library to file
|
||||
libationcli export --path "C:\foo\bar\my.json" --json
|
||||
libationcli export -p "C:\foo\bar\my.json" -j
|
||||
libationcli export -p "C:\foo\bar\my.csv" --csv
|
||||
libationcli export -p "C:\foo\bar\my.csv" -c
|
||||
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
|
||||
libationcli export -p "C:\foo\bar\my.xlsx" -x
|
||||
|
||||
Set download statuses throughout library based on whether each book's audio file can be found.
|
||||
Must include at least one flag: --downloaded , --not-downloaded.
|
||||
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
|
||||
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
|
||||
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
|
||||
CLI: Full library. No prompt
|
||||
|
||||
libationcli set-status -d
|
||||
libationcli set-status -n
|
||||
libationcli set-status -d -n
|
||||
```
|
||||
### Custom Theme Colors
|
||||
|
||||
In Libation Chardonnay (not Classic), you may adjust the app colors using the built-in theme editor. Open the Settings window (from the menu bar: Settings > Settings). On the "Important" settings tab, click "Edit Theme Colors".
|
||||
|
||||
#### Theme Editor Window
|
||||
|
||||
The theme editor has a list of style names and their currently assigned colors. To change a style color, click on the color swatch in the left-hand column to open the color editor for that style. Observe the color changes in real-time on the built-in preview panel on the right-hand side of the theme editor.
|
||||
|
||||
You may import or export themes using the buttons at the bottom-left of the theme editor.
|
||||
"Cancel" or closing the window will revert any changes you've made in the theme editor.
|
||||
"Reset" will reset any changes you've made in the theme editor.
|
||||
"Defaults" will restore the application default colors for the active theme ("Light" or "Dark")
|
||||
"Save" will save the theme colors to the ChardonnayTheme.json file and close the editor.
|
||||
|
||||
Note: you may only edit the currently applied theme ("Light" or "Dark").
|
||||
|
||||
#### Video Walkthrough
|
||||
The below video demonstrates using the theme editor to make changes to the Dark theme color pallet.
|
||||
|
||||
[](https://github.com/user-attachments/assets/05c0cb7f-578f-4465-9691-77d694111349)
|
||||
|
||||
# This page has been moved to https://getlibation.com/docs/advanced/advanced
|
||||
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/advanced/advanced.md))
|
||||
@@ -1,104 +1,3 @@
|
||||
# Audio Formats Produced by Libation
|
||||
|
||||
Libation will download audio in a number of different audio formats, depending on the settings you choose within Libation and the per-title availability of audio formats from Audible. The Libation settings which affect the format downloaded by Libation are shown in the Settings menu screenshot below.
|
||||
|
||||
Notes:
|
||||
- Audiobook file extensions are either `.m4b` or `.mp3`. Libation uses the `.m4b` file extension for all non-MP3 files, regardless of the audio codec contained therein. Some media players don't recognize the `.m4b` file extension and may require the extension be changed to `.m4a` or `.mp4`.
|
||||
- Most (but not all) podcasts are delivered by Audible as native MP3 files. None of the following audio formats and settings discussions pertain to those podcasts because MP3s have no DRM, and those episodes are copied directly to their output folders.
|
||||
|
||||

|
||||
|
||||
## Settings Summary
|
||||
### Audio quality to request from Audible
|
||||
Audiobooks can be requested from Audible as "Normal" quality or "High" quality, matching the settings in the Audible mobile apps. This setting affects the audio bitrate and, sometimes, the number of audio channels. This setting has no effect on the _audio codec_.
|
||||
|
||||
### Use Widevine DRM
|
||||
When this setting is disabled, all audiobooks will be downloaded using Audible's in-house DRM (AAX(C)) in the [AAC-LC](#aac-lc) format.
|
||||
When this setting is enabled, Libation will request audio files protected by Google's Widevine Digital Rights Managements scheme, and two additional settings will be unlocked: [Request xHE-AAC Codec](#request-xhe-aac-codec) and [Request Spatial Audio](#request-spatial-audio) (explained further below).
|
||||
|
||||
If you don't enable either of those additional options, then enabling 'Use Widevine DRM' will have no pratcical effect in nearly all circumstances. Audiobooks will be downloaded in the same [AAC-LC](#aac-lc) format with the same bitrate and the same number of audio channels. On rare occasions, enabling 'Use Widevine DRM' without the other two options will result in audio files with a different bitrate.
|
||||
|
||||
### Request xHE-AAC Codec
|
||||
Enable this setting to request audiobooks in the [xHE-AAC](#xhe-aac) format. This codec is generally better quality than the [AAC-LC](#aac-lc) codec at the same bitrate, but it isn't as commonly supported by media players, so you may have some difficulty playing these audiobooks. The highest bitrate version of some audiobooks is only available as [xHE-AAC](#xhe-aac).
|
||||
|
||||
### Request Spatial Audio
|
||||
Enable this setting to request audiobooks in a "spatial" ([Dolby Atmos](#dolby-atmos)) audio format. If an audiobook is not available in a spatial format, it will instead be downloaded in the [xHE-AAC codec](#xhe-aac).
|
||||
|
||||
### Spatial audio codec
|
||||
Choose whether spatial audiobooks are downloaded in the [E-AC-3](#e-ac-3) or [AC-4](#ac-4) format.
|
||||
|
||||
### Download my books in the original audio format (Lossless)
|
||||
If selected, Audiobooks will be downloaded and saved in the format delivered by audible (which depends on the settings explained above). Libation will not change the audio.
|
||||
|
||||
### Download my books as .MP3 files (transcode if necessary).
|
||||
If selected, Libation will decode [AAC-LC](#aac-lc), [xHE-AAC](#xhe-aac), and [E-AC-3](#e-ac-3) audiobooks and re-encode them as MP3s using the MP3 encoder settings ([read about LAME MP3 encoder settings](https://lame.sourceforge.io/lame_ui_example.php)). Note that Libation cannot convert [AC-4](#ac-4) audio to MP3.
|
||||
|
||||
# Audio Formats
|
||||
|
||||
## Traditional Mono and Stereo Formats
|
||||
|
||||
### AAC-LC
|
||||
#### _Full Name_
|
||||
Advanced Audio Coding - Low Complexity
|
||||
#### _Description_
|
||||
This is the base profile for AAC audio and has existed since AAC's initial release in 1997. It enjoys wide support on nearly every conceivable platform capable of playing digital audio, as ubiquitous as MP3.
|
||||
If Widevine support is not enabled, or if the book is not available in the more high-definition formats, Libation will download audiobooks in this format.
|
||||
|
||||
### MP3
|
||||
#### _Full Name_
|
||||
MPEG-1 Audio Layer III or MPEG-2 Audio Layer III
|
||||
#### _Description_
|
||||
An older (released in 1991) but still nearly universally supported audio codec. Its audio quality is generally worse than AAC-LC at similar bitrates. Audible delivers some podcasts in MP3 format, but no audiobooks are natively availble as MP3. Libation supports converting Audiobooks delivered in other audio formats to MP3. Note that the MP3 format supports a maximum of two audio channels, so multichannel E-AC-3 audio will be downsampled to stereo or mono (depending on the Libation's settings). [AC-4](#ac-4) cannot be converted to MP3.
|
||||
|
||||
### xHE-AAC
|
||||
#### _Full Name_
|
||||
Extended High-Efficiency Advanced Audio Coding
|
||||
#### _Description_
|
||||
This is a proprietary codec created by the [Fraunhofer Institute for Integrated Circuits IIS](https://www.iis.fraunhofer.de/en/ff/amm/broadcast-streaming/xheaac.html). It combines features of the HE-AAC v2 and the baseline USAC (Unified Speech and Audio Coding) profiles with the parts of the MPEG-D DRC Loudness Control Profile or Dynamic Range Control Profile. Therefore, USAC and xHE-AAC are not synonymous and should not be used interchangeably. A player capable of decoding USAC will not necessarily be able to decode xHE-AAC.
|
||||
|
||||
xHE-AAC boasts significantly higher quality audio at low bitrates. Though it has existed since at least 2016, playback support is still quite limited. FFmpeg has recently added partial decoder support for the USAC profiles, but it is insufficient to decode the xHE-AAC audio files acquired from Audible (due to FFmpeg's lack of support for MPEG Surround for Mono to Stereo Upmixing; ISO 23003-3:2012 §7.11)
|
||||
|
||||
Note that the xHE-AAC files authored by Audible have some USAC conformance errors including:
|
||||
- Number of samples per frame not matching the UsacConfig coreCoderFrameLength value.
|
||||
- Disagreement between stts and UsacFrame usacIndependencyFlag value.
|
||||
- Stts indicating a frame is an immediate play-out frame, but USAC AudioPreRoll is absent.
|
||||
|
||||
## Dolby Atmos
|
||||
Atmos is a surround sound technology that expands on existing surround sound systems by adding height channels as well as free-moving sound objects. Audible delivers Dolby Atmos in two formats: E-AC-3 and AC-4.
|
||||
|
||||
Your device's ability to play audio from these formats does not necessarily mean that the audio you are hearing is Atmos (spatial). For instance, downloading the AC-4 codec for Windows ([links in the [Supported media Players](#supported-media-players) section) will enable you to play AC-4 audiobooks, but you'll still need to download [Dolby Access](https://apps.microsoft.com/detail/9n0866fs04w8?hl=en-US&gl=US) and pay $15 to enable _Dolby Atmos For Headphones_. Please refer to [this comment](https://github.com/rmcrackan/Libation/pull/1331#discussion_r2268660524) for additional context.
|
||||
|
||||
### E-AC-3
|
||||
#### _Full Name_
|
||||
Dolby Digital Plus (a.k.a Enhanced AC-3, DDP, DD+, and EC-3)
|
||||
#### _Description_
|
||||
A proprietary digital audio compression scheme developed by Dolby Digital for the transport and storage of multichannel audio. This format can be extended to add support for Atmos, making the codec _Dolby Digital Plus Atmos_. _Dolby Digital Plus Atmos_ is backwards compatible with Dolby Digital Plus, so any media player capable of playing Dolby Digital Plus can play _Dolby Digital Plus Atmos_. Audible spatial audiobooks downloaded in the E-AC-3 format are _Dolby Digital Plus Atmos_. If they are played by a media player that supports Atmos, they will play as Atmos audio. If they are played by a media player that does not support Atmos, they will be played as traditional 5.1 surround audio.
|
||||
|
||||
### AC-4
|
||||
#### _Full Name_
|
||||
Dolby AC-4
|
||||
#### _Description_
|
||||
A proprietary audio compression technology developed by Dolby Digital for the transport and storage of audio channels and/or audio objects. Audible spatial audiobooks downloaded in the AC-4 format are 2-channel AC-4 Immersive Stereo (AC4-IMS) audio, intended for playback in headphones or earbuds (though apparently [not supported on Apple devices](https://github.com/rmcrackan/Libation/issues/996#issuecomment-3169574514)).
|
||||
|
||||
# Supported Media Players
|
||||
Below is an incomplete matrix of codec support across various media players and platforms.
|
||||
| Player | [AAC-LC](#aac-lc) | [xHE-AAC](#xhe-aac) | [E-AC-3](#e-ac-3) | [AC-4](#ac-4) |
|
||||
| :--- | :---: | :---: | :---: | :---: |
|
||||
|Windows Native Support|Yes|Yes<sup>1</sup>|Yes<sup>2,3</sup>|Yes<sup>4</sup>|
|
||||
|macOS Native Support|Yes|Yes|Yes<sup>3</sup>| |
|
||||
|Android Native Support<sup>5</sup>|Yes|Yes| | |
|
||||
|FFmpeg (all platforms)|Yes|Yes<sup>6</sup>|Yes<sup>3</sup>||
|
||||
|[VLC](https://www.videolan.org/vlc/) (Windows)|Yes| |Yes<sup>3</sup> | |
|
||||
|[foobar2000](https://www.foobar2000.org/components) (Windows and Mac)|Yes|Yes<sup>7</sup> | | |
|
||||
|[PotPlayer](https://potplayer.daum.net/) (Windows)|Yes|Yes|Yes<sup>3</sup>| |
|
||||
|[Samsung Media Player](https://play.google.com/store/apps/details?id=com.sec.android.app.music)<sup>8</sup> (Samsung devices) |Yes|Yes|Yes|Yes|
|
||||
|
||||
1. Windows 11 22H2 and later
|
||||
2. On Windows [prior to Windows 11, version 24H2](https://support.microsoft.com/en-us/windows/codecs-in-media-player-d5c2cdcd-83a2-4805-abb0-c6888138e456). You can still get the codec by running the following command from a Windows PowerShell console: `winget install --id 9nvjqjbdkn97`
|
||||
3. As mentioned in the [Dolby Atmos](#dolby-atmos) section, just because a media player can play a file does not mean it's rendering Atmos. _Dolby Digital Plus Atmos_ is backwards compatible with _Dolby Digital Plus_, so media players which only support _Dolby Digital Plus_ will play E-AC-3 audio files as regular 5.1 surround without rendering the Atmos spatial qualities. Additional software or hardware support may be required for Dolby Atmos playback.
|
||||
4. You can download the AC-4 codec for Windows from 3rd party sites like [Major Geeks](https://www.majorgeeks.com/files/details/dolby_ac_3ac_4_installer.html) and [Free-Codecs](https://www.free-codecs.com/dolby-ac-4-decoder_download.htm). Once you install the codec bundle from one of those sources, the Windows store app will keep it updated. Read more about the process [in this comment](https://github.com/rmcrackan/Libation/pull/1331#discussion_r2268660524).
|
||||
5. All Android devices will support AAC-LC and xHE-AAC. Some manufactures (such as Samsung) will include Dolby codecs for playing E-AC-3 and AC-4 audio.
|
||||
6. requires FFmpeg to be [built with fdk-aac](https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_aac). You will almost certainly not find pre-build binaries in the wild due to licensing restrictions.
|
||||
7. Requires the [fdk-aac plugin](https://www.foobar2000.org/components/view/foo_pd_aac) (Windows only)
|
||||
8. Requires audio file extensions to be `.m4a` or `.mp4`. Libation sets the file extensions to `.m4b`, so you must manually change it to `.m4a` by renaming the audio file.
|
||||
# This page has been moved to https://getlibation.com/docs/features/audio-file-formats
|
||||
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/features/audio-file-formats.md))
|
||||
+2
-75
@@ -1,76 +1,3 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
# This page has been moved to https://getlibation.com/docs/installation/docker
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
> [!WARNING]
|
||||
> ## Breaking Changes
|
||||
> * The docker image now runs as user 1001 and group 1001. Make sure that the permissions on your volumes allow user 1001 to read and write to them or see the User section below for other options, or if you're not sure.
|
||||
> * `SLEEP_TIME` is now set to `-1` by default. This means the image will run once and exit. If you were relying on the previous default, you'll need to explicitly set the `SLEEP_TIME` environment variable to `30m` to replicate the previous behavior.
|
||||
> * The docker image now ignores the values in `Settings.json` for `Books` and `InProgress`. You can now change the folder that books are saved to by using the `LIBATION_BOOKS_DIR` environment variable.
|
||||
|
||||
# Disclaimer
|
||||
The docker image is provided as-is. We hope it can be useful to you but it is not officially supported.
|
||||
|
||||
### Configuration
|
||||
Configuration in Libation is handled by two files, `AccountsSettings.json` and `Settings.json`. These files can usually be found in the Libation folder in your user's home directory. The easiest way to configure these is to run the desktop version of Libation and then copy them into a folder, such as `/opt/libation/config`, that you'll volume mount into the image. `Settings.json` is technically optional, and, if not provided, Libation will run using the default settings. Additionally, the `Books` and `InProgress` settings in `Settings.json` will be ignored and the image will instead substitute it's own values.
|
||||
|
||||
### Running
|
||||
Once the configuration files are copied, the docker image can be run with the following command.
|
||||
```
|
||||
sudo docker run -d \
|
||||
-v /opt/libation/config:/config \
|
||||
-v /opt/libation/books:/data \
|
||||
--name libation \
|
||||
--restart=always \
|
||||
rmcrackan/libation:latest
|
||||
```
|
||||
|
||||
By default the container will scan for new books once and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. For example, if you pass in `10m` it will keep running, scan for new books, and download them every 10 minutes.
|
||||
|
||||
```
|
||||
sudo docker run -d \
|
||||
-v /opt/libation/config:/config \
|
||||
-v /opt/libation/books:/data \
|
||||
-e SLEEP_TIME='10m' \
|
||||
--name libation \
|
||||
--restart=always \
|
||||
rmcrackan/libation:latest
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
| Env Var | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| SLEEP_TIME | -1 | Length of time to sleep before doing another scan/download. Set to -1 to run one. |
|
||||
| LIBATION_BOOKS_DIR | /data | Folder where books will be saved |
|
||||
| LIBATION_CONFIG_DIR | /config | Folder to read configuration from. |
|
||||
| LIBATION_DB_DIR | /db | Optional folder to load database from. If not mounted, will load database from `LIBATION_CONFIG_DIR`. |
|
||||
| LIBATION_DB_FILE | | Name of database file to load. By default it will look for all `.db` files and load one if there is only one present. |
|
||||
| LIBATION_CREATE_DB | true | Whether or not the image should create a database file if none are found. |
|
||||
|
||||
### User
|
||||
This docker image runs as user `1001`. In order for the image to function properly, user `1001` must be able to read and write the volumes that are mounted in. If they are not, you will see errors, including [sqlite error](#1060), [Microsoft.Data.Sqlite.SqliteException](#1110), [unable to open database file](#1113), [Microsoft.EntityFrameworkCore.DbUpdateException](#1049)
|
||||
|
||||
If you're not sure what your user number is, check the output of the `id` command. Docker should normally run with the number of the user who configured and ran it.
|
||||
|
||||
If you want to change the user the image runs as, you can specify `-u <uid>:<gid>`. For example, to run it as user `2000` and group `3000`, you could do the following:
|
||||
|
||||
```
|
||||
sudo docker run -d \
|
||||
-u 2000:3000 \
|
||||
-v /opt/libation/config:/config \
|
||||
-v /opt/libation/books:/data \
|
||||
--name libation \
|
||||
--restart=always \
|
||||
rmcrackan/libation:latest
|
||||
```
|
||||
|
||||
If the user it's running as is correct, and it still cannot write, be sure to check whether the files and/or folders might be owned by the wrong user. You can use the `chown` command to change the owner of the file to the correct user and group number, for example: `chown -R 1001:1001 /mnt/audiobooks /mnt/libation-config`
|
||||
|
||||
### Advanced Database Options
|
||||
The docker image supports an optional database mount location defined by `LIBATION_DB_DIR`. This allows the database to be mounted as read/write, while allowing the rest of the configuration files to be mounted as read only. This is specifically useful if running in Kubernetes where you can use Configmaps and Secrets to define the configuration. If the `LIBATION_DB_DIR` is mounted, it will be used, otherwise it will look for the database in `LIBATION_CONFIG_DIR`. If it does not find the database in the expected location, it will attempt to make an empty database there.
|
||||
|
||||
### Getting help
|
||||
As mentioned above: docker is not officially supported. I'm adding this at the bottom of the page for anyone serious enough to have read this far. If you've tried everything above and would still like help, you can open an [issue](https://github.com/rmcrackan/Libation/issues). Please include `[docker]` in the title. There are also some docker folks who have offered occasional assistance who you can tag within your issue: `@ducamagnifico` , `@wtanksleyjr` , `@CLHatch`.
|
||||
|
||||
**Reminder** that these are just friendly users who are sometimes around. They're *not* our customer support.
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/installation/docker.md))
|
||||
@@ -1,56 +1,3 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
# This page has been moved to https://getlibation.com/docs/frequently-asked-questions
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
## Q: Where can I get help for my specific problem?
|
||||
|
||||
**A:** [You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
|
||||
## Q: What's the difference between 'Classic' and 'Chardonnay'?
|
||||
|
||||
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
|
||||
|
||||
* **Classic** is Windows only. Its older 'grey boxes' look has a compact design which allows for more information on the screen. Notably, Classic was written using an older, more mature technology which has built-in support for screenreaders.
|
||||
|
||||
* **Chardonnay** is available for Windows, Mac, and Linux. Its modern design has a more open look and feel.
|
||||
|
||||
## Q: Now that I've downloaded my books, how can I listen to them?
|
||||
|
||||
**A:** You can use any app which plays m4b files (or mp3 files if you used that setting). Here are just a few ideas. Disclaimer: I have no affiliation with any of these companies:
|
||||
|
||||
* iOS: [BookPlayer](https://apps.apple.com/us/app/bookplayer/id1138219998)
|
||||
* iOS: [Bound](https://apps.apple.com/us/app/bound-audiobook-player/id1041727137)
|
||||
* Android: [Smart AudioBook Player](https://play.google.com/store/apps/details?id=ak.alizandro.smartaudiobookplayer&hl=en_US&gl=US)
|
||||
* Android: [Listen](https://play.google.com/store/apps/details?id=ru.litres.android.audio&hl=en_US&gl=US)
|
||||
* Desktop: [VLC](https://www.videolan.org/)
|
||||
* Windows Desktop: [Audibly](https://github.com/rstewa/Audibly) -- a desktop player build specifically for audiobooks
|
||||
|
||||
Self-hosting online:
|
||||
|
||||
* [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/)
|
||||
* [plex](https://www.plex.tv/). Listen with [Prologue](https://prologue.audio/) (iOS)
|
||||
|
||||
## Q: I'm having trouble playing my non-spatial audiobook, how can I fix this?
|
||||
|
||||
**A:** If you enabled the [Request xHE-AAC Codec](AudioFileFormats.md#request-xhe-aac-codec) option in settings, then the audiobook is being downloaded in the [xHE-AAC codec](AudioFileFormats.md#xhe-aac) which isn't widely supported. You have two options:
|
||||
1. Use a media player which supports the xHE-AAC codec. [See an incomplete list of media players which support xHE-AAC](AudioFileFormats.md#supported-media-players).
|
||||
2. Disable the [Request xHE-AAC Codec](AudioFileFormats.md#request-xhe-aac-codec) option in settings and re-download the audiobook. This will cause Libation to download audiobooks in the [AAC-LC codec](AudioFileFormats.md#aac-lc), which enjoys near-universal media player support.
|
||||
|
||||
## Q: I'm having trouble playing my book with 4D, spatial audio, or Dolby Atmos, how can I fix this?
|
||||
|
||||
**A:** Spatial audiobooks are delivered in two formats: [E-AC-3](AudioFileFormats.md#e-ac-3) and [AC-4](AudioFileFormats.md#ac-4). [See an incomplete list of media players which support those codecs](AudioFileFormats.md#supported-media-players).
|
||||
|
||||
## Q: I'm having trouble loggin into my Brazil account.
|
||||
|
||||
**A:** For reasons known only to Jeff Bezos and God, amazon and audible brazil handle logins slightly differently. The external browser login option is not possible for Brazil. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103)
|
||||
|
||||
## Q: How do I use Libation with a South Africa account?
|
||||
|
||||
**A:** Like many countries, amazon gives South Africa it's own amazon site. [Unlike many other regions](https://www.audible.com/ep/country-selector) there is not South Africa specific audible site. Use `US` for your region -- ie: audible.com.
|
||||
|
||||
(Not exactly a *frequently* asked question but it's come up more than once)
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/frequently-asked-questions.md))
|
||||
@@ -1,155 +1,3 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
# This page has been moved to https://getlibation.com/docs/getting-started
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
|
||||
# Getting started: Table of Contents
|
||||
|
||||
- [Download Libation](#download-libation-1)
|
||||
- [Installation](#installation)
|
||||
- [Create Accounts](#create-accounts)
|
||||
- [Import your library](#import-your-library)
|
||||
- [Download your books -- DRM-free!](#download-your-books----drm-free)
|
||||
- [Download PDF attachments](#download-pdf-attachments)
|
||||
- [Details of downloaded files](#details-of-downloaded-files)
|
||||
- [Export your library](#export-your-library)
|
||||
- [I still need help](#i-still-need-help)
|
||||
|
||||
|
||||
|
||||
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
|
||||
|
||||
##### Which version? Chardonnay vs Classic
|
||||
|
||||
Nearly 100% of the difference is look and feel -- it's a matter of preference.
|
||||
|
||||
Chardonnay has an updated look and will work and look the same on Windows, Mac, and Linux.
|
||||
Classic is Windows only. It has an older look because it's built with older, duller, and more mature technology. This tech has built into it better support for things like accessibility for screen readers.
|
||||
|
||||
### Installation
|
||||
|
||||
* Windows
|
||||
|
||||
Extract the zip file to a folder and then run `Libation.exe` from inside of that folder. Do not put it in Program Files. The inability to edit files from there causes problems with configuration and updating.
|
||||
|
||||
* [Linux](InstallOnLinux.md)
|
||||
* [MacOS](InstallOnMac.md)
|
||||
|
||||
### Create Accounts
|
||||
|
||||
Create your account(s):
|
||||
|
||||

|
||||
|
||||
New locale options include many more regions including old audible accounts which pre-date the amazon acquisition
|
||||
|
||||

|
||||
|
||||
### Import your library
|
||||
|
||||
Be default, Libation will periodically scan the accounts you added above with a checkbox next to them. Nothing for you to do. You can also scan manually.
|
||||
|
||||
Select Import > Scan Library:
|
||||
|
||||

|
||||
|
||||
Or if you have multiple accounts, you'll get to choose whether to scan all accounts or just the ones you select:
|
||||
|
||||

|
||||
|
||||
If this is a new installation, or you're scanning an account you haven't scanned before, you'll be prompted to enter your password for the Audible account.
|
||||
|
||||

|
||||
|
||||
Enter the password and click Submit. Audible will prompt you with a CAPTCHA image.
|
||||
|
||||

|
||||
|
||||
Enter the CAPTCHA answer characters and click Submit. If all has gone well, Libation will start scanning the account.
|
||||
|
||||
In rare instances, the Captcha image/response will fail in an endless loop. If this happens, delete the problem account, and then click Save. Re-add the account and click Save again. Now try to scan the account again. This time, instead of typing your password, click the link that says "Or click here". This will open the Audible External Login dialog shown below.
|
||||
|
||||

|
||||
|
||||
You can either copy the URL shown and paste it into your browser or launch the browser directly by clicking Launch in Browser. Audible will display its standard login page. Login, including answering the CAPTCHA on the next page. In some cases, you might have to approve the login from the email account associated with that login, but once the login is successful, you'll see an error message.
|
||||
|
||||

|
||||
|
||||
This actually means you've successfully logged in. Copy the entire URL shown in your browser and return to Libation. Paste that URL into the text box at the bottom of the Audible External Login window and click Submit.
|
||||
|
||||
You'll see this window while it's scanning:
|
||||
|
||||

|
||||
|
||||
Success! We see how many new titles are imported:
|
||||
|
||||

|
||||
|
||||
### Download your books -- DRM-free!
|
||||
|
||||
Automatically download some or all of your audible books. This shows you how much of your library is not yet downloaded and decrypted:
|
||||
|
||||
The stoplights will tell you a title's status:
|
||||
|
||||
* Green: downloaded and decrypted
|
||||
* Yellow: downloaded but still encrypted with DRM
|
||||
* Red: not downloaded
|
||||
* PDF icon without arrow: downloaded
|
||||
* PDF with arrow: not downloaded
|
||||
|
||||
Or hover over the button to see the status.
|
||||
|
||||

|
||||
|
||||
Select Liberate > Begin Book Backups
|
||||
|
||||
You can also click on the stop light to download only that title and its PDF
|
||||
|
||||

|
||||
|
||||
First the original book with DRM is downloaded
|
||||
|
||||

|
||||
|
||||
Then it's decrypted so you can use it on any device you choose. The very first time you decrypt a book, this step will take a while. Every other book will go much faster. The first time, Libation has to figure out the special decryption key which allows your personal books to be unlocked.
|
||||
|
||||

|
||||
|
||||
And voila! If you have multiple books not yet liberated, Libation will automatically move on to the next.
|
||||
|
||||

|
||||
|
||||
The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
|
||||
|
||||
### Download PDF attachments
|
||||
|
||||
For books which include PDF downloads, Libation can download these for you as well and will attempt to store them with the book. "Book backup" will already download an available PDF. This additional option is useful when Audible adds a PDF to your book after you've already backed it up.
|
||||
|
||||
Select Liberate > Begin PDF Backups
|
||||
|
||||

|
||||
|
||||
The downloads work just like with books, only with no additional decryption needed.
|
||||
|
||||

|
||||
|
||||
### Details of downloaded files
|
||||
|
||||

|
||||
|
||||
When you set up Libation, you'll specify a Books directory. Libation looks inside that directory and all subdirectories to look for files or folders with each library book's audible id. This way, organization is completely up to you. When you download + decrypt a book, you get several files
|
||||
|
||||
* .m4b: your audiobook in m4b format. This is the most pure version of your audiobook and retains the highest quality. Now that it's decrypted, you can play it on any audio player and put it on any device. If you'd like, you can also use 3rd party tools to turn it into an mp3. The freedom to do what you want with your files was the original inspiration for Libation.
|
||||
* .cue: this is a file which logs where chapter breaks occur. Many tools are able to use this if you want to split your book into files along chapter lines.
|
||||
|
||||
### Export your library
|
||||
|
||||

|
||||
|
||||
Export your library to Excel, CSV, or JSON
|
||||
|
||||
### I still need help
|
||||
|
||||
[You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/getting-started.md))
|
||||
@@ -1,67 +1,3 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
# This page has been moved to https://getlibation.com/docs/installation/linux
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
## Packaging status
|
||||
|
||||
[](https://repology.org/project/libation/versions)
|
||||
|
||||
New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the [Libation repository's releases page](https://github.com/rmcrackan/Libation/releases).
|
||||
|
||||
Run these commands in your terminal to download and install Libation. **Make sure you replace** `X.X.X` with the latest Libation version and `ARCH` with your CPU's architechture (either `amd64` or `arm64`).
|
||||
|
||||
### Debian
|
||||
```Console
|
||||
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.deb
|
||||
sudo apt install ./libation.deb
|
||||
```
|
||||
### Redhat and CentOS
|
||||
```Console
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.rpm
|
||||
sudo yum install ./libation.rpm
|
||||
```
|
||||
### Fedora
|
||||
```Console
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.rpm
|
||||
sudo dnf5 install ./libation.rpm
|
||||
```
|
||||
---
|
||||
### Arch Linux
|
||||
```Console
|
||||
yay -S libation
|
||||
```
|
||||
This package is available on [Arch User Repository](https://aur.archlinux.org/packages/libation), install via your choice of [AUR helpers](https://wiki.archlinux.org/title/AUR_helpers).
|
||||
|
||||
Thanks to [mhdi](https://aur.archlinux.org/account/mhdi) for taking care of AUR package maintenance.
|
||||
### NixOS
|
||||
- Install via `nix-shell`
|
||||
```Console
|
||||
nix-shell -p libation
|
||||
```
|
||||
A `nix-shell` will temporarily modify your $PATH environment variable. This can be used to try a piece of software before deciding to permanently install it.
|
||||
- Install via NixOS configuration
|
||||
```Console
|
||||
environment.systemPackages = [
|
||||
pkgs.libation
|
||||
];
|
||||
```
|
||||
Add the following Nix code to your NixOS Configuration, usually located in `/etc/nixos/configuration.nix`
|
||||
- On NixOS via via `nix-env`
|
||||
```Console
|
||||
nix-env -iA nixos.libation
|
||||
```
|
||||
- On Non NixOS via `nix-env`
|
||||
```Console
|
||||
nix-env -iA nixpkgs.libation
|
||||
```
|
||||
Warning: Using `nix-env` permanently modifies a local profile of installed packages. This must be updated and maintained by the user in the same way as with a traditional package manager.
|
||||
|
||||
Thanks to [TomaSajt](https://github.com/tomasajt) for taking care of Nix package maintenance.
|
||||
|
||||
If your desktop uses gtk, you should now see Libation among your applications.
|
||||
|
||||
Additionally, you may launch Libation, LibationCli, and Hangover (the Libation recovery app) via the command line using 'libation, libationcli', and 'hangover' aliases respectively.
|
||||
|
||||
Report bugs to https://github.com/rmcrackan/Libation/issues
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/installation/linux.md))
|
||||
@@ -1,82 +1,3 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
# This page has been moved to https://getlibation.com/docs/installation/mac
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
|
||||
# Run Libation on MacOS
|
||||
This walkthrough should get you up and running with Libation on your Mac.
|
||||
|
||||
## Supports macOS 13 (Ventura) and above
|
||||
|
||||
## Install Libation
|
||||
|
||||
- Download the file from the latest release and extract it.
|
||||
- Apple Silicon (M1, M2, ...): `Libation.x.x.x-macOS-chardonnay-`**arm64**`.tgz`
|
||||
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
|
||||
- Move the extracted Libation app bundle to your applications folder.
|
||||
- Right-click on Libation and then click on open
|
||||
- The first time, it will not immediately show you an option to open it. Just dismiss the dialog and do the same thing again (right-click -> open) then you will get an option to run the unsigned application. This takes about 10 seconds.
|
||||
|
||||
## If this doesn't work
|
||||
|
||||
You can add Libation as a safe app without touching Gatekeeper.
|
||||
|
||||
- Copy/paste/run the following command. Adjust the file path to the Libation.app on your computer if necessary.
|
||||
|
||||
```Console
|
||||
xattr -r -d com.apple.quarantine ~/Downloads/Libation.app
|
||||
```
|
||||
- Close the terminal and use Libation!
|
||||
|
||||
## If this still doesn't work
|
||||
|
||||
- Copy/paste/run the following command (you'll be prompted to enter your Mac password)
|
||||
|
||||
```Console
|
||||
sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable
|
||||
```
|
||||
|
||||
* Close the terminal and use Libation!
|
||||
|
||||
## "Apple can't check app for malicious software"
|
||||
|
||||
From: [How to Open Anyway](https://support.apple.com/guide/mac-help/apple-cant-check-app-for-malicious-software-mchleab3a043/mac):
|
||||
|
||||
* On your Mac, choose Apple menu > System Settings, then click Privacy & Security in the sidebar. (You may need to scroll down.)
|
||||
* Go to Security, then click Open.
|
||||
* Click Open Anyway. This button is available for about an hour after you try to open the app.
|
||||
* Enter your login password, then click OK.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If Libation fails to start after completing the above steps, try the following:
|
||||
|
||||
1. Right-click the Libation app in your applications folder and select _Show Package Contents_
|
||||
2. Open the `Contents` folder and then the `MacOS` folder.
|
||||
3. Find the file named `Libation`, right-click it, and then select _Open_.
|
||||
|
||||
Libation _should_ launch, and you should now be able to open Libation by just double-clicking the app bundle in your applications folder.
|
||||
|
||||
|
||||
## Running Hangover
|
||||
|
||||
Libation comes with a recovery app called Hangover. You can start it by running this command:
|
||||
```Console
|
||||
open /Applications/Libation.app --args hangover
|
||||
```
|
||||
|
||||
## Running LibationCli
|
||||
|
||||
Libation comes with a command-line interface. Unfortunately, due to the way apps are sandboxed on mac, its use is somewhat limited. To open a new sandboxed terminal in LibationCli's directory, run the following command:
|
||||
```Console
|
||||
open /Applications/Libation.app --args cli
|
||||
```
|
||||
To use LibationCli from an unsandboxed terminal, you must disable gatekeeper again and run the program directly at `/Applications/Libation.app/Contents/MacOS/LibationCli`
|
||||
|
||||
Then use `./LibationCli` to execute a command.
|
||||
|
||||
## Get Libation running on Mac
|
||||
|
||||
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/219271379-a922e4e1-48a0-48e4-bd81-48aa1226a4f5.mp4)
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/installation/mac.md))
|
||||
@@ -1,64 +1,3 @@
|
||||
# Development Environment Setup using Nix or Nix Flakes on Linux x86_64
|
||||
[Nix flakes](https://nixos.wiki/wiki/Flakes) can be used to provide version controlled reproducible and cross-platform development environments. The key files are:
|
||||
- `flake.nix`: Defines the flake inputs and outputs, including development shells.
|
||||
- `shell.nix`: This file defines the dependencies and additionally adds support for the Impure `nix-shell` method. This is used by the flake to create the dev environment.
|
||||
- `flake.lock`: Locks the versions of inputs for reproducibility.
|
||||
---
|
||||
## Prerequisites
|
||||
- [Nix](https://nixos.org/download.html) the package manager or NixOs installed on Linux (x86_64-linux)
|
||||
- Optional: flakes support enabled.
|
||||
---
|
||||
## Using the Development Shell
|
||||
You have two primary ways to enter the development shell with Nix:
|
||||
### 1. Using `nix develop` (flake-native command)
|
||||
This is the recommended way if you have Nix with flakes support. Flake guarantee the versions of the dependencies and can be controlled through `flake.nix` and `flake.lock`.
|
||||
```
|
||||
nix develop
|
||||
```
|
||||
This will open a shell with all dependencies and environment configured as per the `flake.nix` for (`x86_64-linux`) systems only at this time.
|
||||
# This page has been moved to https://getlibation.com/docs/development/nix-linux-setup
|
||||
|
||||
---
|
||||
### 2. Using `nix-shell` (that's why shell.nix is a separate file)
|
||||
If you want to use traditional `nix-shell` tooling which uses the nixpkgs version of your system:
|
||||
```
|
||||
nix-shell
|
||||
```
|
||||
This will drop you into the shell environment defined in `shell.nix`. Note that this is not flake-native method and does not use the locked nixpkgs in `flake.lock` so exact versions of the dependancies is not guaranteed.
|
||||
|
||||
---
|
||||
## What’s inside the dev shell?
|
||||
- The environment variables and packages configured in `shell.nix` will be available.
|
||||
- The package set (`pkgs`) used aligns with the versions locked in `flake.lock` to ensure reproducibility.
|
||||
|
||||
---
|
||||
## Example Workflow using flakes
|
||||
```
|
||||
# Navigate to the project root folder which contains the flake.nix, flake.lock and shell.nix files.
|
||||
cd /home/user/dev/Libation
|
||||
# Enter the flake development shell (Linux x86_64)
|
||||
nix develop
|
||||
# run VSCode or VSCodium from the current shell environment
|
||||
code .
|
||||
# Run or Debug using VSCode and VSCodium using the linux Launch configuration.
|
||||
```
|
||||

|
||||
|
||||
You can also Build and run your application inside the shell.
|
||||
```
|
||||
dotnet build ./Source/LibationAvalonia/LibationAvalonia.csproj -p:TargetFrameworks=net9.0 -p:TargetFramework=net9.0 -p:RuntimeIdentifier=linux-x64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- Leaving the current shell environemnt will drop all added dependancies and you will not be able to run or debug the program unless your system has those dependancies defined globally.
|
||||
- To exit the shell environment voluntarily use `exit` inside the shell.
|
||||
- Ensure you have no conflicting `nix.conf` or `global.json` that might affect SDK versions or runtime identifiers.
|
||||
- Keep your `flake.lock` file committed to ensure builds are reproducible for all collaborators.
|
||||
|
||||
---
|
||||
## References
|
||||
|
||||
- [Nix Flakes - NixOS Wiki](https://nixos.wiki/wiki/Flakes)
|
||||
- [Nix.dev - Introduction to Nix flakes](https://nix.dev/manual/nix/2.28/command-ref/new-cli/nix3-flake-init)
|
||||
- [Nix-shell Manual](https://nixos.org/manual/nix/stable/command-ref/nix-shell.html)
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/development/nix-linux-setup.md))
|
||||
@@ -1,178 +1,3 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
|
||||
# Naming Templates
|
||||
File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options.
|
||||
|
||||
These templates apply to both GUI and CLI.
|
||||
|
||||
# Table of Contents
|
||||
|
||||
- [Template Tags](#template-tags)
|
||||
- [Property Tags](#property-tags)
|
||||
- [Conditional Tags](#conditional-tags)
|
||||
- [Tag Formatters](#tag-formatters)
|
||||
- [Text Formatters](#text-formatters)
|
||||
- [Series Formatters](#series-formatters)
|
||||
- [Series List Formatters](#series-list-formatters)
|
||||
- [Name Formatters](#name-formatters)
|
||||
- [Name List Formatters](#name-list-formatters)
|
||||
- [Number Formatters](#number-formatters)
|
||||
- [Date Formatters](#date-formatters)
|
||||
|
||||
|
||||
# Template Tags
|
||||
|
||||
These are the naming template tags currently supported by Libation.
|
||||
|
||||
## Property Tags
|
||||
These tags will be replaced in the template with the audiobook's values.
|
||||
|
||||
|Tag|Description|Type|
|
||||
|-|-|-|
|
||||
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
||||
|\<title\>|Full title with subtitle|[Text](#text-formatters)|
|
||||
|\<title short\>|Title. Stop at first colon|[Text](#text-formatters)|
|
||||
|\<audible title\>|Audible's title (does not include subtitle)|[Text](#text-formatters)|
|
||||
|\<audible subtitle\>|Audible's subtitle|[Text](#text-formatters)|
|
||||
|\<author\>|Author(s)|[Name List](#name-list-formatters)|
|
||||
|\<first author\>|First author|[Name](#name-formatters)|
|
||||
|\<narrator\>|Narrator(s)|[Name List](#name-list-formatters)|
|
||||
|\<first narrator\>|First narrator|[Name](#name-formatters)|
|
||||
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|
||||
|\<first series\>|First series|[Series](#series-formatters)|
|
||||
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|
||||
|\<bitrate\>|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)|
|
||||
|\<samplerate\>|Sample rate (Hz) of the last downloaded audiobook|[Number](#number-formatters)|
|
||||
|\<channels\>|Number of audio channels in the last downloaded audiobook|[Number](#number-formatters)|
|
||||
|\<codec\>|Audio codec of the last downloaded audiobook|[Text](#text-formatters)|
|
||||
|\<file version\>|Audible's file version number of the last downloaded audiobook|[Text](#text-formatters)|
|
||||
|\<libation version\>|Libation version used during last download of the audiobook|[Text](#text-formatters)|
|
||||
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|
||||
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|
||||
|\<locale\>|Region/country|[Text](#text-formatters)|
|
||||
|\<year\>|Year published|[Number](#number-formatters)|
|
||||
|\<language\>|Book's language|[Text](#text-formatters)|
|
||||
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
||||
|\<file date\>|File creation date/time.|[DateTime](#date-formatters)|
|
||||
|\<pub date\>|Audiobook publication date|[DateTime](#date-formatters)|
|
||||
|\<date added\>|Date the book added to your Audible account|[DateTime](#date-formatters)|
|
||||
|\<ch count\> **‡**|Number of chapters|[Number](#number-formatters)|
|
||||
|\<ch title\> **‡**|Chapter title|[Text](#text-formatters)|
|
||||
|\<ch#\> **‡**|Chapter number|[Number](#number-formatters)|
|
||||
|\<ch# 0\> **‡**|Chapter number with leading zeros|[Number](#number-formatters)|
|
||||
|
||||
**†** Does not support custom formatting
|
||||
|
||||
**‡** Only valid for Chapter Filename and Chapter Tile Metadata
|
||||
|
||||
To change how these properties are displayed, [read about custom formatters](#tag-formatters)
|
||||
|
||||
## Conditional Tags
|
||||
Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
|
||||
|
||||
|Tag|Description|Type|
|
||||
|-|-|-|
|
||||
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|
||||
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|
||||
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|
||||
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
|
||||
|\<has PROPERTY-\>...\<-has\>|Only include if the PROPERTY has a value (i.e. not null or empty)|Conditional|
|
||||
|
||||
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
|
||||
|
||||
For example, `<if podcast-><series><-if podcast>` will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
|
||||
|
||||
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a `!` symbol before the opening tag name.
|
||||
|
||||
|Inverted Tag|Description|Type|
|
||||
|-|-|-|
|
||||
|\<!if series-\>...\<-if series\>|Only include if *not* part of a book series or podcast|Conditional|
|
||||
|\<!if podcast-\>...\<-if podcast\>|Only include if *not* part of a podcast|Conditional|
|
||||
|\<!if bookseries-\>...\<-if bookseries\>|Only include if *not* part of a book series|Conditional|
|
||||
|\<!if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is *not* a podcast series parent|Conditional|
|
||||
|\<!has PROPERTY-\>...\<-has\>|Only include if the PROPERTY *does not* have a value (i.e. is null or empty)|Conditional|
|
||||
|
||||
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
|
||||
|
||||
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
|
||||
|
||||
`<if podcast->Podcasts<-if podcast><!if podcast->Books<-if podcast>\<title>`
|
||||
|
||||
This example will add a number if the `<series#\>` tag has a value:
|
||||
|
||||
`<has series#><series#><-has>`
|
||||
|
||||
This example will put non-series books in a "Standalones" folder:
|
||||
|
||||
`<!if series->Standalones/<-if series>`
|
||||
|
||||
And this example will customize the title based on whether the book has a subtitle:
|
||||
|
||||
`<audible title><has audible subtitle->-<audible subtitle><-has>`
|
||||
|
||||
# Tag Formatters
|
||||
**Text**, **Name List**, **Number**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
|
||||
|
||||
## Text Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet꞉ a sherlock holmes novel|
|
||||
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
|
||||
|
||||
## Series Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|\{N \| # \| ID\}|Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{#:[Number_Formatter](#number-formatters)\} = Number order in series, formatted<br>\{ID\} = Audible Series ID<br><br>Default is \{N\}|`<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`<hr>`<first series[{N}, {ID}, {#:00.0}]>`|Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1-6, B08376S3R2<hr>Sherlock Holmes, B08376S3R2, 01.0-06.0|
|
||||
|
||||
## Series List Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|separator()|Speficy the text used to join<br>multiple series names.<br><br>Default is ", "|`<series[separator(; )]>`|Sherlock Holmes; Some Other Series|
|
||||
|format(\{N \| # \| ID\})|Formats the series properties<br>using the name series tags.<br>See [Series Formatter Usage](#series-formatters) above.|`<series[format({N}, {#})`<br>`separator(; )]>`<hr>`<series[format({ID}-{N}, {#:00.0})]>`|Sherlock Holmes, 1-6; Book Collection, 1<hr>B08376S3R2-Sherlock Holmes, 01.0-06.0, B000000000-Book Collection, 01.0|
|
||||
|max(#)|Only use the first # of series<br><br>Default is all series|`<series[max(1)]>`|Sherlock Holmes|
|
||||
|
||||
## Name Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|\{T \| F \| M \| L \| S \| ID\}|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br>\{ID\} = Audible Contributor ID<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<first narrator[{L}, {F}]>`<hr>`<first author[{L}, {F} _{ID}_]>`|Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
|
||||
|
||||
## Name List Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|separator()|Speficy the text used to join<br>multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|
||||
|format(\{T \| F \| M \| L \| S \| ID\})|Formats the human name using<br>the name part tags.<br>See [Name Formatter Usage](#name-formatters) above.|`<author[format({L}, {F})`<br>`separator(; )]>`<hr>`<author[format({L}, {F}`<br>`_{ID}_) separator(; )]>`|Doyle, Arthur; Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
|
||||
|sort(F \| M \| L)|Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|
||||
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
|
||||
|
||||
## Number Formatters
|
||||
For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings).
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|\[integer\]|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
|
||||
|0|Replaces the zero with the corresponding digit if one<br>is present; otherwise, zero appears in the result string.|\<series#\[000.0\]\>|001.0|
|
||||
|#|Replaces the "#" symbol with the corresponding digit if one<br> is present; otherwise, no digit appears in the result string|\<series#\[00.##\]\>|01|
|
||||
|
||||
## Date Formatters
|
||||
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
|
||||
### Standard DateTime Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|s|Sortable date/time pattern.|\<file date[s]\>|2023-02-14T13:45:30|
|
||||
|Y|Year month pattern.|\<file date[Y]\>|February 2023|
|
||||
|
||||
### Custom DateTime Formatters
|
||||
You can use custom formatters to construct customized DateTime string. For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|yyyy|4-digit year|\<file date[yyyy]\>|2023|
|
||||
|yy|2-digit year|\<file date[yy]\>|23|
|
||||
|MM|2-digit month|\<file date[MM]\>|02|
|
||||
|dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14|
|
||||
|HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45|
|
||||
|
||||
# This page has been moved to https://getlibation.com/docs/features/naming-templates
|
||||
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/features/naming-templates.md))
|
||||
@@ -1,79 +1,3 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
# This page has been moved to https://getlibation.com/docs/features/searching-and-filtering
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
|
||||
# Searching and filtering: Table of Contents
|
||||
|
||||
- [Tags](#tags)
|
||||
- [Searches](#searches)
|
||||
- [Search examples](#search-examples)
|
||||
- [Filters](#filters)
|
||||
|
||||
|
||||
|
||||
### Tags
|
||||
|
||||
To add tags to a title, click the tags button
|
||||
|
||||

|
||||
|
||||
Add as many tags as you'd like. Tags are separated by a space. Each tag can contain letters, numbers, and underscores
|
||||
|
||||

|
||||
|
||||
Tags are saved non-case specific for easy search. There is one special tag "hidden" which will also grey-out the book
|
||||
|
||||

|
||||
|
||||
To edit tags, just click the button again.
|
||||
|
||||
### Searches
|
||||
|
||||
Libation's advanced searching is built on the powerful Lucene search engine. Simple searches are effortless and powerful searches are simple. To search, just type and click Filter or press enter
|
||||
|
||||
* Type anything in the search box to search common fields: title, authors, narrators, and the book's audible id
|
||||
* Use Lucene's "Query Parser Syntax" for advanced searching.
|
||||
* Easy tutorial: http://www.lucenetutorial.com/lucene-query-syntax.html
|
||||
* Full official guide: https://lucene.apache.org/core/2_9_4/queryparsersyntax.html
|
||||
* Tons of search fields, specific to audiobooks
|
||||
* Synonyms so you don't have to memorize magic words. Eg: author and author**s** will both work
|
||||
* Click [?] button for a full list of search fields and synonyms 
|
||||
* Search by tag like \[this\]
|
||||
* When tags have an underscore you can use part of the tag. This is useful for quick categories. The below examples make this more clear.
|
||||
|
||||
### Search examples
|
||||
|
||||
Search for anything with the word potter
|
||||
|
||||

|
||||
|
||||
If you only want to see Harry Potter
|
||||
|
||||

|
||||
|
||||
If you only want to see potter except for Harry Potter. You can also use "-" instead of "NOT"
|
||||
|
||||

|
||||

|
||||
|
||||
To see only books written by Neil Gaiman where he also narrates his own book. (If you don't include AND, you'll see everything written by Neil Gaiman and also all books in your library which are self-narrated.)
|
||||
|
||||

|
||||
|
||||
I tagged autobiographies as auto_bio and biographies written by someone else as bio. I can get only autobiographies with \[auto_bio\] or get both by searching \[bio\]
|
||||
|
||||
![Search example: \[bio\]](images/SearchExampleBio.png)
|
||||
![Search example: \[auto_bio\]](images/SearchExampleAutoBio.png)
|
||||
|
||||
### Filters
|
||||
|
||||
If you have a search you want to save, click Add To Quick Filters to save it in your Quick Filters list. To use it again, select it from the Quick Filters list.
|
||||
|
||||
To edit this list go to Quick Filters > Edit quick filters. Here you can re-order the list, delete filters, double-click a filter to edit it, or double-click the bottom blank box to add a new filter.
|
||||
|
||||
Check "Quick Filters > Start Libation with 1st filter Default" to have your top filter automatically applied when Libation starts. In this top example, I want to always start without these: at books I've tagged hidden, books I've tagged as free_audible_originals, and books which I have rated.
|
||||
|
||||

|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/features/searching-and-filtering.md))
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,75 +1,59 @@
|
||||
# Libation: Liberate your Library
|
||||
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
**Libation** is a free, open-source application for downloading and managing your Audible audiobooks. It decrypts your library, removes DRM, and lets you own your audiobooks forever.
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
> <a href="https://getlibation.com"><img src=".github/download-icon.svg" width="20" height="20" alt="" /></a> **[Which version should I download?](https://getlibation.com)** — get a recommended download for your system on our site.
|
||||
|
||||
## Features
|
||||
|
||||
- **Unlock Your Library**: Download and remove DRM from your audiobooks.
|
||||
- **Cross-Platform**: Fully supported on Windows, macOS, and Linux.
|
||||
- **Region Support**: Works with Audible regions US, UK, Canada, Germany, France, Australia, Japan, India, and Spain.
|
||||
- **Advanced Organization**: Search, filter, and tag your books.
|
||||
- **Fast & Efficient**: Powered by AAXClean for fast decryption without heavy dependencies like ffmpeg.
|
||||
- **Import**: Easily import your existing library, including cover art.
|
||||
|
||||
# Table of Contents
|
||||
## Getting started with Libation
|
||||
|
||||
- [Audible audiobook manager](#audible-audiobook-manager)
|
||||
- [The good](#the-good)
|
||||
- [The bad](#the-bad)
|
||||
- [The ugly](#the-ugly)
|
||||
- [Getting started](Documentation/GettingStarted.md)
|
||||
- [Download Libation](Documentation/GettingStarted.md#download-libation-1)
|
||||
- [Installation](Documentation/GettingStarted.md#installation)
|
||||
- [Create Accounts](Documentation/GettingStarted.md#create-accounts)
|
||||
- [Import your library](Documentation/GettingStarted.md#import-your-library)
|
||||
- [Download your books -- DRM-free!](Documentation/GettingStarted.md#download-your-books----drm-free)
|
||||
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
|
||||
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
|
||||
- [Export your library](Documentation/GettingStarted.md#export-your-library)
|
||||
- If you still need help, [you can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
- [Searching and filtering](Documentation/SearchingAndFiltering.md)
|
||||
- [Tags](Documentation/SearchingAndFiltering.md#tags)
|
||||
- [Searches](Documentation/SearchingAndFiltering.md#searches)
|
||||
- [Search examples](Documentation/SearchingAndFiltering.md#search-examples)
|
||||
- [Filters](Documentation/SearchingAndFiltering.md#filters)
|
||||
- [Advanced](Documentation/Advanced.md)
|
||||
- [Files and folders](Documentation/Advanced.md#files-and-folders)
|
||||
- [Settings](Documentation/Advanced.md#settings)
|
||||
- [Custom File Naming](Documentation/NamingTemplates.md)
|
||||
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
||||
- [Custom Theme Colors](Documentation/Advanced.md#custom-theme-colors) (Chardonnay Only)
|
||||
- [Audio Formats (Dolby Atmos, Widevine, Spacial Audio)](Documentation/AudioFileFormats.md)
|
||||
- [Docker](Documentation/Docker.md)
|
||||
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
|
||||
|
||||
## Getting started
|
||||
All documentation has been moved to our new site: [getlibation.com](https://getlibation.com). Or jump to the important bits:
|
||||
|
||||
* [Getting Started](https://getlibation.com/docs/getting-started)
|
||||
* [Download](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
* [Step-by-step walk-through](Documentation/GettingStarted.md)
|
||||
* [Issues, bugs, and requests](https://github.com/rmcrackan/Libation/issues)
|
||||
* [Documentation](https://getlibation.com/docs/index)
|
||||
|
||||
## Audible audiobook manager
|
||||
## Development
|
||||
|
||||
### The good
|
||||
Grab the latest release for your platform from the [Releases Page](https://github.com/rmcrackan/Libation/releases/latest).
|
||||
|
||||
* Import library from audible, including cover art
|
||||
* Download and remove DRM from all books
|
||||
* Download accompanying PDFs
|
||||
* Add tags to books for better organization
|
||||
* Powerful advanced search built on the Lucene search engine
|
||||
* Customizable saved filters for common searches
|
||||
* Open source
|
||||
* Supports most regions: US, UK, Canada, Germany, France, Australia, Japan, India, and Spain
|
||||
* Fully supported in Windows, Mac, and Linux
|
||||
## Documentation
|
||||
|
||||
<a name="theBad"/>
|
||||
Comprehensive documentation is available in the `docs` directory and on our [Documentation Site](https://getlibation.com/docs).
|
||||
|
||||
### The bad
|
||||
- [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)
|
||||
|
||||
* Large file size
|
||||
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows
|
||||
## Development & Contributing
|
||||
|
||||
### The ugly
|
||||
We welcome contributions!
|
||||
|
||||
* Documentation? Yer lookin' at it
|
||||
* This is a single-developer personal passion project. Support, response, updates, enhancements, bug fixes etc are as my free time allows
|
||||
* I have a full-time job, a life, and a finite attention span. Therefore a lot of time can potentially go by with no improvements of any kind
|
||||
- **[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.
|
||||
|
||||
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk.
|
||||
## Community & Support
|
||||
|
||||
I made this for myself and I want to share it with the great programming and audible/audiobook communities which have been so generous with their time and help.
|
||||
- **[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.
|
||||
|
||||
## License
|
||||
|
||||
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 [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.
|
||||
@@ -28,14 +28,6 @@ then
|
||||
exit
|
||||
fi
|
||||
|
||||
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
|
||||
|
||||
if ! contains "$BIN_DIR" "$ARCH"
|
||||
then
|
||||
echo "This script must be called with a Libation binaries for ${ARCH}."
|
||||
exit
|
||||
fi
|
||||
|
||||
ARCH=$(echo $ARCH | sed 's/x64/amd64/')
|
||||
|
||||
DEB_DIR=./deb
|
||||
|
||||
+95
-28
@@ -1,39 +1,38 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
BIN_DIR=$1; shift
|
||||
VERSION=$1; shift
|
||||
ARCH=$1; shift
|
||||
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
|
||||
|
||||
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
|
||||
|
||||
if ! contains "$BIN_DIR" $ARCH
|
||||
if [ "$SIGN_WITH_KEY" != "true" ]
|
||||
then
|
||||
echo "This script must be called with a Libation binaries for ${ARCH}."
|
||||
exit
|
||||
echo "::warning:: App will fail Gatekeeper verification without valid Apple Team information."
|
||||
fi
|
||||
|
||||
BUNDLE=./Libation.app
|
||||
@@ -42,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"
|
||||
|
||||
@@ -60,9 +96,9 @@ mkdir -p $BUNDLE_MACOS
|
||||
|
||||
mv "${BIN_DIR}/"* $BUNDLE_MACOS
|
||||
|
||||
if [ $? -ne 0 ]
|
||||
then echo "Error moving ${BIN_DIR} files"
|
||||
exit
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error moving ${BIN_DIR} files"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Make fileicon executable..."
|
||||
@@ -74,34 +110,65 @@ 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
|
||||
|
||||
echo "Moving background.png file..."
|
||||
mv $BUNDLE_MACOS/background.png ./background.png
|
||||
|
||||
echo "Moving background.png file..."
|
||||
mv $BUNDLE_MACOS/Libation.entitlements ./Libation.entitlements
|
||||
|
||||
PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/')
|
||||
echo "Set LSArchitecturePriority to $PLIST_ARCH"
|
||||
sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
|
||||
# Portable sed -i (BSD sed on macOS requires backup arg; use .bak then remove)
|
||||
sed -i.bak "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist && rm -f $BUNDLE_CONTENTS/Info.plist.bak
|
||||
|
||||
echo "Set CFBundleVersion to $VERSION"
|
||||
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
|
||||
|
||||
sed -i.bak "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist && rm -f $BUNDLE_CONTENTS/Info.plist.bak
|
||||
|
||||
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
|
||||
|
||||
for n in "${delfiles[@]}"
|
||||
do
|
||||
echo "Deleting $n"
|
||||
rm $BUNDLE_MACOS/$n
|
||||
done
|
||||
|
||||
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz
|
||||
DMG_FILE="Libation.${VERSION}-macOS-chardonnay-${ARCH}.dmg"
|
||||
|
||||
echo "Signing executables in: $BUNDLE"
|
||||
codesign --force --deep -s - $BUNDLE
|
||||
all_identities=$(security find-identity -v -p codesigning)
|
||||
identity=$(echo ${all_identities} | sed -n 's/.*"\(.*\)".*/\1/p')
|
||||
|
||||
echo "Creating app bundle: $APP_FILE"
|
||||
tar -czvf $APP_FILE $BUNDLE
|
||||
if [ "$SIGN_WITH_KEY" == "true" ]; then
|
||||
echo "Signing executables in: $BUNDLE"
|
||||
codesign --force --deep --timestamp --options=runtime --entitlements "./Libation.entitlements" --sign "${identity}" "$BUNDLE"
|
||||
codesign --verify --verbose "$BUNDLE"
|
||||
else
|
||||
echo "Signing with empty key: $BUNDLE"
|
||||
codesign --force --deep -s - $BUNDLE
|
||||
fi
|
||||
|
||||
mkdir bundle
|
||||
echo "moving to ./bundle/$APP_FILE"
|
||||
mv $APP_FILE ./bundle/$APP_FILE
|
||||
echo "Creating app disk image: $DMG_FILE"
|
||||
mkdir Libation
|
||||
mv $BUNDLE ./Libation/$BUNDLE
|
||||
mv Libation_DS_Store Libation/.DS_Store
|
||||
mkdir Libation/.background
|
||||
mv background.png Libation/.background/
|
||||
ln -s /Applications "./Libation/ "
|
||||
mkdir ./bundle
|
||||
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.
|
||||
# - Copy the .DS_Store from the directory and save it to Libation_DS_Store
|
||||
|
||||
rm -r $BUNDLE
|
||||
|
||||
if [ "$SIGN_WITH_KEY" == "true" ]; then
|
||||
echo "Signing $DMG_FILE"
|
||||
codesign --deep --sign "${identity}" "./bundle/$DMG_FILE"
|
||||
fi
|
||||
|
||||
echo "Done!"
|
||||
@@ -28,14 +28,6 @@ then
|
||||
exit
|
||||
fi
|
||||
|
||||
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
|
||||
|
||||
if ! contains "$BIN_DIR" "$ARCH"
|
||||
then
|
||||
echo "This script must be called with a Libation binaries for ${ARCH}."
|
||||
exit
|
||||
fi
|
||||
|
||||
BASEDIR=$(pwd)
|
||||
|
||||
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
@@ -13,7 +14,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean.Codecs" Version="2.0.2.2" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="3.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,144 +1,150 @@
|
||||
using AAXClean;
|
||||
using Mpeg4Lib;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
||||
{
|
||||
public event EventHandler<MetadataItems>? RetrievedMetadata;
|
||||
|
||||
public Mp4File? AaxFile { get; private set; }
|
||||
protected Mp4Operation? AaxConversion { get; set; }
|
||||
|
||||
protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outDirectory, cacheDirectory, dlOptions) { }
|
||||
|
||||
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
||||
public override void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
public event EventHandler<AppleTags>? RetrievedMetadata;
|
||||
|
||||
public Mp4File? AaxFile { get; private set; }
|
||||
protected Mp4Operation? AaxConversion { get; set; }
|
||||
|
||||
protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outDirectory, cacheDirectory, dlOptions) { }
|
||||
|
||||
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
||||
public override void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
base.SetCoverArt(coverArt);
|
||||
if (coverArt is not null && AaxFile?.AppleTags is not null)
|
||||
AaxFile.AppleTags.Cover = coverArt;
|
||||
}
|
||||
|
||||
public override async Task CancelAsync()
|
||||
{
|
||||
await base.CancelAsync();
|
||||
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
||||
}
|
||||
|
||||
private Mp4File Open()
|
||||
{
|
||||
if (DownloadOptions.DecryptionKeys is not KeyData[] keys || keys.Length == 0)
|
||||
throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} cannot be null or empty for a '{DownloadOptions.InputType}' file.");
|
||||
else if (DownloadOptions.InputType is FileType.Dash)
|
||||
{
|
||||
//We may have multiple keys , so use the key whose key ID matches
|
||||
//the dash files default Key ID.
|
||||
var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
|
||||
|
||||
var dash = new DashFile(InputFileStream);
|
||||
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
|
||||
|
||||
if (kidIndex == -1)
|
||||
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
|
||||
|
||||
keys[0] = keys[kidIndex];
|
||||
var keyId = keys[kidIndex].KeyPart1;
|
||||
var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2).");
|
||||
dash.SetDecryptionKey(keyId, key);
|
||||
WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}");
|
||||
return dash;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aax)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
var key = keys[0].KeyPart1;
|
||||
aax.SetDecryptionKey(keys[0].KeyPart1);
|
||||
WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}");
|
||||
return aax;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aaxc)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
var key = keys[0].KeyPart1;
|
||||
var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2).");
|
||||
aax.SetDecryptionKey(keys[0].KeyPart1, iv);
|
||||
WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}");
|
||||
return aax;
|
||||
}
|
||||
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
|
||||
|
||||
void WriteKeyFile(string contents)
|
||||
{
|
||||
var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key"));
|
||||
File.WriteAllText(keyFile, contents + Environment.NewLine);
|
||||
OnTempFileCreated(new(keyFile));
|
||||
}
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
{
|
||||
AaxFile = Open();
|
||||
|
||||
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
|
||||
|
||||
if (DownloadOptions.StripUnabridged)
|
||||
{
|
||||
AaxFile.AppleTags.Title = AaxFile.AppleTags.TitleSansUnabridged;
|
||||
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
|
||||
}
|
||||
|
||||
if (DownloadOptions.FixupFile)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddTag("©wrt", AaxFile.AppleTags.Narrator);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
|
||||
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("©", "©");
|
||||
|
||||
//Add audiobook shelf tags
|
||||
//https://github.com/advplyr/audiobookshelf/issues/1794#issuecomment-1565050213
|
||||
const string tagDomain = "com.pilabor.tone";
|
||||
|
||||
AaxFile.AppleTags.Title = DownloadOptions.Title;
|
||||
|
||||
if (DownloadOptions.Subtitle is string subtitle)
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SUBTITLE", subtitle);
|
||||
|
||||
if (DownloadOptions.Publisher is string publisher)
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PUBLISHER", publisher);
|
||||
|
||||
if (DownloadOptions.Language is string language)
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "LANGUAGE", language);
|
||||
|
||||
if (DownloadOptions.AudibleProductId is string asin)
|
||||
{
|
||||
AaxFile.AppleTags.Asin = asin;
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddTag("asin", asin);
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ASIN", asin);
|
||||
}
|
||||
|
||||
if (DownloadOptions.SeriesName is string series)
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
|
||||
|
||||
if (DownloadOptions.SeriesNumber is string part)
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part);
|
||||
}
|
||||
|
||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor);
|
||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator);
|
||||
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
||||
OnInitialized();
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
protected virtual void OnInitialized() { }
|
||||
base.SetCoverArt(coverArt);
|
||||
if (coverArt is not null && AaxFile?.MetadataItems is not null)
|
||||
AaxFile.MetadataItems.Cover = coverArt;
|
||||
}
|
||||
|
||||
public override async Task CancelAsync()
|
||||
{
|
||||
await base.CancelAsync();
|
||||
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
||||
}
|
||||
|
||||
private Mp4File Open()
|
||||
{
|
||||
if (DownloadOptions.DecryptionKeys is not KeyData[] keys || keys.Length == 0)
|
||||
throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} cannot be null or empty for a '{DownloadOptions.InputType}' file.");
|
||||
else if (DownloadOptions.InputType is FileType.Dash)
|
||||
{
|
||||
//We may have multiple keys , so use the key whose key ID matches
|
||||
//the dash files default Key ID.
|
||||
var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
|
||||
|
||||
var dash = new DashFile(InputFileStream);
|
||||
if (dash.Tenc is null)
|
||||
throw new InvalidOperationException("The DASH file does not contain 'tenc' box, indicating that it is unencrypted.");
|
||||
|
||||
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
|
||||
if (kidIndex == -1)
|
||||
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
|
||||
|
||||
keys[0] = keys[kidIndex];
|
||||
var keyId = keys[kidIndex].KeyPart1;
|
||||
var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2).");
|
||||
dash.SetDecryptionKey(keyId, key);
|
||||
WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}");
|
||||
|
||||
//Remove meta box containing DRM info
|
||||
if (DownloadOptions.FixupFile && dash.Moov.GetChild<Mpeg4Lib.Boxes.MetaBox>() is { } meta)
|
||||
dash.Moov.Children.Remove(meta);
|
||||
|
||||
return dash;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aax)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
var key = keys[0].KeyPart1;
|
||||
aax.SetDecryptionKey(keys[0].KeyPart1);
|
||||
WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}");
|
||||
return aax;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aaxc)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
var key = keys[0].KeyPart1;
|
||||
var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2).");
|
||||
aax.SetDecryptionKey(keys[0].KeyPart1, iv);
|
||||
WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}");
|
||||
return aax;
|
||||
}
|
||||
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
|
||||
|
||||
void WriteKeyFile(string contents)
|
||||
{
|
||||
var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key"));
|
||||
File.WriteAllText(keyFile, contents + Environment.NewLine);
|
||||
OnTempFileCreated(new(keyFile));
|
||||
}
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
{
|
||||
AaxFile = Open();
|
||||
|
||||
RetrievedMetadata?.Invoke(this, AaxFile.MetadataItems);
|
||||
|
||||
if (DownloadOptions.StripUnabridged)
|
||||
{
|
||||
AaxFile.MetadataItems.Title = AaxFile.MetadataItems.TitleSansUnabridged;
|
||||
AaxFile.MetadataItems.Album = AaxFile.MetadataItems.Album?.Replace(" (Unabridged)", "");
|
||||
}
|
||||
|
||||
if (DownloadOptions.FixupFile)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(AaxFile.MetadataItems.Narrator))
|
||||
AaxFile.MetadataItems.AppleListBox.EditOrAddTag("©wrt", AaxFile.MetadataItems.Narrator);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AaxFile.MetadataItems.Copyright))
|
||||
AaxFile.MetadataItems.Copyright = AaxFile.MetadataItems.Copyright.Replace("(P)", "℗").Replace("©", "©");
|
||||
|
||||
//Add audiobook shelf tags
|
||||
//https://github.com/advplyr/audiobookshelf/issues/1794#issuecomment-1565050213
|
||||
const string tagDomain = "com.pilabor.tone";
|
||||
|
||||
AaxFile.MetadataItems.Title = DownloadOptions.Title;
|
||||
|
||||
if (DownloadOptions.Subtitle is string subtitle)
|
||||
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "SUBTITLE", subtitle);
|
||||
|
||||
if (DownloadOptions.Publisher is string publisher)
|
||||
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "PUBLISHER", publisher);
|
||||
|
||||
if (DownloadOptions.Language is string language)
|
||||
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "LANGUAGE", language);
|
||||
|
||||
if (DownloadOptions.AudibleProductId is string asin)
|
||||
{
|
||||
AaxFile.MetadataItems.Asin = asin;
|
||||
AaxFile.MetadataItems.AppleListBox.EditOrAddTag("asin", asin);
|
||||
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ASIN", asin);
|
||||
}
|
||||
|
||||
if (DownloadOptions.SeriesName is string series)
|
||||
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
|
||||
|
||||
if (DownloadOptions.SeriesNumber is string part)
|
||||
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part);
|
||||
}
|
||||
|
||||
OnRetrievedTitle(AaxFile.MetadataItems.TitleSansUnabridged);
|
||||
OnRetrievedAuthors(AaxFile.MetadataItems.FirstAuthor);
|
||||
OnRetrievedNarrators(AaxFile.MetadataItems.Narrator);
|
||||
OnRetrievedCoverArt(AaxFile.MetadataItems.Cover);
|
||||
OnInitialized();
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
protected virtual void OnInitialized() { }
|
||||
}
|
||||
@@ -1,157 +1,119 @@
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using FileManager;
|
||||
using Mpeg4Lib;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
|
||||
private FileStream? workingFileStream;
|
||||
|
||||
public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outDirectory, cacheDirectory, dlOptions)
|
||||
{
|
||||
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
|
||||
private FileStream? workingFileStream;
|
||||
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
|
||||
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||
}
|
||||
|
||||
public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outDirectory, cacheDirectory, dlOptions)
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
//Finishing configuring lame encoder.
|
||||
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||
{
|
||||
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
|
||||
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||
}
|
||||
if (AaxFile is null)
|
||||
throw new InvalidOperationException($"AaxFile is null during {nameof(OnInitialized)} in {nameof(AaxcDownloadConvertBase)}.");
|
||||
if (DownloadOptions.LameConfig is null)
|
||||
throw new InvalidOperationException($"LameConfig is null during {nameof(OnInitialized)} in {nameof(DownloadOptions)}.");
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
//Finishing configuring lame encoder.
|
||||
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||
MpegUtil.ConfigureLameOptions(
|
||||
AaxFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.Downsample,
|
||||
DownloadOptions.MatchSourceBitrate,
|
||||
chapters: null);
|
||||
}
|
||||
|
||||
/*
|
||||
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
|
||||
|
||||
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
|
||||
If the chapter is shorter than 3 seconds long but still has some audio frames, those frames are combined with the following chapter and not split into a new file.
|
||||
|
||||
I also implemented file naming by chapter title. When 2 or more consecutive chapters are combined, the first of the combined chapter's title is used in the file name. For example, given an audiobook with the following chapters:
|
||||
|
||||
00:00:00 - 00:00:02 | Part 1
|
||||
00:00:02 - 00:35:00 | Chapter 1
|
||||
00:35:02 - 01:02:00 | Chapter 2
|
||||
01:02:00 - 01:02:02 | Part 2
|
||||
01:02:02 - 01:41:00 | Chapter 3
|
||||
01:41:00 - 02:05:00 | Chapter 4
|
||||
|
||||
The book will be split into the following files:
|
||||
|
||||
00:00:00 - 00:35:00 | Book - 01 - Part 1.m4b
|
||||
00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b
|
||||
01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b
|
||||
01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b
|
||||
|
||||
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
|
||||
*/
|
||||
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
{
|
||||
if (AaxFile is null) return false;
|
||||
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
||||
|
||||
// Ensure split files are at least minChapterLength in duration.
|
||||
var splitChapters = new ChapterInfo(DownloadOptions.ChapterInfo.StartOffset);
|
||||
|
||||
var runningTotal = TimeSpan.Zero;
|
||||
string title = "";
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
if (runningTotal == TimeSpan.Zero)
|
||||
title = chapters[i].Title;
|
||||
|
||||
runningTotal += chapters[i].Duration;
|
||||
|
||||
if (runningTotal >= minChapterLength)
|
||||
{
|
||||
splitChapters.AddChapter(title, runningTotal);
|
||||
runningTotal = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters));
|
||||
|
||||
if (AaxConversion.IsCompletedSuccessfully)
|
||||
await moveMoovToBeginning(AaxFile, workingFileStream?.Name);
|
||||
|
||||
return AaxConversion.IsCompletedSuccessfully;
|
||||
}
|
||||
finally
|
||||
{
|
||||
workingFileStream?.Dispose();
|
||||
FinalizeDownload();
|
||||
}
|
||||
}
|
||||
|
||||
private Mp4Operation decryptMultiAsync(Mp4File aaxFile, ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return
|
||||
DownloadOptions.OutputFormat == OutputFormat.M4b
|
||||
? aaxFile.ConvertToMultiMp4aAsync
|
||||
(
|
||||
splitChapters,
|
||||
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
|
||||
)
|
||||
: aaxFile.ConvertToMultiMp3Async
|
||||
(
|
||||
splitChapters,
|
||||
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
|
||||
DownloadOptions.LameConfig
|
||||
);
|
||||
|
||||
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
|
||||
{
|
||||
moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult();
|
||||
var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||
MultiConvertFileProperties props = new()
|
||||
{
|
||||
OutputFileName = newTempFile.FilePath,
|
||||
PartsPosition = currentChapter,
|
||||
PartsTotal = splitChapters.Count,
|
||||
Title = newSplitCallback.Chapter?.Title,
|
||||
};
|
||||
|
||||
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
|
||||
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
|
||||
newSplitCallback.TrackNumber = currentChapter;
|
||||
newSplitCallback.TrackCount = splitChapters.Count;
|
||||
|
||||
OnTempFileCreated(newTempFile with { PartProperties = props });
|
||||
}
|
||||
|
||||
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||
{
|
||||
FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName);
|
||||
return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
}
|
||||
}
|
||||
|
||||
private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename)
|
||||
{
|
||||
if (DownloadOptions.OutputFormat is OutputFormat.M4b
|
||||
&& DownloadOptions.MoveMoovToBeginning
|
||||
&& filename is not null
|
||||
&& File.Exists(filename))
|
||||
{
|
||||
return Mp4File.RelocateMoovAsync(filename);
|
||||
}
|
||||
else return Mp4Operation.FromCompleted(aaxFile);
|
||||
MpegUtil.ConfigureLameOptions(
|
||||
AaxFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.Downsample,
|
||||
DownloadOptions.MatchSourceBitrate,
|
||||
chapters: null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
{
|
||||
if (AaxFile is null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
await (AaxConversion = decryptMultiAsync(AaxFile, DownloadOptions.ChapterInfo));
|
||||
|
||||
if (AaxConversion.IsCompletedSuccessfully)
|
||||
await moveMoovToBeginning(AaxFile, workingFileStream?.Name);
|
||||
|
||||
return AaxConversion.IsCompletedSuccessfully;
|
||||
}
|
||||
finally
|
||||
{
|
||||
workingFileStream?.Dispose();
|
||||
FinalizeDownload();
|
||||
}
|
||||
}
|
||||
|
||||
private Mp4Operation decryptMultiAsync(Mp4File aaxFile, ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return
|
||||
DownloadOptions.OutputFormat == OutputFormat.M4b
|
||||
? aaxFile.ConvertToMultiMp4aAsync
|
||||
(
|
||||
splitChapters,
|
||||
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
|
||||
)
|
||||
: aaxFile.ConvertToMultiMp3Async
|
||||
(
|
||||
splitChapters,
|
||||
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
|
||||
DownloadOptions.LameConfig
|
||||
);
|
||||
|
||||
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
|
||||
{
|
||||
moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult();
|
||||
var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||
MultiConvertFileProperties props = new()
|
||||
{
|
||||
OutputFileName = newTempFile.FilePath,
|
||||
PartsPosition = currentChapter,
|
||||
PartsTotal = splitChapters.Count,
|
||||
Title = newSplitCallback.Chapter?.Title,
|
||||
};
|
||||
|
||||
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
|
||||
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
|
||||
newSplitCallback.TrackNumber = currentChapter;
|
||||
newSplitCallback.TrackCount = splitChapters.Count;
|
||||
|
||||
OnTempFileCreated(newTempFile with { PartProperties = props });
|
||||
}
|
||||
|
||||
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||
{
|
||||
FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName);
|
||||
return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
}
|
||||
}
|
||||
|
||||
private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename)
|
||||
{
|
||||
if (DownloadOptions.OutputFormat is OutputFormat.M4b
|
||||
&& DownloadOptions.MoveMoovToBeginning
|
||||
&& filename is not null
|
||||
&& File.Exists(filename))
|
||||
{
|
||||
return Mp4File.RelocateMoovAsync(filename);
|
||||
}
|
||||
else return Mp4Operation.FromCompleted(aaxFile);
|
||||
}
|
||||
}
|
||||
@@ -6,103 +6,108 @@ using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||
private readonly AverageSpeed averageSpeed = new();
|
||||
private TempFile? outputTempFile;
|
||||
|
||||
public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outDirectory, cacheDirectory, dlOptions)
|
||||
{
|
||||
private readonly AverageSpeed averageSpeed = new();
|
||||
private TempFile? outputTempFile;
|
||||
var step = 1;
|
||||
|
||||
public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outDirectory, cacheDirectory, dlOptions)
|
||||
AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}";
|
||||
AsyncSteps[$"Step {step++}: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
|
||||
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
|
||||
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
//Finishing configuring lame encoder.
|
||||
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||
{
|
||||
var step = 1;
|
||||
if (AaxFile is null)
|
||||
throw new InvalidOperationException($"AaxFile is null during {nameof(OnInitialized)} in {nameof(AaxcDownloadConvertBase)}.");
|
||||
if (DownloadOptions.LameConfig is null)
|
||||
throw new InvalidOperationException($"LameConfig is null during {nameof(OnInitialized)} in {nameof(DownloadOptions)}.");
|
||||
|
||||
AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}";
|
||||
AsyncSteps[$"Step {step++}: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
|
||||
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
|
||||
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
||||
MpegUtil.ConfigureLameOptions(
|
||||
AaxFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.Downsample,
|
||||
DownloadOptions.MatchSourceBitrate,
|
||||
DownloadOptions.ChapterInfo);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
{
|
||||
if (AaxFile is null) return false;
|
||||
outputTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||
FileUtility.SaferDelete(outputTempFile.FilePath);
|
||||
|
||||
using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
OnTempFileCreated(outputTempFile);
|
||||
|
||||
try
|
||||
{
|
||||
//Finishing configuring lame encoder.
|
||||
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||
MpegUtil.ConfigureLameOptions(
|
||||
AaxFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.Downsample,
|
||||
DownloadOptions.MatchSourceBitrate,
|
||||
DownloadOptions.ChapterInfo);
|
||||
}
|
||||
await (AaxConversion = decryptAsync(AaxFile, outputFile));
|
||||
|
||||
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
{
|
||||
if (AaxFile is null) return false;
|
||||
outputTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||
FileUtility.SaferDelete(outputTempFile.FilePath);
|
||||
|
||||
using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
OnTempFileCreated(outputTempFile);
|
||||
|
||||
try
|
||||
{
|
||||
await (AaxConversion = decryptAsync(AaxFile, outputFile));
|
||||
|
||||
return AaxConversion.IsCompletedSuccessfully;
|
||||
}
|
||||
finally
|
||||
{
|
||||
FinalizeDownload();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> Step_MoveMoov()
|
||||
{
|
||||
if (outputTempFile is null) return false;
|
||||
AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath);
|
||||
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
|
||||
await AaxConversion;
|
||||
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
|
||||
return AaxConversion.IsCompletedSuccessfully;
|
||||
}
|
||||
|
||||
private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e)
|
||||
finally
|
||||
{
|
||||
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
|
||||
|
||||
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
|
||||
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
OnDecryptProgressUpdate(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = 100 * e.FractionCompleted,
|
||||
BytesReceived = (long)(InputFileStream.Length * e.FractionCompleted),
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
});
|
||||
FinalizeDownload();
|
||||
}
|
||||
|
||||
private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile)
|
||||
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
|
||||
? aaxFile.ConvertToMp3Async
|
||||
(
|
||||
outputFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.ChapterInfo
|
||||
)
|
||||
: DownloadOptions.FixupFile
|
||||
? aaxFile.ConvertToMp4aAsync
|
||||
(
|
||||
outputFile,
|
||||
DownloadOptions.ChapterInfo
|
||||
)
|
||||
: aaxFile.ConvertToMp4aAsync(outputFile);
|
||||
}
|
||||
|
||||
private async Task<bool> Step_MoveMoov()
|
||||
{
|
||||
if (outputTempFile is null) return false;
|
||||
AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath);
|
||||
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
|
||||
await AaxConversion;
|
||||
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
|
||||
return AaxConversion.IsCompletedSuccessfully;
|
||||
}
|
||||
|
||||
private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
|
||||
|
||||
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
|
||||
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
OnDecryptProgressUpdate(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = 100 * e.FractionCompleted,
|
||||
BytesReceived = (long)(InputFileStream.Length * e.FractionCompleted),
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
});
|
||||
}
|
||||
|
||||
private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile)
|
||||
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
|
||||
? aaxFile.ConvertToMp3Async
|
||||
(
|
||||
outputFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.ChapterInfo
|
||||
)
|
||||
: DownloadOptions.FixupFile
|
||||
? aaxFile.ConvertToMp4aAsync
|
||||
(
|
||||
outputFile,
|
||||
DownloadOptions.ChapterInfo
|
||||
)
|
||||
: aaxFile.ConvertToMp4aAsync(outputFile);
|
||||
}
|
||||
@@ -6,213 +6,221 @@ using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public enum OutputFormat { M4b, Mp3 }
|
||||
|
||||
public abstract class AudiobookDownloadBase
|
||||
{
|
||||
public enum OutputFormat { M4b, Mp3 }
|
||||
public event EventHandler<string?>? RetrievedTitle;
|
||||
public event EventHandler<string?>? RetrievedAuthors;
|
||||
public event EventHandler<string?>? RetrievedNarrators;
|
||||
public event EventHandler<byte[]?>? RetrievedCoverArt;
|
||||
public event EventHandler<DownloadProgress>? DecryptProgressUpdate;
|
||||
public event EventHandler<TimeSpan>? DecryptTimeRemaining;
|
||||
public event EventHandler<TempFile>? TempFileCreated;
|
||||
|
||||
public abstract class AudiobookDownloadBase
|
||||
public bool IsCanceled { get; protected set; }
|
||||
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||
protected string OutputDirectory { get; }
|
||||
public IDownloadOptions DownloadOptions { get; }
|
||||
protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
|
||||
protected virtual long InputFilePosition
|
||||
{
|
||||
public event EventHandler<string?>? RetrievedTitle;
|
||||
public event EventHandler<string?>? RetrievedAuthors;
|
||||
public event EventHandler<string?>? RetrievedNarrators;
|
||||
public event EventHandler<byte[]?>? RetrievedCoverArt;
|
||||
public event EventHandler<DownloadProgress>? DecryptProgressUpdate;
|
||||
public event EventHandler<TimeSpan>? DecryptTimeRemaining;
|
||||
public event EventHandler<TempFile>? TempFileCreated;
|
||||
get
|
||||
{
|
||||
//Use try/catch instread of checking CanRead to avoid
|
||||
//a race with the background download completing
|
||||
//between the check and the Position call.
|
||||
try { return InputFileStream.Position; }
|
||||
catch { return InputFileStream.Length; }
|
||||
}
|
||||
}
|
||||
private bool downloadFinished;
|
||||
|
||||
public bool IsCanceled { get; protected set; }
|
||||
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||
protected string OutputDirectory { get; }
|
||||
public IDownloadOptions DownloadOptions { get; }
|
||||
protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
|
||||
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||
private bool downloadFinished;
|
||||
private NetworkFileStreamPersister? m_nfsPersister;
|
||||
private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream();
|
||||
private readonly DownloadProgress zeroProgress;
|
||||
private readonly string jsonDownloadState;
|
||||
private readonly string tempFilePath;
|
||||
|
||||
private NetworkFileStreamPersister? m_nfsPersister;
|
||||
private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream();
|
||||
private readonly DownloadProgress zeroProgress;
|
||||
private readonly string jsonDownloadState;
|
||||
private readonly string tempFilePath;
|
||||
protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
{
|
||||
OutputDirectory = ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory));
|
||||
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
||||
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
|
||||
|
||||
protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
{
|
||||
OutputDirectory = ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory));
|
||||
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
||||
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
|
||||
if (!Directory.Exists(OutputDirectory))
|
||||
Directory.CreateDirectory(OutputDirectory);
|
||||
|
||||
if (!Directory.Exists(OutputDirectory))
|
||||
Directory.CreateDirectory(OutputDirectory);
|
||||
if (!Directory.Exists(cacheDirectory))
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
|
||||
if (!Directory.Exists(cacheDirectory))
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json");
|
||||
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
||||
|
||||
jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json");
|
||||
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
||||
zeroProgress = new DownloadProgress
|
||||
{
|
||||
BytesReceived = 0,
|
||||
ProgressPercentage = 0,
|
||||
TotalBytesToReceive = 0
|
||||
};
|
||||
|
||||
zeroProgress = new DownloadProgress
|
||||
OnDecryptProgressUpdate(zeroProgress);
|
||||
}
|
||||
|
||||
protected TempFile GetNewTempFilePath(string extension)
|
||||
{
|
||||
extension = FileUtility.GetStandardizedExtension(extension);
|
||||
var path = Path.Combine(OutputDirectory, Guid.NewGuid().ToString("N") + extension);
|
||||
return new(path, extension);
|
||||
}
|
||||
|
||||
public async Task<bool> RunAsync()
|
||||
{
|
||||
await InputFileStream.BeginDownloadingAsync();
|
||||
var progressTask = Task.Run(reportProgress);
|
||||
|
||||
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
||||
|
||||
//Stop the downloader so it doesn't keep running in the background.
|
||||
if (!success)
|
||||
NfsPersister.Dispose();
|
||||
|
||||
await progressTask;
|
||||
|
||||
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
||||
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
||||
|
||||
NfsPersister.Dispose();
|
||||
return success;
|
||||
|
||||
async Task reportProgress()
|
||||
{
|
||||
AverageSpeed averageSpeed = new();
|
||||
|
||||
while (
|
||||
InputFileStream.CanRead
|
||||
&& InputFileStream.Length > InputFilePosition
|
||||
&& !InputFileStream.IsCancelled
|
||||
&& !downloadFinished)
|
||||
{
|
||||
BytesReceived = 0,
|
||||
ProgressPercentage = 0,
|
||||
TotalBytesToReceive = 0
|
||||
};
|
||||
averageSpeed.AddPosition(InputFilePosition);
|
||||
|
||||
var estSecsRemaining = (InputFileStream.Length - InputFilePosition) / averageSpeed.Average;
|
||||
|
||||
if (double.IsNormal(estSecsRemaining))
|
||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining));
|
||||
|
||||
var progressPercent = 100d * InputFilePosition / InputFileStream.Length;
|
||||
|
||||
OnDecryptProgressUpdate(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = progressPercent,
|
||||
BytesReceived = InputFilePosition,
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
});
|
||||
|
||||
await Task.Delay(200);
|
||||
}
|
||||
|
||||
OnDecryptTimeRemaining(TimeSpan.Zero);
|
||||
OnDecryptProgressUpdate(zeroProgress);
|
||||
}
|
||||
}
|
||||
|
||||
protected TempFile GetNewTempFilePath(string extension)
|
||||
public virtual Task CancelAsync()
|
||||
{
|
||||
IsCanceled = true;
|
||||
FinalizeDownload();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
||||
|
||||
public virtual void SetCoverArt(byte[] coverArt) { }
|
||||
protected void OnRetrievedTitle(string? title)
|
||||
=> RetrievedTitle?.Invoke(this, title);
|
||||
protected void OnRetrievedAuthors(string? authors)
|
||||
=> RetrievedAuthors?.Invoke(this, authors);
|
||||
protected void OnRetrievedNarrators(string? narrators)
|
||||
=> RetrievedNarrators?.Invoke(this, narrators);
|
||||
protected void OnRetrievedCoverArt(byte[]? coverArt)
|
||||
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
||||
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
||||
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
||||
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
||||
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
||||
public void OnTempFileCreated(TempFile path)
|
||||
=> TempFileCreated?.Invoke(this, path);
|
||||
|
||||
protected virtual void FinalizeDownload()
|
||||
{
|
||||
NfsPersister.Dispose();
|
||||
downloadFinished = true;
|
||||
}
|
||||
|
||||
protected async Task<bool> Step_CreateCueAsync()
|
||||
{
|
||||
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
|
||||
|
||||
if (DownloadOptions.ChapterInfo.Count <= 1)
|
||||
{
|
||||
extension = FileUtility.GetStandardizedExtension(extension);
|
||||
var path = Path.Combine(OutputDirectory, Guid.NewGuid().ToString("N") + extension);
|
||||
return new(path, extension);
|
||||
}
|
||||
|
||||
public async Task<bool> RunAsync()
|
||||
{
|
||||
await InputFileStream.BeginDownloadingAsync();
|
||||
var progressTask = Task.Run(reportProgress);
|
||||
|
||||
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
||||
|
||||
//Stop the downloader so it doesn't keep running in the background.
|
||||
if (!success)
|
||||
NfsPersister.Dispose();
|
||||
|
||||
await progressTask;
|
||||
|
||||
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
||||
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
||||
|
||||
NfsPersister.Dispose();
|
||||
return success;
|
||||
|
||||
async Task reportProgress()
|
||||
{
|
||||
AverageSpeed averageSpeed = new();
|
||||
|
||||
while (
|
||||
InputFileStream.CanRead
|
||||
&& InputFileStream.Length > InputFilePosition
|
||||
&& !InputFileStream.IsCancelled
|
||||
&& !downloadFinished)
|
||||
{
|
||||
averageSpeed.AddPosition(InputFilePosition);
|
||||
|
||||
var estSecsRemaining = (InputFileStream.Length - InputFilePosition) / averageSpeed.Average;
|
||||
|
||||
if (double.IsNormal(estSecsRemaining))
|
||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining));
|
||||
|
||||
var progressPercent = 100d * InputFilePosition / InputFileStream.Length;
|
||||
|
||||
OnDecryptProgressUpdate(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = progressPercent,
|
||||
BytesReceived = InputFilePosition,
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
});
|
||||
|
||||
await Task.Delay(200);
|
||||
}
|
||||
|
||||
OnDecryptTimeRemaining(TimeSpan.Zero);
|
||||
OnDecryptProgressUpdate(zeroProgress);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual Task CancelAsync()
|
||||
{
|
||||
IsCanceled = true;
|
||||
FinalizeDownload();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
||||
|
||||
public virtual void SetCoverArt(byte[] coverArt) { }
|
||||
protected void OnRetrievedTitle(string? title)
|
||||
=> RetrievedTitle?.Invoke(this, title);
|
||||
protected void OnRetrievedAuthors(string? authors)
|
||||
=> RetrievedAuthors?.Invoke(this, authors);
|
||||
protected void OnRetrievedNarrators(string? narrators)
|
||||
=> RetrievedNarrators?.Invoke(this, narrators);
|
||||
protected void OnRetrievedCoverArt(byte[]? coverArt)
|
||||
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
||||
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
||||
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
||||
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
||||
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
||||
public void OnTempFileCreated(TempFile path)
|
||||
=> TempFileCreated?.Invoke(this, path);
|
||||
|
||||
protected virtual void FinalizeDownload()
|
||||
{
|
||||
NfsPersister.Dispose();
|
||||
downloadFinished = true;
|
||||
}
|
||||
|
||||
protected async Task<bool> Step_CreateCueAsync()
|
||||
{
|
||||
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
|
||||
|
||||
if (DownloadOptions.ChapterInfo.Count <= 1)
|
||||
{
|
||||
Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters.");
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
// not a critical step. its failure should not prevent future steps from running
|
||||
try
|
||||
{
|
||||
var tempFile = GetNewTempFilePath(".cue");
|
||||
await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo));
|
||||
OnTempFileCreated(tempFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed");
|
||||
}
|
||||
Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters.");
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
private NetworkFileStreamPersister OpenNetworkFileStream()
|
||||
// not a critical step. its failure should not prevent future steps from running
|
||||
try
|
||||
{
|
||||
NetworkFileStreamPersister? nfsp = default;
|
||||
try
|
||||
{
|
||||
if (!File.Exists(jsonDownloadState))
|
||||
return nfsp = newNetworkFilePersister();
|
||||
var tempFile = GetNewTempFilePath(".cue");
|
||||
await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo));
|
||||
OnTempFileCreated(tempFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed");
|
||||
}
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
nfsp = new NetworkFileStreamPersister(jsonDownloadState);
|
||||
// The download url expires after 1 hour.
|
||||
// The new url points to the same file.
|
||||
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
|
||||
return nfsp;
|
||||
}
|
||||
catch
|
||||
{
|
||||
nfsp?.Target?.Dispose();
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
FileUtility.SaferDelete(tempFilePath);
|
||||
private NetworkFileStreamPersister OpenNetworkFileStream()
|
||||
{
|
||||
NetworkFileStreamPersister? nfsp = default;
|
||||
try
|
||||
{
|
||||
if (!File.Exists(jsonDownloadState))
|
||||
return nfsp = newNetworkFilePersister();
|
||||
}
|
||||
finally
|
||||
{
|
||||
//nfsp will only be null when an unhandled exception occurs. Let the caller handle it.
|
||||
if (nfsp is not null)
|
||||
{
|
||||
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
|
||||
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
||||
OnTempFileCreated(new(tempFilePath, DownloadOptions.InputType.ToString()));
|
||||
OnTempFileCreated(new(jsonDownloadState));
|
||||
}
|
||||
}
|
||||
|
||||
NetworkFileStreamPersister newNetworkFilePersister()
|
||||
nfsp = new NetworkFileStreamPersister(jsonDownloadState);
|
||||
// The download url expires after 1 hour.
|
||||
// The new url points to the same file.
|
||||
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
|
||||
return nfsp;
|
||||
}
|
||||
catch
|
||||
{
|
||||
nfsp?.Target?.Dispose();
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
FileUtility.SaferDelete(tempFilePath);
|
||||
return nfsp = newNetworkFilePersister();
|
||||
}
|
||||
finally
|
||||
{
|
||||
//nfsp will only be null when an unhandled exception occurs. Let the caller handle it.
|
||||
if (nfsp is not null)
|
||||
{
|
||||
var networkFileStream = new NetworkFileStream(tempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
|
||||
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
||||
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
|
||||
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
||||
OnTempFileCreated(new(tempFilePath, DownloadOptions.InputType.ToString()));
|
||||
OnTempFileCreated(new(jsonDownloadState));
|
||||
}
|
||||
}
|
||||
|
||||
NetworkFileStreamPersister newNetworkFilePersister()
|
||||
{
|
||||
var networkFileStream = new NetworkFileStream(tempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
|
||||
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,13 +66,13 @@ public static class LinqStats
|
||||
/// 2-tailed t-Distribution critical values at 75%, 80%, 85%,
|
||||
/// 90%, 95%, and 99% confidence for 1 - 201 degrees of freedom.
|
||||
/// </summary>
|
||||
private readonly static double[][] T_TABLE;
|
||||
private readonly static double[] T_Table_25 = { 2.414213562, 1.603567451, 1.422625281, 1.344397556, 1.300949037, 1.273349309, 1.254278682, 1.240318261, 1.229659173, 1.221255395, 1.214460246, 1.208852542, 1.204146242, 1.200140298, 1.196689284, 1.193685414, 1.191047107, 1.188711483, 1.186629298, 1.184761434, 1.183076432, 1.181548697, 1.180157199, 1.178884497, 1.177716003, 1.176639425, 1.175644329, 1.174721803, 1.173864189, 1.173064871, 1.1723181, 1.17161886, 1.170962753, 1.17034591, 1.169764906, 1.169216709, 1.168698615, 1.168208212, 1.167743338, 1.167302049, 1.166882595, 1.166483396, 1.166103019, 1.165740162, 1.165393644, 1.165062385, 1.164745398, 1.164441782, 1.164150707, 1.163871412, 1.163603196, 1.163345413, 1.163097467, 1.162858803, 1.162628911, 1.162407316, 1.162193577, 1.161987283, 1.161788052, 1.161595527, 1.161409375, 1.161229286, 1.161054967, 1.160886145, 1.160722566, 1.160563987, 1.160410184, 1.160260944, 1.160116066, 1.159975363, 1.159838656, 1.159705777, 1.159576569, 1.15945088, 1.15932857, 1.159209503, 1.159093552, 1.158980598, 1.158870524, 1.158763222, 1.158658589, 1.158556526, 1.15845694, 1.158359742, 1.158264847, 1.158172173, 1.158081645, 1.157993188, 1.157906731, 1.157822209, 1.157739556, 1.157658712, 1.157579617, 1.157502216, 1.157426454, 1.157352281, 1.157279646, 1.157208502, 1.157138804, 1.157070509, 1.157003573, 1.156937958, 1.156873624, 1.156810534, 1.156748653, 1.156687945, 1.156628379, 1.156569922, 1.156512543, 1.156456213, 1.156400904, 1.156346587, 1.156293237, 1.156240827, 1.156189334, 1.156138733, 1.156089001, 1.156040117, 1.155992058, 1.155944804, 1.155898335, 1.155852631, 1.155807674, 1.155763446, 1.155719928, 1.155677105, 1.155634959, 1.155593475, 1.155552637, 1.15551243, 1.155472839, 1.155433851, 1.155395452, 1.155357629, 1.155320368, 1.155283658, 1.155247486, 1.155211841, 1.15517671, 1.155142084, 1.15510795, 1.1550743, 1.155041122, 1.155008406, 1.154976144, 1.154944326, 1.154912942, 1.154881984, 1.154851443, 1.154821311, 1.15479158, 1.154762241, 1.154733287, 1.154704711, 1.154676505, 1.154648662, 1.154621175, 1.154594037, 1.154567242, 1.154540783, 1.154514654, 1.154488849, 1.154463361, 1.154438185, 1.154413316, 1.154388747, 1.154364474, 1.15434049, 1.154316792, 1.154293373, 1.154270229, 1.154247355, 1.154224746, 1.154202398, 1.154180307, 1.154158467, 1.154136875, 1.154115526, 1.154094417, 1.154073543, 1.1540529, 1.154032485, 1.154012294, 1.153992323, 1.153972568, 1.153953027, 1.153933695, 1.15391457, 1.153895647, 1.153876925, 1.153858399, 1.153840066, 1.153821925, 1.15380397, 1.153786201, 1.153768613, 1.153751204, 1.153733972, 1.153716914, 1.153700026 };
|
||||
private readonly static double[] T_Table_20 = { 3.077683537, 1.885618083, 1.637744354, 1.533206274, 1.475884049, 1.439755747, 1.414923928, 1.39681531, 1.383028738, 1.372183641, 1.363430318, 1.356217334, 1.350171289, 1.345030374, 1.340605608, 1.336757167, 1.33337939, 1.330390944, 1.327728209, 1.325340707, 1.323187874, 1.321236742, 1.31946024, 1.317835934, 1.316345073, 1.314971864, 1.313702913, 1.312526782, 1.311433647, 1.310415025, 1.309463549, 1.308572793, 1.307737124, 1.306951587, 1.306211802, 1.305513886, 1.304854381, 1.304230204, 1.303638589, 1.303077053, 1.302543359, 1.302035487, 1.301551608, 1.30109006, 1.300649332, 1.300228048, 1.299824947, 1.299438879, 1.299068785, 1.298713694, 1.298372713, 1.298045016, 1.297729843, 1.297426488, 1.2971343, 1.296852673, 1.296581044, 1.29631889, 1.296065725, 1.295821094, 1.295584571, 1.295355762, 1.295134294, 1.29491982, 1.294712013, 1.294510568, 1.294315197, 1.294125629, 1.293941609, 1.293762898, 1.293589269, 1.293420507, 1.293256413, 1.293096793, 1.292941469, 1.292790268, 1.292643029, 1.292499597, 1.292359828, 1.292223583, 1.29209073, 1.291961144, 1.291834705, 1.291711301, 1.291590824, 1.291473171, 1.291358243, 1.291245948, 1.291136195, 1.291028899, 1.290923979, 1.290821356, 1.290720956, 1.290622708, 1.290526543, 1.290432395, 1.290340202, 1.290249904, 1.290161442, 1.290074761, 1.289989809, 1.289906533, 1.289824884, 1.289744816, 1.289666283, 1.289589241, 1.289513648, 1.289439464, 1.289366649, 1.289295166, 1.289224979, 1.289156054, 1.289088355, 1.289021851, 1.28895651, 1.288892302, 1.288829199, 1.288767171, 1.288706191, 1.288646234, 1.288587273, 1.288529284, 1.288472243, 1.288416127, 1.288360913, 1.288306581, 1.288253109, 1.288200477, 1.288148665, 1.288097654, 1.288047427, 1.287997964, 1.287949248, 1.287901264, 1.287853994, 1.287807422, 1.287761534, 1.287716314, 1.287671748, 1.287627821, 1.287584521, 1.287541833, 1.287499745, 1.287458245, 1.287417319, 1.287376957, 1.287337146, 1.287297876, 1.287259135, 1.287220914, 1.2871832, 1.287145985, 1.287109259, 1.287073012, 1.287037235, 1.287001918, 1.286967053, 1.286932631, 1.286898644, 1.286865084, 1.286831942, 1.286799212, 1.286766884, 1.286734952, 1.286703409, 1.286672248, 1.286641461, 1.286611042, 1.286580985, 1.286551283, 1.286521929, 1.286492918, 1.286464244, 1.286435901, 1.286407882, 1.286380184, 1.286352799, 1.286325724, 1.286298952, 1.286272479, 1.286246299, 1.286220408, 1.286194801, 1.286169474, 1.286144421, 1.286119638, 1.286095122, 1.286070867, 1.28604687, 1.286023127, 1.285999633, 1.285976384, 1.285953377, 1.285930609, 1.285908074, 1.285885771, 1.285863694, 1.285841842, 1.285820209, 1.285798794 };
|
||||
private readonly static double[] T_Table_15 = { 4.16529977, 2.281930588, 1.924319657, 1.778192164, 1.699362566, 1.650173154, 1.616591737, 1.59222144, 1.573735785, 1.559235933, 1.547559766, 1.537956495, 1.529919606, 1.523095061, 1.517227969, 1.51213017, 1.507659754, 1.503707672, 1.500188756, 1.497035518, 1.494193795, 1.491619612, 1.489276897, 1.487135783, 1.485171326, 1.483362535, 1.481691617, 1.48014339, 1.478704821, 1.477364662, 1.47611315, 1.474941772, 1.473843072, 1.47281049, 1.471838233, 1.470921166, 1.470054719, 1.469234815, 1.468457801, 1.467720399, 1.467019655, 1.466352901, 1.465717725, 1.465111933, 1.464533534, 1.463980712, 1.463451805, 1.462945295, 1.46245979, 1.461994009, 1.461546775, 1.461117, 1.460703683, 1.460305896, 1.45992278, 1.459553538, 1.45919743, 1.458853767, 1.458521908, 1.458201256, 1.457891251, 1.457591373, 1.457301133, 1.457020074, 1.456747768, 1.45648381, 1.456227824, 1.455979454, 1.455738365, 1.455504241, 1.455276784, 1.455055715, 1.454840767, 1.45463169, 1.454428246, 1.454230212, 1.454037373, 1.453849529, 1.453666487, 1.453488066, 1.453314093, 1.453144404, 1.452978842, 1.452817259, 1.452659513, 1.452505469, 1.452354998, 1.452207977, 1.452064289, 1.451923821, 1.451786468, 1.451652126, 1.451520697, 1.451392088, 1.451266209, 1.451142973, 1.451022299, 1.450904108, 1.450788323, 1.450674871, 1.450563684, 1.450454694, 1.450347836, 1.450243048, 1.450140271, 1.450039448, 1.449940523, 1.449843444, 1.449748158, 1.449654617, 1.449562773, 1.449472581, 1.449383997, 1.449296977, 1.449211481, 1.449127468, 1.449044902, 1.448963744, 1.448883959, 1.448805513, 1.448728372, 1.448652503, 1.448577876, 1.44850446, 1.448432226, 1.448361146, 1.448291192, 1.448222337, 1.448154557, 1.448087826, 1.44802212, 1.447957415, 1.447893688, 1.447830919, 1.447769085, 1.447708165, 1.44764814, 1.44758899, 1.447530695, 1.447473238, 1.447416601, 1.447360765, 1.447305715, 1.447251433, 1.447197905, 1.447145113, 1.447093044, 1.447041682, 1.446991013, 1.446941023, 1.446891698, 1.446843026, 1.446794994, 1.446747588, 1.446700797, 1.446654609, 1.446609012, 1.446563996, 1.446519548, 1.446475659, 1.446432318, 1.446389514, 1.446347238, 1.44630548, 1.44626423, 1.44622348, 1.44618322, 1.446143442, 1.446104137, 1.446065296, 1.446026911, 1.445988975, 1.44595148, 1.445914417, 1.44587778, 1.445841561, 1.445805753, 1.445770349, 1.445735343, 1.445700727, 1.445666495, 1.445632641, 1.445599159, 1.445566042, 1.445533284, 1.445500881, 1.445468825, 1.445437112, 1.445405736, 1.445374691, 1.445343973, 1.445313576, 1.445283495, 1.445253726, 1.445224264, 1.445195103, 1.445166239, 1.445137668, 1.445109385, 1.445081387 };
|
||||
private readonly static double[] T_Table_10 = { 6.313751515, 2.91998558, 2.353363435, 2.131846786, 2.015048373, 1.943180281, 1.894578605, 1.859548038, 1.833112933, 1.812461123, 1.795884819, 1.782287556, 1.770933396, 1.761310136, 1.753050356, 1.745883676, 1.739606726, 1.734063607, 1.729132812, 1.724718243, 1.720742903, 1.717144374, 1.713871528, 1.71088208, 1.708140761, 1.70561792, 1.703288446, 1.701130934, 1.699127027, 1.697260887, 1.695518783, 1.693888748, 1.692360309, 1.690924255, 1.689572458, 1.688297714, 1.68709362, 1.68595446, 1.684875122, 1.683851013, 1.682878002, 1.681952357, 1.681070703, 1.680229977, 1.679427393, 1.678660414, 1.677926722, 1.677224196, 1.676550893, 1.675905025, 1.67528495, 1.674689154, 1.674116237, 1.673564906, 1.673033965, 1.672522303, 1.672028888, 1.671552762, 1.671093032, 1.670648865, 1.670219484, 1.669804163, 1.669402222, 1.669013025, 1.668635976, 1.668270514, 1.667916114, 1.667572281, 1.667238549, 1.666914479, 1.666599658, 1.666293696, 1.665996224, 1.665706893, 1.665425373, 1.665151353, 1.664884537, 1.664624645, 1.664371409, 1.664124579, 1.663883913, 1.663649184, 1.663420175, 1.663196679, 1.6629785, 1.662765449, 1.662557349, 1.662354029, 1.662155326, 1.661961084, 1.661771155, 1.661585397, 1.661403674, 1.661225855, 1.661051817, 1.66088144, 1.66071461, 1.660551217, 1.660391156, 1.660234326, 1.66008063, 1.659929976, 1.659782273, 1.659637437, 1.659495383, 1.659356034, 1.659219312, 1.659085144, 1.658953458, 1.658824187, 1.658697265, 1.658572629, 1.658450216, 1.658329969, 1.65821183, 1.658095744, 1.657981659, 1.657869522, 1.657759285, 1.657650899, 1.657544319, 1.657439499, 1.657336397, 1.65723497, 1.657135178, 1.657036982, 1.656940344, 1.656845226, 1.656751594, 1.656659413, 1.656568649, 1.65647927, 1.656391244, 1.656304542, 1.656219133, 1.656134988, 1.65605208, 1.655970382, 1.655889868, 1.655810511, 1.655732287, 1.655655173, 1.655579143, 1.655504177, 1.655430251, 1.655357345, 1.655285437, 1.655214506, 1.655144534, 1.6550755, 1.655007387, 1.654940175, 1.654873847, 1.654808385, 1.654743774, 1.654679996, 1.654617035, 1.654554875, 1.654493503, 1.654432901, 1.654373057, 1.654313957, 1.654255585, 1.654197929, 1.654140976, 1.654084713, 1.654029128, 1.653974208, 1.653919942, 1.653866317, 1.653813324, 1.653760949, 1.653709184, 1.653658017, 1.653607437, 1.653557435, 1.653508002, 1.653459126, 1.6534108, 1.653363013, 1.653315758, 1.653269024, 1.653222803, 1.653177088, 1.653131869, 1.653087138, 1.653042889, 1.652999113, 1.652955802, 1.652912949, 1.652870547, 1.652828589, 1.652787068, 1.652745977, 1.65270531, 1.652665059, 1.652625219, 1.652585784, 1.652546746, 1.652508101 };
|
||||
private readonly static double[] T_Table_05 = { 12.70620474, 4.30265273, 3.182446305, 2.776445105, 2.570581836, 2.446911851, 2.364624252, 2.306004135, 2.262157163, 2.228138852, 2.20098516, 2.17881283, 2.160368656, 2.144786688, 2.131449546, 2.119905299, 2.109815578, 2.10092204, 2.093024054, 2.085963447, 2.079613845, 2.073873068, 2.06865761, 2.063898562, 2.059538553, 2.055529439, 2.051830516, 2.048407142, 2.045229642, 2.042272456, 2.039513446, 2.036933343, 2.034515297, 2.032244509, 2.030107928, 2.028094001, 2.026192463, 2.024394164, 2.02269092, 2.02107539, 2.01954097, 2.018081703, 2.016692199, 2.015367574, 2.014103389, 2.012895599, 2.011740514, 2.010634758, 2.009575237, 2.008559112, 2.00758377, 2.006646805, 2.005745995, 2.004879288, 2.004044783, 2.003240719, 2.002465459, 2.001717484, 2.000995378, 2.000297822, 1.999623585, 1.998971517, 1.998340543, 1.997729654, 1.997137908, 1.996564419, 1.996008354, 1.995468931, 1.994945415, 1.994437112, 1.993943368, 1.993463567, 1.992997126, 1.992543495, 1.992102154, 1.99167261, 1.991254395, 1.990847069, 1.99045021, 1.990063421, 1.989686323, 1.989318557, 1.98895978, 1.988609667, 1.988267907, 1.987934206, 1.987608282, 1.987289865, 1.9869787, 1.986674541, 1.986377154, 1.986086317, 1.985801814, 1.985523442, 1.985251004, 1.984984312, 1.984723186, 1.984467455, 1.984216952, 1.983971519, 1.983731003, 1.983495259, 1.983264145, 1.983037526, 1.982815274, 1.982597262, 1.98238337, 1.982173483, 1.98196749, 1.981765282, 1.981566757, 1.981371815, 1.981180359, 1.980992298, 1.980807541, 1.980626002, 1.980447599, 1.980272249, 1.980099876, 1.979930405, 1.979763763, 1.979599878, 1.979438685, 1.979280117, 1.979124109, 1.978970602, 1.978819535, 1.97867085, 1.978524491, 1.978380405, 1.978238539, 1.978098842, 1.977961264, 1.977825758, 1.977692277, 1.977560777, 1.977431212, 1.977303542, 1.977177724, 1.97705372, 1.976931489, 1.976810994, 1.976692198, 1.976575066, 1.976459563, 1.976345655, 1.976233309, 1.976122494, 1.976013178, 1.975905331, 1.975798924, 1.975693928, 1.975590315, 1.975488058, 1.975387131, 1.975287508, 1.975189163, 1.975092073, 1.974996213, 1.97490156, 1.974808092, 1.974715786, 1.974624621, 1.974534576, 1.97444563, 1.974357764, 1.974270957, 1.974185191, 1.974100447, 1.974016708, 1.973933954, 1.973852169, 1.973771337, 1.97369144, 1.973612462, 1.973534388, 1.973457202, 1.973380889, 1.973305434, 1.973230823, 1.973157042, 1.973084077, 1.973011915, 1.972940542, 1.972869946, 1.972800114, 1.972731033, 1.972662692, 1.972595079, 1.972528182, 1.97246199, 1.972396491, 1.972331676, 1.972267533, 1.972204051, 1.972141222, 1.972079034, 1.972017478, 1.971956544, 1.971896224 };
|
||||
private readonly static double[] T_Table_01 = { 63.65674116, 9.924843201, 5.84090931, 4.604094871, 4.032142984, 3.707428021, 3.499483297, 3.355387331, 3.249835542, 3.169272673, 3.105806516, 3.054539589, 3.012275839, 2.976842734, 2.946712883, 2.920781622, 2.89823052, 2.878440473, 2.860934606, 2.84533971, 2.831359558, 2.818756061, 2.807335684, 2.796939505, 2.787435814, 2.778714533, 2.770682957, 2.763262455, 2.756385904, 2.749995654, 2.744041919, 2.738481482, 2.733276642, 2.728394367, 2.723805589, 2.71948463, 2.715408722, 2.711557602, 2.707913184, 2.704459267, 2.701181304, 2.698066186, 2.695102079, 2.692278266, 2.689585019, 2.687013492, 2.684555618, 2.682204027, 2.679951974, 2.677793271, 2.675722234, 2.673733631, 2.671822636, 2.669984796, 2.668215988, 2.666512398, 2.664870482, 2.663286954, 2.661758752, 2.660283029, 2.658857127, 2.657478565, 2.656145025, 2.654854337, 2.653604469, 2.652393515, 2.651219685, 2.650081299, 2.648976774, 2.647904624, 2.646863444, 2.645851913, 2.644868782, 2.643912872, 2.642983067, 2.642078313, 2.641197611, 2.640340015, 2.639504627, 2.638690596, 2.637897113, 2.63712341, 2.636368757, 2.635632458, 2.634913852, 2.634212309, 2.633527229, 2.632858038, 2.632204191, 2.631565166, 2.630940463, 2.630329608, 2.629732145, 2.629147638, 2.628575671, 2.628015844, 2.627467774, 2.626931096, 2.626405457, 2.625890521, 2.625385965, 2.624891476, 2.624406758, 2.623931523, 2.623465496, 2.623008411, 2.622560015, 2.622120061, 2.621688313, 2.621264543, 2.620848534, 2.620440073, 2.620038957, 2.619644989, 2.619257981, 2.618877749, 2.618504116, 2.618136914, 2.617775976, 2.617421145, 2.617072266, 2.616729191, 2.616391776, 2.616059883, 2.615733377, 2.615412127, 2.615096008, 2.614784899, 2.61447868, 2.614177238, 2.613880461, 2.613588242, 2.613300477, 2.613017065, 2.612737908, 2.61246291, 2.61219198, 2.611925028, 2.611661966, 2.611402711, 2.611147181, 2.610895295, 2.610646976, 2.61040215, 2.610160742, 2.609922682, 2.609687901, 2.609456331, 2.609227907, 2.609002566, 2.608780245, 2.608560883, 2.608344423, 2.608130807, 2.60791998, 2.607711886, 2.607506474, 2.607303692, 2.607103489, 2.606905817, 2.606710628, 2.606517876, 2.606327515, 2.606139501, 2.605953791, 2.605770342, 2.605589114, 2.605410067, 2.605233162, 2.605058359, 2.604885623, 2.604714916, 2.604546204, 2.60437945, 2.604214622, 2.604051686, 2.60389061, 2.603731363, 2.603573912, 2.603418229, 2.603264282, 2.603112045, 2.602961487, 2.602812582, 2.602665303, 2.602519622, 2.602375515, 2.602232955, 2.602091918, 2.60195238, 2.601814317, 2.601677705, 2.601542523, 2.601408747, 2.601276355, 2.601145327, 2.601015642, 2.600887278, 2.600760216, 2.600634436 };
|
||||
private static readonly double[][] T_TABLE;
|
||||
private static readonly double[] T_Table_25 = { 2.414213562, 1.603567451, 1.422625281, 1.344397556, 1.300949037, 1.273349309, 1.254278682, 1.240318261, 1.229659173, 1.221255395, 1.214460246, 1.208852542, 1.204146242, 1.200140298, 1.196689284, 1.193685414, 1.191047107, 1.188711483, 1.186629298, 1.184761434, 1.183076432, 1.181548697, 1.180157199, 1.178884497, 1.177716003, 1.176639425, 1.175644329, 1.174721803, 1.173864189, 1.173064871, 1.1723181, 1.17161886, 1.170962753, 1.17034591, 1.169764906, 1.169216709, 1.168698615, 1.168208212, 1.167743338, 1.167302049, 1.166882595, 1.166483396, 1.166103019, 1.165740162, 1.165393644, 1.165062385, 1.164745398, 1.164441782, 1.164150707, 1.163871412, 1.163603196, 1.163345413, 1.163097467, 1.162858803, 1.162628911, 1.162407316, 1.162193577, 1.161987283, 1.161788052, 1.161595527, 1.161409375, 1.161229286, 1.161054967, 1.160886145, 1.160722566, 1.160563987, 1.160410184, 1.160260944, 1.160116066, 1.159975363, 1.159838656, 1.159705777, 1.159576569, 1.15945088, 1.15932857, 1.159209503, 1.159093552, 1.158980598, 1.158870524, 1.158763222, 1.158658589, 1.158556526, 1.15845694, 1.158359742, 1.158264847, 1.158172173, 1.158081645, 1.157993188, 1.157906731, 1.157822209, 1.157739556, 1.157658712, 1.157579617, 1.157502216, 1.157426454, 1.157352281, 1.157279646, 1.157208502, 1.157138804, 1.157070509, 1.157003573, 1.156937958, 1.156873624, 1.156810534, 1.156748653, 1.156687945, 1.156628379, 1.156569922, 1.156512543, 1.156456213, 1.156400904, 1.156346587, 1.156293237, 1.156240827, 1.156189334, 1.156138733, 1.156089001, 1.156040117, 1.155992058, 1.155944804, 1.155898335, 1.155852631, 1.155807674, 1.155763446, 1.155719928, 1.155677105, 1.155634959, 1.155593475, 1.155552637, 1.15551243, 1.155472839, 1.155433851, 1.155395452, 1.155357629, 1.155320368, 1.155283658, 1.155247486, 1.155211841, 1.15517671, 1.155142084, 1.15510795, 1.1550743, 1.155041122, 1.155008406, 1.154976144, 1.154944326, 1.154912942, 1.154881984, 1.154851443, 1.154821311, 1.15479158, 1.154762241, 1.154733287, 1.154704711, 1.154676505, 1.154648662, 1.154621175, 1.154594037, 1.154567242, 1.154540783, 1.154514654, 1.154488849, 1.154463361, 1.154438185, 1.154413316, 1.154388747, 1.154364474, 1.15434049, 1.154316792, 1.154293373, 1.154270229, 1.154247355, 1.154224746, 1.154202398, 1.154180307, 1.154158467, 1.154136875, 1.154115526, 1.154094417, 1.154073543, 1.1540529, 1.154032485, 1.154012294, 1.153992323, 1.153972568, 1.153953027, 1.153933695, 1.15391457, 1.153895647, 1.153876925, 1.153858399, 1.153840066, 1.153821925, 1.15380397, 1.153786201, 1.153768613, 1.153751204, 1.153733972, 1.153716914, 1.153700026 };
|
||||
private static readonly double[] T_Table_20 = { 3.077683537, 1.885618083, 1.637744354, 1.533206274, 1.475884049, 1.439755747, 1.414923928, 1.39681531, 1.383028738, 1.372183641, 1.363430318, 1.356217334, 1.350171289, 1.345030374, 1.340605608, 1.336757167, 1.33337939, 1.330390944, 1.327728209, 1.325340707, 1.323187874, 1.321236742, 1.31946024, 1.317835934, 1.316345073, 1.314971864, 1.313702913, 1.312526782, 1.311433647, 1.310415025, 1.309463549, 1.308572793, 1.307737124, 1.306951587, 1.306211802, 1.305513886, 1.304854381, 1.304230204, 1.303638589, 1.303077053, 1.302543359, 1.302035487, 1.301551608, 1.30109006, 1.300649332, 1.300228048, 1.299824947, 1.299438879, 1.299068785, 1.298713694, 1.298372713, 1.298045016, 1.297729843, 1.297426488, 1.2971343, 1.296852673, 1.296581044, 1.29631889, 1.296065725, 1.295821094, 1.295584571, 1.295355762, 1.295134294, 1.29491982, 1.294712013, 1.294510568, 1.294315197, 1.294125629, 1.293941609, 1.293762898, 1.293589269, 1.293420507, 1.293256413, 1.293096793, 1.292941469, 1.292790268, 1.292643029, 1.292499597, 1.292359828, 1.292223583, 1.29209073, 1.291961144, 1.291834705, 1.291711301, 1.291590824, 1.291473171, 1.291358243, 1.291245948, 1.291136195, 1.291028899, 1.290923979, 1.290821356, 1.290720956, 1.290622708, 1.290526543, 1.290432395, 1.290340202, 1.290249904, 1.290161442, 1.290074761, 1.289989809, 1.289906533, 1.289824884, 1.289744816, 1.289666283, 1.289589241, 1.289513648, 1.289439464, 1.289366649, 1.289295166, 1.289224979, 1.289156054, 1.289088355, 1.289021851, 1.28895651, 1.288892302, 1.288829199, 1.288767171, 1.288706191, 1.288646234, 1.288587273, 1.288529284, 1.288472243, 1.288416127, 1.288360913, 1.288306581, 1.288253109, 1.288200477, 1.288148665, 1.288097654, 1.288047427, 1.287997964, 1.287949248, 1.287901264, 1.287853994, 1.287807422, 1.287761534, 1.287716314, 1.287671748, 1.287627821, 1.287584521, 1.287541833, 1.287499745, 1.287458245, 1.287417319, 1.287376957, 1.287337146, 1.287297876, 1.287259135, 1.287220914, 1.2871832, 1.287145985, 1.287109259, 1.287073012, 1.287037235, 1.287001918, 1.286967053, 1.286932631, 1.286898644, 1.286865084, 1.286831942, 1.286799212, 1.286766884, 1.286734952, 1.286703409, 1.286672248, 1.286641461, 1.286611042, 1.286580985, 1.286551283, 1.286521929, 1.286492918, 1.286464244, 1.286435901, 1.286407882, 1.286380184, 1.286352799, 1.286325724, 1.286298952, 1.286272479, 1.286246299, 1.286220408, 1.286194801, 1.286169474, 1.286144421, 1.286119638, 1.286095122, 1.286070867, 1.28604687, 1.286023127, 1.285999633, 1.285976384, 1.285953377, 1.285930609, 1.285908074, 1.285885771, 1.285863694, 1.285841842, 1.285820209, 1.285798794 };
|
||||
private static readonly double[] T_Table_15 = { 4.16529977, 2.281930588, 1.924319657, 1.778192164, 1.699362566, 1.650173154, 1.616591737, 1.59222144, 1.573735785, 1.559235933, 1.547559766, 1.537956495, 1.529919606, 1.523095061, 1.517227969, 1.51213017, 1.507659754, 1.503707672, 1.500188756, 1.497035518, 1.494193795, 1.491619612, 1.489276897, 1.487135783, 1.485171326, 1.483362535, 1.481691617, 1.48014339, 1.478704821, 1.477364662, 1.47611315, 1.474941772, 1.473843072, 1.47281049, 1.471838233, 1.470921166, 1.470054719, 1.469234815, 1.468457801, 1.467720399, 1.467019655, 1.466352901, 1.465717725, 1.465111933, 1.464533534, 1.463980712, 1.463451805, 1.462945295, 1.46245979, 1.461994009, 1.461546775, 1.461117, 1.460703683, 1.460305896, 1.45992278, 1.459553538, 1.45919743, 1.458853767, 1.458521908, 1.458201256, 1.457891251, 1.457591373, 1.457301133, 1.457020074, 1.456747768, 1.45648381, 1.456227824, 1.455979454, 1.455738365, 1.455504241, 1.455276784, 1.455055715, 1.454840767, 1.45463169, 1.454428246, 1.454230212, 1.454037373, 1.453849529, 1.453666487, 1.453488066, 1.453314093, 1.453144404, 1.452978842, 1.452817259, 1.452659513, 1.452505469, 1.452354998, 1.452207977, 1.452064289, 1.451923821, 1.451786468, 1.451652126, 1.451520697, 1.451392088, 1.451266209, 1.451142973, 1.451022299, 1.450904108, 1.450788323, 1.450674871, 1.450563684, 1.450454694, 1.450347836, 1.450243048, 1.450140271, 1.450039448, 1.449940523, 1.449843444, 1.449748158, 1.449654617, 1.449562773, 1.449472581, 1.449383997, 1.449296977, 1.449211481, 1.449127468, 1.449044902, 1.448963744, 1.448883959, 1.448805513, 1.448728372, 1.448652503, 1.448577876, 1.44850446, 1.448432226, 1.448361146, 1.448291192, 1.448222337, 1.448154557, 1.448087826, 1.44802212, 1.447957415, 1.447893688, 1.447830919, 1.447769085, 1.447708165, 1.44764814, 1.44758899, 1.447530695, 1.447473238, 1.447416601, 1.447360765, 1.447305715, 1.447251433, 1.447197905, 1.447145113, 1.447093044, 1.447041682, 1.446991013, 1.446941023, 1.446891698, 1.446843026, 1.446794994, 1.446747588, 1.446700797, 1.446654609, 1.446609012, 1.446563996, 1.446519548, 1.446475659, 1.446432318, 1.446389514, 1.446347238, 1.44630548, 1.44626423, 1.44622348, 1.44618322, 1.446143442, 1.446104137, 1.446065296, 1.446026911, 1.445988975, 1.44595148, 1.445914417, 1.44587778, 1.445841561, 1.445805753, 1.445770349, 1.445735343, 1.445700727, 1.445666495, 1.445632641, 1.445599159, 1.445566042, 1.445533284, 1.445500881, 1.445468825, 1.445437112, 1.445405736, 1.445374691, 1.445343973, 1.445313576, 1.445283495, 1.445253726, 1.445224264, 1.445195103, 1.445166239, 1.445137668, 1.445109385, 1.445081387 };
|
||||
private static readonly double[] T_Table_10 = { 6.313751515, 2.91998558, 2.353363435, 2.131846786, 2.015048373, 1.943180281, 1.894578605, 1.859548038, 1.833112933, 1.812461123, 1.795884819, 1.782287556, 1.770933396, 1.761310136, 1.753050356, 1.745883676, 1.739606726, 1.734063607, 1.729132812, 1.724718243, 1.720742903, 1.717144374, 1.713871528, 1.71088208, 1.708140761, 1.70561792, 1.703288446, 1.701130934, 1.699127027, 1.697260887, 1.695518783, 1.693888748, 1.692360309, 1.690924255, 1.689572458, 1.688297714, 1.68709362, 1.68595446, 1.684875122, 1.683851013, 1.682878002, 1.681952357, 1.681070703, 1.680229977, 1.679427393, 1.678660414, 1.677926722, 1.677224196, 1.676550893, 1.675905025, 1.67528495, 1.674689154, 1.674116237, 1.673564906, 1.673033965, 1.672522303, 1.672028888, 1.671552762, 1.671093032, 1.670648865, 1.670219484, 1.669804163, 1.669402222, 1.669013025, 1.668635976, 1.668270514, 1.667916114, 1.667572281, 1.667238549, 1.666914479, 1.666599658, 1.666293696, 1.665996224, 1.665706893, 1.665425373, 1.665151353, 1.664884537, 1.664624645, 1.664371409, 1.664124579, 1.663883913, 1.663649184, 1.663420175, 1.663196679, 1.6629785, 1.662765449, 1.662557349, 1.662354029, 1.662155326, 1.661961084, 1.661771155, 1.661585397, 1.661403674, 1.661225855, 1.661051817, 1.66088144, 1.66071461, 1.660551217, 1.660391156, 1.660234326, 1.66008063, 1.659929976, 1.659782273, 1.659637437, 1.659495383, 1.659356034, 1.659219312, 1.659085144, 1.658953458, 1.658824187, 1.658697265, 1.658572629, 1.658450216, 1.658329969, 1.65821183, 1.658095744, 1.657981659, 1.657869522, 1.657759285, 1.657650899, 1.657544319, 1.657439499, 1.657336397, 1.65723497, 1.657135178, 1.657036982, 1.656940344, 1.656845226, 1.656751594, 1.656659413, 1.656568649, 1.65647927, 1.656391244, 1.656304542, 1.656219133, 1.656134988, 1.65605208, 1.655970382, 1.655889868, 1.655810511, 1.655732287, 1.655655173, 1.655579143, 1.655504177, 1.655430251, 1.655357345, 1.655285437, 1.655214506, 1.655144534, 1.6550755, 1.655007387, 1.654940175, 1.654873847, 1.654808385, 1.654743774, 1.654679996, 1.654617035, 1.654554875, 1.654493503, 1.654432901, 1.654373057, 1.654313957, 1.654255585, 1.654197929, 1.654140976, 1.654084713, 1.654029128, 1.653974208, 1.653919942, 1.653866317, 1.653813324, 1.653760949, 1.653709184, 1.653658017, 1.653607437, 1.653557435, 1.653508002, 1.653459126, 1.6534108, 1.653363013, 1.653315758, 1.653269024, 1.653222803, 1.653177088, 1.653131869, 1.653087138, 1.653042889, 1.652999113, 1.652955802, 1.652912949, 1.652870547, 1.652828589, 1.652787068, 1.652745977, 1.65270531, 1.652665059, 1.652625219, 1.652585784, 1.652546746, 1.652508101 };
|
||||
private static readonly double[] T_Table_05 = { 12.70620474, 4.30265273, 3.182446305, 2.776445105, 2.570581836, 2.446911851, 2.364624252, 2.306004135, 2.262157163, 2.228138852, 2.20098516, 2.17881283, 2.160368656, 2.144786688, 2.131449546, 2.119905299, 2.109815578, 2.10092204, 2.093024054, 2.085963447, 2.079613845, 2.073873068, 2.06865761, 2.063898562, 2.059538553, 2.055529439, 2.051830516, 2.048407142, 2.045229642, 2.042272456, 2.039513446, 2.036933343, 2.034515297, 2.032244509, 2.030107928, 2.028094001, 2.026192463, 2.024394164, 2.02269092, 2.02107539, 2.01954097, 2.018081703, 2.016692199, 2.015367574, 2.014103389, 2.012895599, 2.011740514, 2.010634758, 2.009575237, 2.008559112, 2.00758377, 2.006646805, 2.005745995, 2.004879288, 2.004044783, 2.003240719, 2.002465459, 2.001717484, 2.000995378, 2.000297822, 1.999623585, 1.998971517, 1.998340543, 1.997729654, 1.997137908, 1.996564419, 1.996008354, 1.995468931, 1.994945415, 1.994437112, 1.993943368, 1.993463567, 1.992997126, 1.992543495, 1.992102154, 1.99167261, 1.991254395, 1.990847069, 1.99045021, 1.990063421, 1.989686323, 1.989318557, 1.98895978, 1.988609667, 1.988267907, 1.987934206, 1.987608282, 1.987289865, 1.9869787, 1.986674541, 1.986377154, 1.986086317, 1.985801814, 1.985523442, 1.985251004, 1.984984312, 1.984723186, 1.984467455, 1.984216952, 1.983971519, 1.983731003, 1.983495259, 1.983264145, 1.983037526, 1.982815274, 1.982597262, 1.98238337, 1.982173483, 1.98196749, 1.981765282, 1.981566757, 1.981371815, 1.981180359, 1.980992298, 1.980807541, 1.980626002, 1.980447599, 1.980272249, 1.980099876, 1.979930405, 1.979763763, 1.979599878, 1.979438685, 1.979280117, 1.979124109, 1.978970602, 1.978819535, 1.97867085, 1.978524491, 1.978380405, 1.978238539, 1.978098842, 1.977961264, 1.977825758, 1.977692277, 1.977560777, 1.977431212, 1.977303542, 1.977177724, 1.97705372, 1.976931489, 1.976810994, 1.976692198, 1.976575066, 1.976459563, 1.976345655, 1.976233309, 1.976122494, 1.976013178, 1.975905331, 1.975798924, 1.975693928, 1.975590315, 1.975488058, 1.975387131, 1.975287508, 1.975189163, 1.975092073, 1.974996213, 1.97490156, 1.974808092, 1.974715786, 1.974624621, 1.974534576, 1.97444563, 1.974357764, 1.974270957, 1.974185191, 1.974100447, 1.974016708, 1.973933954, 1.973852169, 1.973771337, 1.97369144, 1.973612462, 1.973534388, 1.973457202, 1.973380889, 1.973305434, 1.973230823, 1.973157042, 1.973084077, 1.973011915, 1.972940542, 1.972869946, 1.972800114, 1.972731033, 1.972662692, 1.972595079, 1.972528182, 1.97246199, 1.972396491, 1.972331676, 1.972267533, 1.972204051, 1.972141222, 1.972079034, 1.972017478, 1.971956544, 1.971896224 };
|
||||
private static readonly double[] T_Table_01 = { 63.65674116, 9.924843201, 5.84090931, 4.604094871, 4.032142984, 3.707428021, 3.499483297, 3.355387331, 3.249835542, 3.169272673, 3.105806516, 3.054539589, 3.012275839, 2.976842734, 2.946712883, 2.920781622, 2.89823052, 2.878440473, 2.860934606, 2.84533971, 2.831359558, 2.818756061, 2.807335684, 2.796939505, 2.787435814, 2.778714533, 2.770682957, 2.763262455, 2.756385904, 2.749995654, 2.744041919, 2.738481482, 2.733276642, 2.728394367, 2.723805589, 2.71948463, 2.715408722, 2.711557602, 2.707913184, 2.704459267, 2.701181304, 2.698066186, 2.695102079, 2.692278266, 2.689585019, 2.687013492, 2.684555618, 2.682204027, 2.679951974, 2.677793271, 2.675722234, 2.673733631, 2.671822636, 2.669984796, 2.668215988, 2.666512398, 2.664870482, 2.663286954, 2.661758752, 2.660283029, 2.658857127, 2.657478565, 2.656145025, 2.654854337, 2.653604469, 2.652393515, 2.651219685, 2.650081299, 2.648976774, 2.647904624, 2.646863444, 2.645851913, 2.644868782, 2.643912872, 2.642983067, 2.642078313, 2.641197611, 2.640340015, 2.639504627, 2.638690596, 2.637897113, 2.63712341, 2.636368757, 2.635632458, 2.634913852, 2.634212309, 2.633527229, 2.632858038, 2.632204191, 2.631565166, 2.630940463, 2.630329608, 2.629732145, 2.629147638, 2.628575671, 2.628015844, 2.627467774, 2.626931096, 2.626405457, 2.625890521, 2.625385965, 2.624891476, 2.624406758, 2.623931523, 2.623465496, 2.623008411, 2.622560015, 2.622120061, 2.621688313, 2.621264543, 2.620848534, 2.620440073, 2.620038957, 2.619644989, 2.619257981, 2.618877749, 2.618504116, 2.618136914, 2.617775976, 2.617421145, 2.617072266, 2.616729191, 2.616391776, 2.616059883, 2.615733377, 2.615412127, 2.615096008, 2.614784899, 2.61447868, 2.614177238, 2.613880461, 2.613588242, 2.613300477, 2.613017065, 2.612737908, 2.61246291, 2.61219198, 2.611925028, 2.611661966, 2.611402711, 2.611147181, 2.610895295, 2.610646976, 2.61040215, 2.610160742, 2.609922682, 2.609687901, 2.609456331, 2.609227907, 2.609002566, 2.608780245, 2.608560883, 2.608344423, 2.608130807, 2.60791998, 2.607711886, 2.607506474, 2.607303692, 2.607103489, 2.606905817, 2.606710628, 2.606517876, 2.606327515, 2.606139501, 2.605953791, 2.605770342, 2.605589114, 2.605410067, 2.605233162, 2.605058359, 2.604885623, 2.604714916, 2.604546204, 2.60437945, 2.604214622, 2.604051686, 2.60389061, 2.603731363, 2.603573912, 2.603418229, 2.603264282, 2.603112045, 2.602961487, 2.602812582, 2.602665303, 2.602519622, 2.602375515, 2.602232955, 2.602091918, 2.60195238, 2.601814317, 2.601677705, 2.601542523, 2.601408747, 2.601276355, 2.601145327, 2.601015642, 2.600887278, 2.600760216, 2.600634436 };
|
||||
}
|
||||
|
||||
public enum Significance
|
||||
@@ -125,7 +125,7 @@ public class AverageSpeed
|
||||
|
||||
var time = now - start;
|
||||
|
||||
while (speeds.Count > MAX_SPEEDS || (speeds.Count > 2 && time - speeds.First.Value.Time > SlowWindow))
|
||||
while (speeds.Count > MAX_SPEEDS || (speeds.Count > 2 && time - speeds.First!.Value.Time > SlowWindow))
|
||||
speeds.RemoveFirst();
|
||||
|
||||
if (!double.IsNaN(lastPosition))
|
||||
@@ -145,7 +145,7 @@ public class AverageSpeed
|
||||
if (speeds.Count == 0)
|
||||
return 0;
|
||||
else if (speeds.Count == 1)
|
||||
return speeds.Last.Value.Velocity;
|
||||
return speeds.Last!.Value.Velocity;
|
||||
else
|
||||
{
|
||||
var n_newest = speeds.Count(s => s.Time > lastTime.Subtract(FastWindow));
|
||||
|
||||
+41
-42
@@ -1,60 +1,59 @@
|
||||
using AAXClean;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core;
|
||||
using Mpeg4Lib;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace AaxDecrypter
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public static class Cue
|
||||
{
|
||||
public static class Cue
|
||||
{
|
||||
public static string CreateContents(string filePath, ChapterInfo chapters)
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
public static string CreateContents(string filePath, ChapterInfo chapters)
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
stringBuilder.AppendLine(GetFileLine(filePath, "MP3"));
|
||||
stringBuilder.AppendLine(GetFileLine(filePath, "MP3"));
|
||||
|
||||
var startOffset = chapters.StartOffset;
|
||||
var startOffset = chapters.StartOffset;
|
||||
|
||||
var trackCount = 1;
|
||||
foreach (var c in chapters.Chapters)
|
||||
{
|
||||
var startTime = c.StartOffset - startOffset;
|
||||
var trackCount = 1;
|
||||
foreach (var c in chapters.Chapters)
|
||||
{
|
||||
var startTime = c.StartOffset - startOffset;
|
||||
|
||||
stringBuilder.AppendLine($"TRACK {trackCount++} AUDIO");
|
||||
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
|
||||
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds * 75d / 1000):D2}");
|
||||
}
|
||||
stringBuilder.AppendLine($"TRACK {trackCount++} AUDIO");
|
||||
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
|
||||
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds * 75d / 1000):D2}");
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
public static void UpdateFileName(FileInfo cueFileInfo, string audioFilePath)
|
||||
=> UpdateFileName(cueFileInfo.FullName, audioFilePath);
|
||||
public static void UpdateFileName(FileInfo cueFileInfo, string audioFilePath)
|
||||
=> UpdateFileName(cueFileInfo.FullName, audioFilePath);
|
||||
|
||||
public static void UpdateFileName(string cueFilePath, FileInfo audioFileInfo)
|
||||
=> UpdateFileName(cueFilePath, audioFileInfo.FullName);
|
||||
public static void UpdateFileName(string cueFilePath, FileInfo audioFileInfo)
|
||||
=> UpdateFileName(cueFilePath, audioFileInfo.FullName);
|
||||
|
||||
public static void UpdateFileName(FileInfo cueFileInfo, FileInfo audioFileInfo)
|
||||
=> UpdateFileName(cueFileInfo.FullName, audioFileInfo.FullName);
|
||||
public static void UpdateFileName(FileInfo cueFileInfo, FileInfo audioFileInfo)
|
||||
=> UpdateFileName(cueFileInfo.FullName, audioFileInfo.FullName);
|
||||
|
||||
public static void UpdateFileName(string cueFilePath, string audioFilePath)
|
||||
{
|
||||
var cueContents = File.ReadAllLines(cueFilePath);
|
||||
public static void UpdateFileName(string cueFilePath, string audioFilePath)
|
||||
{
|
||||
var cueContents = File.ReadAllLines(cueFilePath);
|
||||
|
||||
for (var i = 0; i < cueContents.Length; i++)
|
||||
{
|
||||
var line = cueContents[i];
|
||||
if (!line.Trim().StartsWith("FILE") || !line.Contains(' '))
|
||||
continue;
|
||||
for (var i = 0; i < cueContents.Length; i++)
|
||||
{
|
||||
var line = cueContents[i];
|
||||
if (!line.Trim().StartsWith("FILE") || !line.Contains(' '))
|
||||
continue;
|
||||
|
||||
var fileTypeBegins = line.LastIndexOf(" ") + 1;
|
||||
cueContents[i] = GetFileLine(audioFilePath, line[fileTypeBegins..]);
|
||||
break;
|
||||
}
|
||||
var fileTypeBegins = line.LastIndexOf(" ") + 1;
|
||||
cueContents[i] = GetFileLine(audioFilePath, line[fileTypeBegins..]);
|
||||
break;
|
||||
}
|
||||
|
||||
File.WriteAllLines(cueFilePath, cueContents);
|
||||
}
|
||||
File.WriteAllLines(cueFilePath, cueContents);
|
||||
}
|
||||
|
||||
private static string GetFileLine(string filePath, string audioType) => $"FILE {Path.GetFileName(filePath).SurroundWithQuotes()} {audioType}";
|
||||
}
|
||||
private static string GetFileLine(string filePath, string audioType) => $"FILE {Path.GetFileName(filePath).SurroundWithQuotes()} {audioType}";
|
||||
}
|
||||
@@ -1,54 +1,54 @@
|
||||
using AAXClean;
|
||||
using Mpeg4Lib;
|
||||
using System;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public class KeyData
|
||||
{
|
||||
public class KeyData
|
||||
{
|
||||
public byte[] KeyPart1 { get; }
|
||||
public byte[]? KeyPart2 { get; }
|
||||
public byte[] KeyPart1 { get; }
|
||||
public byte[]? KeyPart2 { get; }
|
||||
|
||||
public KeyData(byte[] keyPart1, byte[]? keyPart2 = null)
|
||||
{
|
||||
KeyPart1 = keyPart1;
|
||||
KeyPart2 = keyPart2;
|
||||
}
|
||||
public KeyData(byte[] keyPart1, byte[]? keyPart2 = null)
|
||||
{
|
||||
KeyPart1 = keyPart1;
|
||||
KeyPart2 = keyPart2;
|
||||
}
|
||||
|
||||
public KeyData(string keyPart1, string? keyPart2 = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));
|
||||
KeyPart1 = Convert.FromHexString(keyPart1);
|
||||
if (keyPart2 != null)
|
||||
KeyPart2 = Convert.FromHexString(keyPart2);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IDownloadOptions
|
||||
{
|
||||
event EventHandler<long> DownloadSpeedChanged;
|
||||
string DownloadUrl { get; }
|
||||
string UserAgent { get; }
|
||||
KeyData[]? DecryptionKeys { get; }
|
||||
TimeSpan RuntimeLength { get; }
|
||||
OutputFormat OutputFormat { get; }
|
||||
bool StripUnabridged { get; }
|
||||
bool CreateCueSheet { get; }
|
||||
long DownloadSpeedBps { get; }
|
||||
ChapterInfo ChapterInfo { get; }
|
||||
bool FixupFile { get; }
|
||||
string? AudibleProductId { get; }
|
||||
string? Title { get; }
|
||||
string? Subtitle { get; }
|
||||
string? Publisher { get; }
|
||||
string? Language { get; }
|
||||
string? SeriesName { get; }
|
||||
string? SeriesNumber { get; }
|
||||
NAudio.Lame.LameConfig? LameConfig { get; }
|
||||
bool Downsample { get; }
|
||||
bool MatchSourceBitrate { get; }
|
||||
bool MoveMoovToBeginning { get; }
|
||||
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||
public FileType? InputType { get; }
|
||||
}
|
||||
[Newtonsoft.Json.JsonConstructor]
|
||||
public KeyData(string keyPart1, string? keyPart2 = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));
|
||||
KeyPart1 = Convert.FromHexString(keyPart1);
|
||||
if (keyPart2 != null)
|
||||
KeyPart2 = Convert.FromHexString(keyPart2);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IDownloadOptions
|
||||
{
|
||||
event EventHandler<long> DownloadSpeedChanged;
|
||||
string DownloadUrl { get; }
|
||||
string UserAgent { get; }
|
||||
KeyData[]? DecryptionKeys { get; }
|
||||
TimeSpan RuntimeLength { get; }
|
||||
OutputFormat OutputFormat { get; }
|
||||
bool StripUnabridged { get; }
|
||||
bool CreateCueSheet { get; }
|
||||
long DownloadSpeedBps { get; }
|
||||
ChapterInfo ChapterInfo { get; }
|
||||
bool FixupFile { get; }
|
||||
string? AudibleProductId { get; }
|
||||
string? Title { get; }
|
||||
string? Subtitle { get; }
|
||||
string? Publisher { get; }
|
||||
string? Language { get; }
|
||||
string? SeriesName { get; }
|
||||
string? SeriesNumber { get; }
|
||||
NAudio.Lame.LameConfig? LameConfig { get; }
|
||||
bool Downsample { get; }
|
||||
bool MatchSourceBitrate { get; }
|
||||
bool MoveMoovToBeginning { get; }
|
||||
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||
public FileType? InputType { get; }
|
||||
}
|
||||
@@ -1,69 +1,71 @@
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using AAXClean.Codecs;
|
||||
using Mpeg4Lib;
|
||||
using NAudio.Lame;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace AaxDecrypter
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public static class MpegUtil
|
||||
{
|
||||
public static class MpegUtil
|
||||
private const string TagDomain = "com.pilabor.tone";
|
||||
public static void ConfigureLameOptions(
|
||||
Mpeg4File mp4File,
|
||||
LameConfig lameConfig,
|
||||
bool downsample,
|
||||
bool matchSourceBitrate,
|
||||
ChapterInfo? chapters)
|
||||
{
|
||||
private const string TagDomain = "com.pilabor.tone";
|
||||
public static void ConfigureLameOptions(
|
||||
Mp4File mp4File,
|
||||
LameConfig lameConfig,
|
||||
bool downsample,
|
||||
bool matchSourceBitrate,
|
||||
ChapterInfo chapters)
|
||||
double bitrateMultiple = 1;
|
||||
|
||||
if (mp4File.TimeScale < lameConfig.OutputSampleRate)
|
||||
{
|
||||
double bitrateMultiple = 1;
|
||||
lameConfig.OutputSampleRate = mp4File.TimeScale;
|
||||
}
|
||||
else if (mp4File.TimeScale > lameConfig.OutputSampleRate)
|
||||
{
|
||||
bitrateMultiple *= (double)lameConfig.OutputSampleRate / mp4File.TimeScale;
|
||||
}
|
||||
|
||||
if (mp4File.TimeScale < lameConfig.OutputSampleRate)
|
||||
{
|
||||
lameConfig.OutputSampleRate = mp4File.TimeScale;
|
||||
}
|
||||
else if (mp4File.TimeScale > lameConfig.OutputSampleRate)
|
||||
{
|
||||
bitrateMultiple *= (double)lameConfig.OutputSampleRate / mp4File.TimeScale;
|
||||
}
|
||||
if (mp4File.AudioChannels == 2)
|
||||
{
|
||||
if (downsample)
|
||||
bitrateMultiple /= 2;
|
||||
else
|
||||
lameConfig.Mode = MPEGMode.Stereo;
|
||||
}
|
||||
|
||||
if (mp4File.AudioChannels == 2)
|
||||
{
|
||||
if (downsample)
|
||||
bitrateMultiple /= 2;
|
||||
else
|
||||
lameConfig.Mode = MPEGMode.Stereo;
|
||||
}
|
||||
if (matchSourceBitrate)
|
||||
{
|
||||
int kbps = (int)Math.Round(mp4File.AverageBitrate * bitrateMultiple / 1024);
|
||||
|
||||
if (matchSourceBitrate)
|
||||
{
|
||||
int kbps = (int)Math.Round(mp4File.AverageBitrate * bitrateMultiple / 1024);
|
||||
if (lameConfig.VBR is null)
|
||||
lameConfig.BitRate = kbps;
|
||||
else if (lameConfig.VBR == VBRMode.ABR)
|
||||
lameConfig.ABRRateKbps = kbps;
|
||||
}
|
||||
|
||||
if (lameConfig.VBR is null)
|
||||
lameConfig.BitRate = kbps;
|
||||
else if (lameConfig.VBR == VBRMode.ABR)
|
||||
lameConfig.ABRRateKbps = kbps;
|
||||
}
|
||||
//Setup metadata tags
|
||||
lameConfig.ID3 = mp4File.MetadataItems.ToIDTags();
|
||||
|
||||
//Setup metadata tags
|
||||
lameConfig.ID3 = mp4File.AppleTags.ToIDTags();
|
||||
if (mp4File.MetadataItems.AppleListBox.GetFreeformTagString(TagDomain, "SUBTITLE") is string subtitle)
|
||||
lameConfig.ID3.Subtitle = subtitle;
|
||||
|
||||
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SUBTITLE") is string subtitle)
|
||||
lameConfig.ID3.Subtitle = subtitle;
|
||||
if (chapters?.Count > 0)
|
||||
{
|
||||
var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters);
|
||||
lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue);
|
||||
}
|
||||
|
||||
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "LANGUAGE") is string lang)
|
||||
lameConfig.ID3.UserDefinedText.Add("LANGUAGE", lang);
|
||||
|
||||
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SERIES") is string series)
|
||||
lameConfig.ID3.UserDefinedText.Add("SERIES", series);
|
||||
|
||||
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "PART") is string part)
|
||||
lameConfig.ID3.UserDefinedText.Add("PART", part);
|
||||
|
||||
if (chapters?.Count > 0)
|
||||
{
|
||||
var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters);
|
||||
lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue);
|
||||
}
|
||||
//Copy over all other freeform tags
|
||||
foreach (var t in mp4File.MetadataItems.AppleListBox.Tags.OfType<Mpeg4Lib.Boxes.FreeformTagBox>())
|
||||
{
|
||||
if (t.Name?.Name is string name &&
|
||||
t.Mean?.ReverseDnsDomain is string domain &&
|
||||
!lameConfig.ID3.UserDefinedText.ContainsKey(name) &&
|
||||
mp4File.MetadataItems.AppleListBox.GetFreeformTagString(domain, name) is string tagStr &&
|
||||
!string.IsNullOrWhiteSpace(tagStr))
|
||||
lameConfig.ID3.UserDefinedText.Add(name, tagStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace AaxDecrypter
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public class MultiConvertFileProperties
|
||||
{
|
||||
public class MultiConvertFileProperties
|
||||
{
|
||||
public string OutputFileName { get; set; }
|
||||
public int PartsPosition { get; set; }
|
||||
public int PartsTotal { get; set; }
|
||||
public string Title { get; set; }
|
||||
public DateTime FileDate { get; } = DateTime.Now;
|
||||
}
|
||||
public required string OutputFileName { get; set; }
|
||||
public int PartsPosition { get; set; }
|
||||
public int PartsTotal { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public DateTime FileDate { get; } = DateTime.Now;
|
||||
}
|
||||
@@ -8,415 +8,419 @@ using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
namespace AaxDecrypter;
|
||||
|
||||
/// <summary>A resumable, simultaneous file downloader and reader. </summary>
|
||||
public class NetworkFileStream : Stream, IUpdatable
|
||||
{
|
||||
/// <summary>A resumable, simultaneous file downloader and reader. </summary>
|
||||
public class NetworkFileStream : Stream, IUpdatable
|
||||
public event EventHandler? Updated;
|
||||
|
||||
#region Public Properties
|
||||
|
||||
/// <summary> Location to save the downloaded data. </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public string SaveFilePath { get; }
|
||||
|
||||
/// <summary> Http(s) address of the file to download. </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public Uri Uri { get; private set; }
|
||||
|
||||
/// <summary> Http headers to be sent to the server with the request. </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public Dictionary<string, string> RequestHeaders { get; private set; }
|
||||
|
||||
/// <summary> The position in <see cref="SaveFilePath"/> that has been written and flushed to disk. </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public long WritePosition { get; private set; }
|
||||
|
||||
/// <summary> The total length of the <see cref="Uri"/> file to download. </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public long ContentLength { get; private set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
|
||||
|
||||
[JsonIgnore]
|
||||
public Task? DownloadTask { get; private set; }
|
||||
|
||||
private long _speedLimit = 0;
|
||||
/// <summary>bytes per second</summary>
|
||||
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Properties
|
||||
private FileStream _writeFile { get; }
|
||||
private FileStream _readFile { get; }
|
||||
private CancellationTokenSource _cancellationSource { get; } = new();
|
||||
private EventWaitHandle? _downloadedPiece { get; set; }
|
||||
|
||||
private DateTime NextUpdateTime { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constants
|
||||
|
||||
//Download memory buffer size
|
||||
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
|
||||
|
||||
//NetworkFileStream will flush all data in _writeFile to disk after every
|
||||
//DATA_FLUSH_SZ bytes are written to the file stream.
|
||||
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
||||
|
||||
//Number of times per second the download rate is checked and throttled
|
||||
private const int THROTTLE_FREQUENCY = 8;
|
||||
|
||||
//Minimum throttle rate. The minimum amount of data that can be throttled
|
||||
//on each iteration of the download loop is DOWNLOAD_BUFF_SZ.
|
||||
public const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
|
||||
/// <summary> A resumable, simultaneous file downloader and reader. </summary>
|
||||
/// <param name="saveFilePath">Path to a location on disk to save the downloaded data from <paramref name="uri"/></param>
|
||||
/// <param name="uri">Http(s) address of the file to download.</param>
|
||||
/// <param name="writePosition">The position in <paramref name="uri"/> to begin downloading.</param>
|
||||
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
|
||||
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary<string, string>? requestHeaders = null)
|
||||
{
|
||||
public event EventHandler Updated;
|
||||
SaveFilePath = ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
|
||||
Uri = ArgumentValidator.EnsureNotNull(uri, nameof(uri));
|
||||
WritePosition = ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
|
||||
|
||||
#region Public Properties
|
||||
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
|
||||
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
|
||||
|
||||
/// <summary> Location to save the downloaded data. </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public string SaveFilePath { get; }
|
||||
RequestHeaders = requestHeaders ?? new();
|
||||
|
||||
/// <summary> Http(s) address of the file to download. </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public Uri Uri { get; private set; }
|
||||
|
||||
/// <summary> Http headers to be sent to the server with the request. </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public Dictionary<string, string> RequestHeaders { get; private set; }
|
||||
|
||||
/// <summary> The position in <see cref="SaveFilePath"/> that has been written and flushed to disk. </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public long WritePosition { get; private set; }
|
||||
|
||||
/// <summary> The total length of the <see cref="Uri"/> file to download. </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public long ContentLength { get; private set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
|
||||
|
||||
[JsonIgnore]
|
||||
public Task DownloadTask { get; private set; }
|
||||
|
||||
private long _speedLimit = 0;
|
||||
/// <summary>bytes per second</summary>
|
||||
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Properties
|
||||
private FileStream _writeFile { get; }
|
||||
private FileStream _readFile { get; }
|
||||
private CancellationTokenSource _cancellationSource { get; } = new();
|
||||
private EventWaitHandle _downloadedPiece { get; set; }
|
||||
|
||||
private DateTime NextUpdateTime { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constants
|
||||
|
||||
//Download memory buffer size
|
||||
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
|
||||
|
||||
//NetworkFileStream will flush all data in _writeFile to disk after every
|
||||
//DATA_FLUSH_SZ bytes are written to the file stream.
|
||||
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
||||
|
||||
//Number of times per second the download rate is checked and throttled
|
||||
private const int THROTTLE_FREQUENCY = 8;
|
||||
|
||||
//Minimum throttle rate. The minimum amount of data that can be throttled
|
||||
//on each iteration of the download loop is DOWNLOAD_BUFF_SZ.
|
||||
public const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
|
||||
/// <summary> A resumable, simultaneous file downloader and reader. </summary>
|
||||
/// <param name="saveFilePath">Path to a location on disk to save the downloaded data from <paramref name="uri"/></param>
|
||||
/// <param name="uri">Http(s) address of the file to download.</param>
|
||||
/// <param name="writePosition">The position in <paramref name="uri"/> to begin downloading.</param>
|
||||
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
|
||||
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary<string, string> requestHeaders = null)
|
||||
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
|
||||
{
|
||||
SaveFilePath = ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
|
||||
Uri = ArgumentValidator.EnsureNotNull(uri, nameof(uri));
|
||||
WritePosition = ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
|
||||
Position = WritePosition
|
||||
};
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
|
||||
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
|
||||
|
||||
RequestHeaders = requestHeaders ?? new();
|
||||
|
||||
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
|
||||
{
|
||||
Position = WritePosition
|
||||
};
|
||||
|
||||
if (_writeFile.Length < WritePosition)
|
||||
{
|
||||
_writeFile.Dispose();
|
||||
throw new InvalidDataException($"{SaveFilePath} file length is shorter than {WritePosition}");
|
||||
}
|
||||
|
||||
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
SetUriForSameFile(uri);
|
||||
if (_writeFile.Length < WritePosition)
|
||||
{
|
||||
_writeFile.Dispose();
|
||||
throw new InvalidDataException($"{SaveFilePath} file length is shorter than {WritePosition}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
#region Downloader
|
||||
SetUriForSameFile(uri);
|
||||
}
|
||||
|
||||
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
||||
private void OnUpdate(bool waitForWrite = false)
|
||||
#endregion
|
||||
|
||||
#region Downloader
|
||||
|
||||
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
||||
private void OnUpdate(bool waitForWrite = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
if (waitForWrite || DateTime.UtcNow > NextUpdateTime)
|
||||
{
|
||||
if (waitForWrite || DateTime.UtcNow > NextUpdateTime)
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
//JsonFilePersister Will not allow update intervals shorter than 100 milliseconds
|
||||
//If an update is called less than 100 ms since the last update, persister will
|
||||
//sleep the thread until 100 ms has elapsed.
|
||||
NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "An error was encountered while saving the download progress to JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary> Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/> </summary>
|
||||
/// <param name="uriToSameFile">New <see cref="System.Uri"/> host must match existing host.</param>
|
||||
public void SetUriForSameFile(Uri uriToSameFile)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile));
|
||||
|
||||
if (Path.GetFileName(uriToSameFile.LocalPath) != Path.GetFileName(Uri.LocalPath))
|
||||
throw new ArgumentException($"New uri to the same file must have the same file name.");
|
||||
if (uriToSameFile.Host != Uri.Host)
|
||||
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
|
||||
if (DownloadTask is not null)
|
||||
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
||||
|
||||
Uri = uriToSameFile;
|
||||
}
|
||||
|
||||
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
|
||||
/// <returns>The downloader <see cref="Task"/></returns>
|
||||
public async Task BeginDownloadingAsync()
|
||||
{
|
||||
if (ContentLength != 0 && WritePosition == ContentLength)
|
||||
{
|
||||
DownloadTask = Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ContentLength != 0 && WritePosition > ContentLength)
|
||||
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
//Initiate connection with the first request block and
|
||||
//get the total content length before returning.
|
||||
var client = new HttpClient();
|
||||
var response = await RequestNextByteRangeAsync(client);
|
||||
|
||||
if (ContentLength != 0 && ContentLength != response.FileSize)
|
||||
throw new WebException($"Content length of 0x{response.FileSize:X10} differs from partially downloaded content length of 0x{ContentLength:X10}");
|
||||
|
||||
ContentLength = response.FileSize;
|
||||
|
||||
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||
//Hand off the client and the open request to the downloader to download and write data to file.
|
||||
DownloadTask = Task.Run(() => DownloadLoopInternal(client, response), _cancellationSource.Token);
|
||||
}
|
||||
|
||||
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
|
||||
{
|
||||
try
|
||||
{
|
||||
long startPosition = WritePosition;
|
||||
|
||||
while (WritePosition < ContentLength && !IsCancelled)
|
||||
{
|
||||
try
|
||||
{
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
//JsonFilePersister Will not allow update intervals shorter than 100 milliseconds
|
||||
//If an update is called less than 100 ms since the last update, persister will
|
||||
//sleep the thread until 100 ms has elapsed.
|
||||
NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110);
|
||||
await DownloadToFile(blockResponse);
|
||||
}
|
||||
catch (HttpIOException e)
|
||||
when (e.HttpRequestError is HttpRequestError.ResponseEnded
|
||||
&& WritePosition != startPosition
|
||||
&& WritePosition < ContentLength && !IsCancelled)
|
||||
{
|
||||
Serilog.Log.Logger.Debug($"The download connection ended before the file completed downloading all 0x{ContentLength:X10} bytes");
|
||||
|
||||
//the download made *some* progress since the last attempt.
|
||||
//Try again to complete the download from where it left off.
|
||||
//Make sure to rewind file to last flush position.
|
||||
_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)
|
||||
{
|
||||
Serilog.Log.Error(ex, "An error was encountered while saving the download progress to JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary> Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/> </summary>
|
||||
/// <param name="uriToSameFile">New <see cref="System.Uri"/> host must match existing host.</param>
|
||||
public void SetUriForSameFile(Uri uriToSameFile)
|
||||
catch (Exception ex)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile));
|
||||
|
||||
if (Path.GetFileName(uriToSameFile.LocalPath) != Path.GetFileName(Uri.LocalPath))
|
||||
throw new ArgumentException($"New uri to the same file must have the same file name.");
|
||||
if (uriToSameFile.Host != Uri.Host)
|
||||
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
|
||||
if (DownloadTask is not null)
|
||||
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
||||
|
||||
Uri = uriToSameFile;
|
||||
//Don't throw from DownloadTask.
|
||||
//This task gets awaited in Dispose() and we don't want to have an unhandled exception there.
|
||||
Serilog.Log.Error(ex, "An error was encountered during the download process.");
|
||||
}
|
||||
|
||||
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
|
||||
/// <returns>The downloader <see cref="Task"/></returns>
|
||||
public async Task BeginDownloadingAsync()
|
||||
finally
|
||||
{
|
||||
if (ContentLength != 0 && WritePosition == ContentLength)
|
||||
{
|
||||
DownloadTask = Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ContentLength != 0 && WritePosition > ContentLength)
|
||||
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
//Initiate connection with the first request block and
|
||||
//get the total content length before returning.
|
||||
var client = new HttpClient();
|
||||
var response = await RequestNextByteRangeAsync(client);
|
||||
|
||||
if (ContentLength != 0 && ContentLength != response.FileSize)
|
||||
throw new WebException($"Content length of 0x{response.FileSize:X10} differs from partially downloaded content length of 0x{ContentLength:X10}");
|
||||
|
||||
ContentLength = response.FileSize;
|
||||
|
||||
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||
//Hand off the client and the open request to the downloader to download and write data to file.
|
||||
DownloadTask = Task.Run(() => DownloadLoopInternal(client , response), _cancellationSource.Token);
|
||||
_writeFile.Dispose();
|
||||
blockResponse.Dispose();
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
|
||||
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, Uri);
|
||||
|
||||
//Just in case it snuck in the saved json (Issue #1232)
|
||||
RequestHeaders.Remove("Range");
|
||||
|
||||
foreach (var header in RequestHeaders)
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
|
||||
request.Headers.Add("Range", $"bytes={WritePosition}-");
|
||||
|
||||
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
||||
|
||||
var totalSize = response.Content.Headers.ContentRange?.Length ??
|
||||
throw new WebException("The response did not contain a total content length.");
|
||||
|
||||
var rangeSize = response.Content.Headers.ContentLength ??
|
||||
throw new WebException($"The response did not contain a {nameof(response.Content.Headers.ContentLength)};");
|
||||
|
||||
return new BlockResponse(response, rangeSize, totalSize);
|
||||
}
|
||||
|
||||
private readonly record struct BlockResponse(HttpResponseMessage Response, long BlockSize, long FileSize) : IDisposable
|
||||
{
|
||||
public void Dispose() => Response?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
||||
private async Task DownloadToFile(BlockResponse block)
|
||||
{
|
||||
var endPosition = WritePosition + block.BlockSize;
|
||||
using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
||||
|
||||
var downloadPosition = WritePosition;
|
||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||
|
||||
try
|
||||
{
|
||||
try
|
||||
DateTime startTime = DateTime.UtcNow;
|
||||
long bytesReadSinceThrottle = 0;
|
||||
int bytesRead;
|
||||
do
|
||||
{
|
||||
long startPosition = WritePosition;
|
||||
bytesRead = await networkStream.ReadAsync(buff, _cancellationSource.Token);
|
||||
await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token);
|
||||
|
||||
while (WritePosition < ContentLength && !IsCancelled)
|
||||
downloadPosition += bytesRead;
|
||||
|
||||
if (downloadPosition > nextFlush)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DownloadToFile(blockResponse);
|
||||
}
|
||||
catch (HttpIOException e)
|
||||
when (e.HttpRequestError is HttpRequestError.ResponseEnded
|
||||
&& WritePosition != startPosition
|
||||
&& WritePosition < ContentLength && !IsCancelled)
|
||||
{
|
||||
Serilog.Log.Logger.Debug($"The download connection ended before the file completed downloading all 0x{ContentLength:X10} bytes");
|
||||
|
||||
//the download made *some* progress since the last attempt.
|
||||
//Try again to complete the download from where it left off.
|
||||
//Make sure to rewind file to last flush position.
|
||||
_writeFile.Position = startPosition = WritePosition;
|
||||
blockResponse.Dispose();
|
||||
blockResponse = await RequestNextByteRangeAsync(client);
|
||||
|
||||
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
|
||||
}
|
||||
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||
WritePosition = downloadPosition;
|
||||
OnUpdate();
|
||||
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
_downloadedPiece?.Set();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeFile.Dispose();
|
||||
blockResponse.Dispose();
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, Uri);
|
||||
#region throttle
|
||||
|
||||
//Just in case it snuck in the saved json (Issue #1232)
|
||||
RequestHeaders.Remove("Range");
|
||||
bytesReadSinceThrottle += bytesRead;
|
||||
|
||||
foreach (var header in RequestHeaders)
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
|
||||
request.Headers.Add("Range", $"bytes={WritePosition}-");
|
||||
|
||||
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
||||
|
||||
var totalSize = response.Content.Headers.ContentRange?.Length ??
|
||||
throw new WebException("The response did not contain a total content length.");
|
||||
|
||||
var rangeSize = response.Content.Headers.ContentLength ??
|
||||
throw new WebException($"The response did not contain a {nameof(response.Content.Headers.ContentLength)};");
|
||||
|
||||
return new BlockResponse(response, rangeSize, totalSize);
|
||||
}
|
||||
|
||||
private readonly record struct BlockResponse(HttpResponseMessage Response, long BlockSize, long FileSize) : IDisposable
|
||||
{
|
||||
public void Dispose() => Response?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
||||
private async Task DownloadToFile(BlockResponse block)
|
||||
{
|
||||
var endPosition = WritePosition + block.BlockSize;
|
||||
using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
||||
|
||||
var downloadPosition = WritePosition;
|
||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||
|
||||
try
|
||||
{
|
||||
DateTime startTime = DateTime.UtcNow;
|
||||
long bytesReadSinceThrottle = 0;
|
||||
int bytesRead;
|
||||
do
|
||||
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
|
||||
{
|
||||
bytesRead = await networkStream.ReadAsync(buff, _cancellationSource.Token);
|
||||
await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token);
|
||||
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.UtcNow).TotalMilliseconds;
|
||||
if (delayMS > 0)
|
||||
await Task.Delay(delayMS, _cancellationSource.Token);
|
||||
|
||||
downloadPosition += bytesRead;
|
||||
startTime = DateTime.UtcNow;
|
||||
bytesReadSinceThrottle = 0;
|
||||
}
|
||||
|
||||
if (downloadPosition > nextFlush)
|
||||
{
|
||||
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||
WritePosition = downloadPosition;
|
||||
OnUpdate();
|
||||
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
_downloadedPiece.Set();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region throttle
|
||||
} while (downloadPosition < endPosition && !IsCancelled && bytesRead > 0);
|
||||
|
||||
bytesReadSinceThrottle += bytesRead;
|
||||
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||
WritePosition = downloadPosition;
|
||||
|
||||
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
|
||||
{
|
||||
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.UtcNow).TotalMilliseconds;
|
||||
if (delayMS > 0)
|
||||
await Task.Delay(delayMS, _cancellationSource.Token);
|
||||
if (!IsCancelled && WritePosition < endPosition)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
startTime = DateTime.UtcNow;
|
||||
bytesReadSinceThrottle = 0;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
} while (downloadPosition < endPosition && !IsCancelled && bytesRead > 0);
|
||||
|
||||
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||
WritePosition = downloadPosition;
|
||||
|
||||
if (!IsCancelled && WritePosition < endPosition)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
if (WritePosition > endPosition)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
Serilog.Log.Information("Download was cancelled");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloadedPiece.Set();
|
||||
OnUpdate(waitForWrite: true);
|
||||
}
|
||||
if (WritePosition > endPosition)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Download Stream Reader
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanRead => _readFile.CanRead;
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanSeek => _readFile.CanSeek;
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanWrite => false;
|
||||
|
||||
[JsonIgnore]
|
||||
public override long Length
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
get
|
||||
{
|
||||
if (DownloadTask is null)
|
||||
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
||||
return ContentLength;
|
||||
}
|
||||
Serilog.Log.Information("Download was cancelled");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloadedPiece?.Set();
|
||||
OnUpdate(waitForWrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public override long Position { get => _readFile.Position; set => Seek(value, SeekOrigin.Begin); }
|
||||
#endregion
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanTimeout => false;
|
||||
#region Download Stream Reader
|
||||
|
||||
[JsonIgnore]
|
||||
public override int ReadTimeout { get => base.ReadTimeout; set => base.ReadTimeout = value; }
|
||||
[JsonIgnore]
|
||||
public override bool CanRead => _readFile.CanRead;
|
||||
|
||||
[JsonIgnore]
|
||||
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
|
||||
[JsonIgnore]
|
||||
public override bool CanSeek => _readFile.CanSeek;
|
||||
|
||||
public override void Flush() => throw new InvalidOperationException();
|
||||
public override void SetLength(long value) => throw new InvalidOperationException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new InvalidOperationException();
|
||||
[JsonIgnore]
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
[JsonIgnore]
|
||||
public override long Length
|
||||
{
|
||||
get
|
||||
{
|
||||
if (DownloadTask is null)
|
||||
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
||||
|
||||
var toRead = Math.Min(count, Length - Position);
|
||||
WaitToPosition(Position + toRead);
|
||||
return IsCancelled ? 0 : _readFile.Read(buffer, offset, count);
|
||||
return ContentLength;
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
var newPosition = origin switch
|
||||
{
|
||||
SeekOrigin.Current => Position + offset,
|
||||
SeekOrigin.End => ContentLength + offset,
|
||||
_ => offset,
|
||||
};
|
||||
|
||||
WaitToPosition(newPosition);
|
||||
return _readFile.Position = newPosition;
|
||||
}
|
||||
|
||||
/// <summary>Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns. </summary>
|
||||
/// <param name="requiredPosition">The minimum required flushed data length in <see cref="SaveFilePath"/>.</param>
|
||||
private void WaitToPosition(long requiredPosition)
|
||||
{
|
||||
while (WritePosition < requiredPosition
|
||||
&& DownloadTask?.IsCompleted is false
|
||||
&& !IsCancelled)
|
||||
{
|
||||
_downloadedPiece.WaitOne(50);
|
||||
}
|
||||
}
|
||||
|
||||
private bool disposed = false;
|
||||
|
||||
/*
|
||||
* https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.dispose?view=net-7.0
|
||||
*
|
||||
* In derived classes, do not override the Close() method, instead, put all of the
|
||||
* Stream cleanup logic in the Dispose(Boolean) method.
|
||||
*/
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !disposed)
|
||||
{
|
||||
_cancellationSource.Cancel();
|
||||
DownloadTask?.GetAwaiter().GetResult();
|
||||
_downloadedPiece?.Dispose();
|
||||
_cancellationSource?.Dispose();
|
||||
_readFile.Dispose();
|
||||
_writeFile.Dispose();
|
||||
OnUpdate(waitForWrite: true);
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public override long Position { get => _readFile.Position; set => Seek(value, SeekOrigin.Begin); }
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanTimeout => false;
|
||||
|
||||
[JsonIgnore]
|
||||
public override int ReadTimeout { get => base.ReadTimeout; set => base.ReadTimeout = value; }
|
||||
|
||||
[JsonIgnore]
|
||||
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
|
||||
|
||||
public override void Flush() => throw new InvalidOperationException();
|
||||
public override void SetLength(long value) => throw new InvalidOperationException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new InvalidOperationException();
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (DownloadTask is null)
|
||||
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
||||
|
||||
var toRead = Math.Min(count, Length - Position);
|
||||
WaitToPosition(Position + toRead);
|
||||
return IsCancelled ? 0 : _readFile.Read(buffer, offset, count);
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
var newPosition = origin switch
|
||||
{
|
||||
SeekOrigin.Current => Position + offset,
|
||||
SeekOrigin.End => ContentLength + offset,
|
||||
_ => offset,
|
||||
};
|
||||
|
||||
WaitToPosition(newPosition);
|
||||
return _readFile.Position = newPosition;
|
||||
}
|
||||
|
||||
/// <summary>Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns. </summary>
|
||||
/// <param name="requiredPosition">The minimum required flushed data length in <see cref="SaveFilePath"/>.</param>
|
||||
private void WaitToPosition(long requiredPosition)
|
||||
{
|
||||
while (WritePosition < requiredPosition
|
||||
&& DownloadTask?.IsCompleted is false
|
||||
&& !IsCancelled)
|
||||
{
|
||||
_downloadedPiece?.WaitOne(50);
|
||||
}
|
||||
}
|
||||
|
||||
private bool disposed = false;
|
||||
|
||||
/*
|
||||
* https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.dispose?view=net-7.0
|
||||
*
|
||||
* In derived classes, do not override the Close() method, instead, put all of the
|
||||
* Stream cleanup logic in the Dispose(Boolean) method.
|
||||
*/
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !Interlocked.CompareExchange(ref disposed, true, false))
|
||||
{
|
||||
_cancellationSource.Cancel();
|
||||
DownloadTask?.GetAwaiter().GetResult();
|
||||
_downloadedPiece?.Dispose();
|
||||
_cancellationSource?.Dispose();
|
||||
_readFile.Dispose();
|
||||
_writeFile.Dispose();
|
||||
OnUpdate(waitForWrite: true);
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,25 +1,24 @@
|
||||
using Dinah.Core.IO;
|
||||
|
||||
namespace AaxDecrypter
|
||||
namespace AaxDecrypter;
|
||||
|
||||
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
|
||||
{
|
||||
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
|
||||
/// <summary>Alias for Target </summary>
|
||||
public NetworkFileStream NetworkFileStream => Target;
|
||||
|
||||
/// <summary>uses path. create file if doesn't yet exist</summary>
|
||||
public NetworkFileStreamPersister(NetworkFileStream networkFileStream, string path, string? jsonPath = null)
|
||||
: base(networkFileStream, path, jsonPath) { }
|
||||
|
||||
/// <summary>load from existing file</summary>
|
||||
public NetworkFileStreamPersister(string path, string? jsonPath = null)
|
||||
: base(path, jsonPath) { }
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
/// <summary>Alias for Target </summary>
|
||||
public NetworkFileStream NetworkFileStream => Target;
|
||||
|
||||
/// <summary>uses path. create file if doesn't yet exist</summary>
|
||||
public NetworkFileStreamPersister(NetworkFileStream networkFileStream, string path, string jsonPath = null)
|
||||
: base(networkFileStream, path, jsonPath) { }
|
||||
|
||||
/// <summary>load from existing file</summary>
|
||||
public NetworkFileStreamPersister(string path, string jsonPath = null)
|
||||
: base(path, jsonPath) { }
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
NetworkFileStream?.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
if (disposing)
|
||||
NetworkFileStream?.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using FileManager;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public record TempFile
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
using FileManager;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
||||
{
|
||||
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
||||
protected override long InputFilePosition => InputFileStream.WritePosition;
|
||||
|
||||
public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic)
|
||||
: base(outDirectory, cacheDirectory, dlLic)
|
||||
{
|
||||
protected override long InputFilePosition => InputFileStream.WritePosition;
|
||||
AsyncSteps.Name = "Download Unencrypted Audiobook";
|
||||
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||
AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync;
|
||||
}
|
||||
|
||||
public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic)
|
||||
: base(outDirectory, cacheDirectory, dlLic)
|
||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
{
|
||||
await (InputFileStream.DownloadTask ?? Task.CompletedTask);
|
||||
|
||||
if (IsCanceled)
|
||||
return false;
|
||||
else
|
||||
{
|
||||
AsyncSteps.Name = "Download Unencrypted Audiobook";
|
||||
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||
AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync;
|
||||
}
|
||||
|
||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
{
|
||||
await InputFileStream.DownloadTask;
|
||||
|
||||
if (IsCanceled)
|
||||
return false;
|
||||
else
|
||||
{
|
||||
FinalizeDownload();
|
||||
var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||
FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath);
|
||||
OnTempFileCreated(tempFile);
|
||||
return true;
|
||||
}
|
||||
FinalizeDownload();
|
||||
var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||
FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath);
|
||||
OnTempFileCreated(tempFile);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>12.7.0.1</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>13.7.11</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="14.0.0" />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,267 +1,267 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace AppScaffolding
|
||||
namespace AppScaffolding;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
///
|
||||
/// directly manipulates settings files without going through domain logic.
|
||||
///
|
||||
/// for migrations only. use with caution.
|
||||
///
|
||||
///
|
||||
/// </summary>
|
||||
public static class UNSAFE_MigrationHelper
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
///
|
||||
/// directly manipulates settings files without going through domain logic.
|
||||
///
|
||||
/// for migrations only. use with caution.
|
||||
///
|
||||
///
|
||||
/// </summary>
|
||||
public static class UNSAFE_MigrationHelper
|
||||
public static string? SettingsDirectory
|
||||
=> !APPSETTINGS_TryGet(LibationFiles.LIBATION_FILES_KEY, out var value) || value is null
|
||||
? null
|
||||
: value;
|
||||
|
||||
#region appsettings.json
|
||||
|
||||
public static bool APPSETTINGS_TryGet(string key, out string? value)
|
||||
{
|
||||
public static string SettingsDirectory
|
||||
=> !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null
|
||||
? null
|
||||
: value;
|
||||
bool success = false;
|
||||
JToken? val = null;
|
||||
|
||||
#region appsettings.json
|
||||
process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false);
|
||||
|
||||
public static bool APPSETTINGS_TryGet(string key, out string value)
|
||||
value = success ? val?.Value<string>() : null;
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>only insert if not exists</summary>
|
||||
public static void APPSETTINGS_Insert(string key, string value)
|
||||
=> process_APPSETTINGS_Json(jObj => jObj.TryAdd(key, value));
|
||||
|
||||
/// <summary>only update if exists</summary>
|
||||
public static void APPSETTINGS_Update(string key, string value)
|
||||
=> process_APPSETTINGS_Json(jObj =>
|
||||
{
|
||||
bool success = false;
|
||||
JToken val = null;
|
||||
if (jObj.ContainsKey(key))
|
||||
jObj[key] = value;
|
||||
});
|
||||
|
||||
process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false);
|
||||
/// <summary>only delete if exists</summary>
|
||||
public static void APPSETTINGS_Delete(string key)
|
||||
=> process_APPSETTINGS_Json(jObj =>
|
||||
{
|
||||
if (jObj.ContainsKey(key))
|
||||
jObj.Remove(key);
|
||||
});
|
||||
|
||||
value = success ? val.Value<string>() : null;
|
||||
return success;
|
||||
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
|
||||
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
|
||||
{
|
||||
if (Configuration.Instance.LibationFiles.AppsettingsJsonFile is not string appSettingsFile)
|
||||
return;
|
||||
|
||||
var startingContents = File.ReadAllText(appSettingsFile);
|
||||
|
||||
JObject jObj;
|
||||
try
|
||||
{
|
||||
jObj = JObject.Parse(startingContents);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>only insert if not exists</summary>
|
||||
public static void APPSETTINGS_Insert(string key, string value)
|
||||
=> process_APPSETTINGS_Json(jObj => jObj.TryAdd(key, value));
|
||||
action(jObj);
|
||||
|
||||
/// <summary>only update if exists</summary>
|
||||
public static void APPSETTINGS_Update(string key, string value)
|
||||
=> process_APPSETTINGS_Json(jObj => {
|
||||
if (jObj.ContainsKey(key))
|
||||
jObj[key] = value;
|
||||
});
|
||||
if (!save)
|
||||
return;
|
||||
|
||||
/// <summary>only delete if exists</summary>
|
||||
public static void APPSETTINGS_Delete(string key)
|
||||
=> process_APPSETTINGS_Json(jObj => {
|
||||
if (jObj.ContainsKey(key))
|
||||
jObj.Remove(key);
|
||||
});
|
||||
// only save if different
|
||||
var endingContents_indented = jObj.ToString(Formatting.Indented);
|
||||
var endingContents_compact = jObj.ToString(Formatting.None);
|
||||
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
|
||||
return;
|
||||
|
||||
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
|
||||
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
|
||||
File.WriteAllText(Configuration.Instance.LibationFiles.AppsettingsJsonFile, endingContents_indented);
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
#endregion
|
||||
#region Settings.json
|
||||
|
||||
public static string? SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LibationFiles.SETTINGS_JSON);
|
||||
|
||||
public static bool Settings_TryGet(string key, out string? value)
|
||||
{
|
||||
bool success = false;
|
||||
JToken? val = null;
|
||||
|
||||
process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false);
|
||||
|
||||
value = success ? val?.Value<string>() : null;
|
||||
return success;
|
||||
}
|
||||
|
||||
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
|
||||
{
|
||||
JToken? val = null;
|
||||
|
||||
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
|
||||
|
||||
return val?.Type == jTokenType;
|
||||
}
|
||||
|
||||
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string? value)
|
||||
{
|
||||
JToken? val = null;
|
||||
|
||||
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
|
||||
|
||||
if (val?.Type == JTokenType.String)
|
||||
{
|
||||
var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile);
|
||||
|
||||
JObject jObj;
|
||||
try
|
||||
{
|
||||
jObj = JObject.Parse(startingContents);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
action(jObj);
|
||||
|
||||
if (!save)
|
||||
return;
|
||||
|
||||
// only save if different
|
||||
var endingContents_indented = jObj.ToString(Formatting.Indented);
|
||||
var endingContents_compact = jObj.ToString(Formatting.None);
|
||||
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
|
||||
return;
|
||||
|
||||
File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented);
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
#endregion
|
||||
#region Settings.json
|
||||
public const string LIBATION_FILES_KEY = "LibationFiles";
|
||||
private const string SETTINGS_JSON = "Settings.json";
|
||||
|
||||
public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON);
|
||||
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
|
||||
|
||||
public static bool Settings_TryGet(string key, out string value)
|
||||
{
|
||||
bool success = false;
|
||||
JToken val = null;
|
||||
|
||||
process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false);
|
||||
|
||||
value = success ? val.Value<string>() : null;
|
||||
return success;
|
||||
}
|
||||
|
||||
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
|
||||
{
|
||||
JToken val = null;
|
||||
|
||||
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
|
||||
|
||||
return val?.Type == jTokenType;
|
||||
}
|
||||
|
||||
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value)
|
||||
{
|
||||
JToken val = null;
|
||||
|
||||
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
|
||||
|
||||
if (val?.Type == JTokenType.String)
|
||||
{
|
||||
value = val.Value<string>();
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Settings_SetWithJsonPath(string jsonPath, string propertyName, string newValue)
|
||||
{
|
||||
if (!Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out _))
|
||||
return;
|
||||
|
||||
process_SettingsJson(jObj =>
|
||||
{
|
||||
var token = jObj.SelectToken(jsonPath);
|
||||
if (token is null
|
||||
|| token is not JObject o
|
||||
|| o[propertyName] is null)
|
||||
return;
|
||||
|
||||
var oldValue = token.Value<string>(propertyName);
|
||||
if (oldValue != newValue)
|
||||
token[propertyName] = newValue;
|
||||
});
|
||||
}
|
||||
|
||||
public static bool Settings_TryGetArrayLength(string jsonPath, out int length)
|
||||
{
|
||||
length = 0;
|
||||
|
||||
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||
return false;
|
||||
|
||||
JArray array = null;
|
||||
process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath));
|
||||
|
||||
length = array.Count;
|
||||
value = val.Value<string>();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void Settings_AddToArray(string jsonPath, string newValue)
|
||||
else
|
||||
{
|
||||
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||
return;
|
||||
|
||||
process_SettingsJson(jObj =>
|
||||
{
|
||||
var array = (JArray)jObj.SelectToken(jsonPath);
|
||||
array.Add(newValue);
|
||||
});
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Do not add if already exists</summary>
|
||||
public static void Settings_AddUniqueToArray(string arrayPath, string newValue)
|
||||
{
|
||||
if (!Settings_TryGetArrayLength(arrayPath, out var qty))
|
||||
return;
|
||||
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
var exists = Settings_TryGetFromJsonPath($"{arrayPath}[{i}]", out var value);
|
||||
if (exists && value == newValue)
|
||||
return;
|
||||
}
|
||||
|
||||
Settings_AddToArray(arrayPath, newValue);
|
||||
}
|
||||
|
||||
/// <summary>only remove if not exists</summary>
|
||||
public static void Settings_RemoveFromArray(string jsonPath, int position)
|
||||
{
|
||||
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||
return;
|
||||
|
||||
process_SettingsJson(jObj =>
|
||||
{
|
||||
var array = (JArray)jObj.SelectToken(jsonPath);
|
||||
if (position < array.Count)
|
||||
array.RemoveAt(position);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>only insert if not exists</summary>
|
||||
public static void Settings_Insert(string key, string value)
|
||||
=> process_SettingsJson(jObj => jObj.TryAdd(key, value));
|
||||
|
||||
/// <summary>only update if exists</summary>
|
||||
public static void Settings_Update(string key, string value)
|
||||
=> process_SettingsJson(jObj => {
|
||||
if (jObj.ContainsKey(key))
|
||||
jObj[key] = value;
|
||||
});
|
||||
|
||||
/// <summary>only delete if exists</summary>
|
||||
public static void Settings_Delete(string key)
|
||||
=> process_SettingsJson(jObj => {
|
||||
if (jObj.ContainsKey(key))
|
||||
jObj.Remove(key);
|
||||
});
|
||||
|
||||
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
|
||||
private static void process_SettingsJson(Action<JObject> action, bool save = true)
|
||||
{
|
||||
// only insert if not exists
|
||||
if (!SettingsJson_Exists)
|
||||
return;
|
||||
|
||||
var startingContents = File.ReadAllText(SettingsJsonPath);
|
||||
|
||||
JObject jObj;
|
||||
try
|
||||
{
|
||||
jObj = JObject.Parse(startingContents);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
action(jObj);
|
||||
|
||||
if (!save)
|
||||
return;
|
||||
|
||||
// only save if different
|
||||
var endingContents_indented = jObj.ToString(Formatting.Indented);
|
||||
var endingContents_compact = jObj.ToString(Formatting.None);
|
||||
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
|
||||
return;
|
||||
|
||||
File.WriteAllText(SettingsJsonPath, endingContents_indented);
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
#endregion
|
||||
#region LibationContext.db
|
||||
public const string LIBATION_CONTEXT = "LibationContext.db";
|
||||
public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
|
||||
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
|
||||
#endregion
|
||||
}
|
||||
|
||||
public static void Settings_SetWithJsonPath(string jsonPath, string propertyName, string newValue)
|
||||
{
|
||||
if (!Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out _))
|
||||
return;
|
||||
|
||||
process_SettingsJson(jObj =>
|
||||
{
|
||||
var token = jObj.SelectToken(jsonPath);
|
||||
if (token is null
|
||||
|| token is not JObject o
|
||||
|| o[propertyName] is null)
|
||||
return;
|
||||
|
||||
var oldValue = token.Value<string>(propertyName);
|
||||
if (oldValue != newValue)
|
||||
token[propertyName] = newValue;
|
||||
});
|
||||
}
|
||||
|
||||
public static bool Settings_TryGetArrayLength(string jsonPath, out int length)
|
||||
{
|
||||
length = 0;
|
||||
|
||||
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||
return false;
|
||||
|
||||
JArray? array = null;
|
||||
process_SettingsJson(jObj => array = jObj.SelectToken(jsonPath) as JArray);
|
||||
|
||||
length = array?.Count ?? 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void Settings_AddToArray(string jsonPath, string newValue)
|
||||
{
|
||||
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||
return;
|
||||
|
||||
process_SettingsJson(jObj =>
|
||||
{
|
||||
(jObj.SelectToken(jsonPath) as JArray)?.Add(newValue);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Do not add if already exists</summary>
|
||||
public static void Settings_AddUniqueToArray(string arrayPath, string newValue)
|
||||
{
|
||||
if (!Settings_TryGetArrayLength(arrayPath, out var qty))
|
||||
return;
|
||||
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
var exists = Settings_TryGetFromJsonPath($"{arrayPath}[{i}]", out var value);
|
||||
if (exists && value == newValue)
|
||||
return;
|
||||
}
|
||||
|
||||
Settings_AddToArray(arrayPath, newValue);
|
||||
}
|
||||
|
||||
/// <summary>only remove if not exists</summary>
|
||||
public static void Settings_RemoveFromArray(string jsonPath, int position)
|
||||
{
|
||||
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||
return;
|
||||
|
||||
process_SettingsJson(jObj =>
|
||||
{
|
||||
if (jObj.SelectToken(jsonPath) is JArray array && position < array.Count)
|
||||
array.RemoveAt(position);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>only insert if not exists</summary>
|
||||
public static void Settings_Insert(string key, string value)
|
||||
=> process_SettingsJson(jObj => jObj.TryAdd(key, value));
|
||||
|
||||
/// <summary>only update if exists</summary>
|
||||
public static void Settings_Update(string key, string value)
|
||||
=> process_SettingsJson(jObj =>
|
||||
{
|
||||
if (jObj.ContainsKey(key))
|
||||
jObj[key] = value;
|
||||
});
|
||||
|
||||
/// <summary>only delete if exists</summary>
|
||||
public static void Settings_Delete(string key)
|
||||
=> process_SettingsJson(jObj =>
|
||||
{
|
||||
if (jObj.ContainsKey(key))
|
||||
jObj.Remove(key);
|
||||
});
|
||||
|
||||
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
|
||||
private static void process_SettingsJson(Action<JObject> action, bool save = true)
|
||||
{
|
||||
// only insert if not exists
|
||||
if (!File.Exists(SettingsJsonPath))
|
||||
return;
|
||||
|
||||
var startingContents = File.ReadAllText(SettingsJsonPath);
|
||||
|
||||
JObject jObj;
|
||||
try
|
||||
{
|
||||
jObj = JObject.Parse(startingContents);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
action(jObj);
|
||||
|
||||
if (!save)
|
||||
return;
|
||||
|
||||
// only save if different
|
||||
var endingContents_indented = jObj.ToString(Formatting.Indented);
|
||||
var endingContents_compact = jObj.ToString(Formatting.None);
|
||||
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
|
||||
return;
|
||||
|
||||
File.WriteAllText(SettingsJsonPath, endingContents_indented);
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
#endregion
|
||||
#region LibationContext.db
|
||||
public const string LIBATION_CONTEXT = "LibationContext.db";
|
||||
public static string? DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
|
||||
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
|
||||
#endregion
|
||||
}
|
||||
@@ -1,26 +1,42 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AppScaffolding
|
||||
namespace AppScaffolding;
|
||||
|
||||
/// <summary>Result of a version check: definitive up-to-date, update available, or unable to determine.</summary>
|
||||
public enum VersionCheckOutcome
|
||||
{
|
||||
public partial record UpgradeProperties
|
||||
{
|
||||
public string ZipUrl { get; }
|
||||
public string HtmlUrl { get; }
|
||||
public string ZipName { get; }
|
||||
public Version LatestRelease { get; }
|
||||
public string Notes { get; }
|
||||
|
||||
public UpgradeProperties(string zipUrl, string htmlUrl, string zipName, Version latestRelease, string notes)
|
||||
{
|
||||
ZipName = zipName;
|
||||
HtmlUrl = htmlUrl;
|
||||
ZipUrl = zipUrl;
|
||||
LatestRelease = latestRelease;
|
||||
Notes = LinkStripRegex().Replace(notes, "$1");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\[(.*)\]\(.*\)")]
|
||||
private static partial Regex LinkStripRegex();
|
||||
}
|
||||
UpToDate,
|
||||
UpdateAvailable,
|
||||
UnableToDetermine,
|
||||
}
|
||||
|
||||
/// <summary>Result of checking for a new version. Use <see cref="Outcome"/> to show the right message; <see cref="UpgradeProperties"/> is set only when <see cref="Outcome"/> is <see cref="VersionCheckOutcome.UpdateAvailable"/>.</summary>
|
||||
public record VersionCheckResult(VersionCheckOutcome Outcome, UpgradeProperties? UpgradeProperties = null);
|
||||
|
||||
public partial record UpgradeProperties
|
||||
{
|
||||
public string ZipUrl { get; }
|
||||
public string HtmlUrl { get; }
|
||||
public string ZipName { get; }
|
||||
public Version LatestRelease { get; }
|
||||
public string Notes { get; }
|
||||
|
||||
public UpgradeProperties(string zipUrl, string htmlUrl, string zipName, Version latestRelease, string notes)
|
||||
{
|
||||
ZipName = zipName;
|
||||
HtmlUrl = htmlUrl;
|
||||
ZipUrl = zipUrl;
|
||||
LatestRelease = latestRelease;
|
||||
|
||||
var text = NoAppBlockRegex().Replace(notes, "");
|
||||
text = LinkStripRegex().Replace(text, "$1");
|
||||
Notes = text.Trim();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\[(.*)\]\(.*\)")]
|
||||
private static partial Regex LinkStripRegex();
|
||||
|
||||
[GeneratedRegex(@"<!-- BEGIN NO-APP -->.*?<!-- END NO-APP -->", RegexOptions.Singleline)]
|
||||
private static partial Regex NoAppBlockRegex();
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0">
|
||||
<PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0">
|
||||
<PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,76 @@
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace ApplicationServices
|
||||
namespace ApplicationServices;
|
||||
|
||||
public class BulkSetDownloadStatus
|
||||
{
|
||||
public class BulkSetDownloadStatus
|
||||
{
|
||||
private List<(string message, LiberatedStatus newStatus, IEnumerable<LibraryBook> LibraryBooks)> actionSets { get; } = new();
|
||||
private List<(string message, LiberatedStatus newStatus, IEnumerable<LibraryBook> LibraryBooks)> actionSets { get; } = new();
|
||||
|
||||
public int Count => actionSets.Count;
|
||||
public int Count => actionSets.Count;
|
||||
|
||||
public IEnumerable<string> Messages => actionSets.Select(a => a.message);
|
||||
public string AggregateMessage => $"Are you sure you want to set {Messages.Aggregate((a, b) => $"{a} and {b}")}?";
|
||||
public IEnumerable<string> Messages => actionSets.Select(a => a.message);
|
||||
public string AggregateMessage => $"Are you sure you want to set {Messages.Aggregate((a, b) => $"{a} and {b}")}?";
|
||||
|
||||
private List<LibraryBook> _libraryBooks;
|
||||
private bool _setDownloaded;
|
||||
private bool _setNotDownloaded;
|
||||
private readonly List<LibraryBook> _libraryBooks;
|
||||
private readonly bool _setDownloaded;
|
||||
private readonly bool _setNotDownloaded;
|
||||
|
||||
public BulkSetDownloadStatus(List<LibraryBook> libraryBooks, bool setDownloaded, bool setNotDownloaded)
|
||||
{
|
||||
_libraryBooks = libraryBooks;
|
||||
_setDownloaded = setDownloaded;
|
||||
_setNotDownloaded = setNotDownloaded;
|
||||
}
|
||||
public BulkSetDownloadStatus(List<LibraryBook> libraryBooks, bool setDownloaded, bool setNotDownloaded)
|
||||
{
|
||||
_libraryBooks = libraryBooks;
|
||||
_setDownloaded = setDownloaded;
|
||||
_setNotDownloaded = setNotDownloaded;
|
||||
}
|
||||
|
||||
public int Discover()
|
||||
{
|
||||
var bookExistsList = _libraryBooks
|
||||
.Select(libraryBook => new
|
||||
{
|
||||
LibraryBook = libraryBook,
|
||||
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
|
||||
})
|
||||
.ToList();
|
||||
public int Discover()
|
||||
{
|
||||
var bookExistsList = _libraryBooks
|
||||
.Select(libraryBook => new
|
||||
{
|
||||
LibraryBook = libraryBook,
|
||||
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (_setDownloaded)
|
||||
{
|
||||
var books2change = bookExistsList
|
||||
.Where(a => a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
||||
.Select(a => a.LibraryBook)
|
||||
.ToList();
|
||||
if (_setDownloaded)
|
||||
{
|
||||
var books2change = bookExistsList
|
||||
.Where(a => a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
||||
.Select(a => a.LibraryBook)
|
||||
.ToList();
|
||||
|
||||
if (books2change.Any())
|
||||
actionSets.Add((
|
||||
$"{"book".PluralizeWithCount(books2change.Count)} to 'Downloaded'",
|
||||
LiberatedStatus.Liberated,
|
||||
books2change));
|
||||
}
|
||||
if (books2change.Any())
|
||||
actionSets.Add((
|
||||
$"{"book".PluralizeWithCount(books2change.Count)} to 'Downloaded'",
|
||||
LiberatedStatus.Liberated,
|
||||
books2change));
|
||||
}
|
||||
|
||||
if (_setNotDownloaded)
|
||||
{
|
||||
var books2change = bookExistsList
|
||||
.Where(a => !a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
||||
.Select(a => a.LibraryBook)
|
||||
.ToList();
|
||||
if (_setNotDownloaded)
|
||||
{
|
||||
var books2change = bookExistsList
|
||||
.Where(a => !a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
||||
.Select(a => a.LibraryBook)
|
||||
.ToList();
|
||||
|
||||
if (books2change.Any())
|
||||
actionSets.Add((
|
||||
$"{"book".PluralizeWithCount(books2change.Count)} to 'Not Downloaded'",
|
||||
LiberatedStatus.NotLiberated,
|
||||
books2change));
|
||||
}
|
||||
if (books2change.Any())
|
||||
actionSets.Add((
|
||||
$"{"book".PluralizeWithCount(books2change.Count)} to 'Not Downloaded'",
|
||||
LiberatedStatus.NotLiberated,
|
||||
books2change));
|
||||
}
|
||||
|
||||
return Count;
|
||||
}
|
||||
return Count;
|
||||
}
|
||||
|
||||
public void Execute()
|
||||
{
|
||||
foreach (var a in actionSets)
|
||||
a.LibraryBooks.UpdateBookStatus(a.newStatus);
|
||||
}
|
||||
}
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
foreach (var a in actionSets)
|
||||
await a.LibraryBooks.UpdateBookStatusAsync(a.newStatus);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,157 @@
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace ApplicationServices
|
||||
namespace ApplicationServices;
|
||||
|
||||
public static class DbContexts
|
||||
{
|
||||
public static class DbContexts
|
||||
{
|
||||
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
|
||||
public static LibationContext GetContext()
|
||||
=> InstanceQueue<LibationContext>.WaitToCreateInstance(() =>
|
||||
{
|
||||
var context = !string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString)
|
||||
? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString)
|
||||
: LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString);
|
||||
context.Database.Migrate();
|
||||
return context;
|
||||
});
|
||||
private static bool _sqliteDbValidated;
|
||||
|
||||
/// <summary>Use for full library querying. No lazy loading</summary>
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibrary_Flat_NoTracking(includeParents);
|
||||
}
|
||||
}
|
||||
private static readonly object _initialDatabaseStatisticsCaptureLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// True after initial DB statistics were read and either written to Serilog or stored for <see cref="TryEmitPendingInitialDatabaseStatistics"/>.
|
||||
/// False if capture has not run yet or the last attempt threw (a later <see cref="GetContext"/> may retry).
|
||||
/// </summary>
|
||||
private static bool _initialDatabaseStatisticsCaptured;
|
||||
|
||||
/// <summary>Shape of the initial DB statistics log event; edit here only when changing what is logged.</summary>
|
||||
private sealed class InitialDatabaseStatistics
|
||||
{
|
||||
public required int LibraryBooksNotInTrash { get; init; }
|
||||
public required int LibraryBooksInTrash { get; init; }
|
||||
public required int BookRecords { get; init; }
|
||||
}
|
||||
|
||||
private static InitialDatabaseStatistics? _pendingInitialDbStats;
|
||||
|
||||
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
|
||||
public static LibationContext GetContext()
|
||||
{
|
||||
var context = !string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString)
|
||||
? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString)
|
||||
: LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString);
|
||||
try
|
||||
{
|
||||
LibationContextFactory.ApplyMigrations(
|
||||
context,
|
||||
string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString) ? SqliteStorage.DatabasePath : null);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Libation cannot write its SQLite database", StringComparison.Ordinal))
|
||||
{
|
||||
Log.Error(
|
||||
ex,
|
||||
"SQLite migrations failed (read-only or blocked). LibationFiles={LibationFiles} DatabasePath={DatabasePath} AppsettingsJson={AppsettingsJson}",
|
||||
Configuration.Instance.LibationFiles.Location,
|
||||
SqliteStorage.DatabasePath,
|
||||
Configuration.Instance.LibationFiles.AppsettingsJsonFile ?? "(null, LIBATION_FILES_DIR may be set)");
|
||||
throw;
|
||||
}
|
||||
|
||||
// Validate SQLite DB file was created and is accessible (once per process; OS may delay availability)
|
||||
if (!_sqliteDbValidated && string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString))
|
||||
{
|
||||
EssentialFileValidator.ValidateCreatedAndReport(SqliteStorage.DatabasePath);
|
||||
_sqliteDbValidated = true;
|
||||
}
|
||||
|
||||
TryCaptureInitialDatabaseStatistics(context);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void TryCaptureInitialDatabaseStatistics(LibationContext context)
|
||||
{
|
||||
lock (_initialDatabaseStatisticsCaptureLock)
|
||||
{
|
||||
if (_initialDatabaseStatisticsCaptured)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var (notInTrash, inTrash) = context.GetLibraryBookCountsByTrashFlag();
|
||||
var bookRecords = context.GetBookCount();
|
||||
|
||||
var stats = new InitialDatabaseStatistics
|
||||
{
|
||||
LibraryBooksNotInTrash = notInTrash,
|
||||
LibraryBooksInTrash = inTrash,
|
||||
BookRecords = bookRecords,
|
||||
};
|
||||
|
||||
if (Configuration.Instance.SerilogInitialized)
|
||||
LogInitialDatabaseStatistics(stats);
|
||||
else
|
||||
_pendingInitialDbStats = stats;
|
||||
|
||||
_initialDatabaseStatisticsCaptured = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Configuration.Instance.SerilogInitialized)
|
||||
Log.Warning(ex, "Could not capture initial database statistics");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes initial DB statistics that were captured before Serilog was configured (e.g. WinForms early library load).
|
||||
/// Call once after <see cref="Configuration.ConfigureLogging"/>.
|
||||
/// </summary>
|
||||
public static void TryEmitPendingInitialDatabaseStatistics()
|
||||
{
|
||||
var pending = Interlocked.Exchange(ref _pendingInitialDbStats, null);
|
||||
if (pending is not null)
|
||||
LogInitialDatabaseStatistics(pending);
|
||||
}
|
||||
|
||||
private static void LogInitialDatabaseStatistics(InitialDatabaseStatistics stats) =>
|
||||
Log.Logger.Information("Initial database statistics. {@DbStats}", stats);
|
||||
|
||||
/// <summary>Use for full library querying. No lazy loading</summary>
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibrary_Flat_NoTracking(includeParents);
|
||||
}
|
||||
|
||||
public static List<LibraryBook> GetUnliberated_Flat_NoTracking()
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetUnLiberated_Flat_NoTracking();
|
||||
}
|
||||
|
||||
public static List<LibraryBook> GetDeletedLibraryBooks()
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetDeletedLibraryBooks();
|
||||
}
|
||||
|
||||
/// <summary>How many books belong in the search index. A row count only; no entities are loaded.</summary>
|
||||
public static int GetIndexableBookCount()
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetIndexableBookCount();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many <see cref="LibraryBook"/> rows are in the trash. A row count only; no entities are loaded,
|
||||
/// so this is cheap enough to refresh whenever the library changes.
|
||||
/// </summary>
|
||||
public static int GetTrashedBookCount()
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibraryBookCountsByTrashFlag().InTrash;
|
||||
}
|
||||
|
||||
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
|
||||
{
|
||||
using var context = GetContext();
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
internal class ExportDto(LibraryBook libBook)
|
||||
{
|
||||
[Name("Account")]
|
||||
public string Account { get; } = libBook.Account;
|
||||
|
||||
[Name("Date Added to library")]
|
||||
public DateTime DateAdded { get; } = libBook.DateAdded;
|
||||
|
||||
[Name("Is Audible Plus?")]
|
||||
public bool IsAudiblePlus { get; } = libBook.IsAudiblePlus;
|
||||
|
||||
[Name("Absent from last scan?")]
|
||||
public bool AbsentFromLastScan { get; } = libBook.AbsentFromLastScan;
|
||||
|
||||
[Name("Audible Product Id")]
|
||||
public string AudibleProductId { get; } = libBook.Book.AudibleProductId;
|
||||
|
||||
[Name("Locale")]
|
||||
public string Locale { get; } = libBook.Book.Locale;
|
||||
|
||||
[Name("Title")]
|
||||
public string Title { get; } = libBook.Book.Title;
|
||||
|
||||
[Name("Subtitle")]
|
||||
public string Subtitle { get; } = libBook.Book.Subtitle;
|
||||
|
||||
[Name("Authors")]
|
||||
public string AuthorNames { get; } = libBook.Book.AuthorNames;
|
||||
|
||||
[Name("Narrators")]
|
||||
public string NarratorNames { get; } = libBook.Book.NarratorNames;
|
||||
|
||||
[Name("Length In Minutes")]
|
||||
public int LengthInMinutes { get; } = libBook.Book.LengthInMinutes;
|
||||
|
||||
[Name("Description")]
|
||||
public string Description { get; } = libBook.Book.Description;
|
||||
|
||||
[Name("Publisher")]
|
||||
public string? Publisher { get; } = libBook.Book.Publisher;
|
||||
|
||||
[Name("Has PDF")]
|
||||
public bool HasPdf { get; } = libBook.Book.HasPdf;
|
||||
|
||||
[Name("Series Names")]
|
||||
public string SeriesNames { get; } = libBook.Book.SeriesNames();
|
||||
|
||||
[Name("Series Order")]
|
||||
public string SeriesOrder { get; } = libBook.Book.SeriesLink?.Any() is true ? string.Join(", ", libBook.Book.SeriesLink.Select(sl => $"{sl.Order} : {sl.Series.Name}")) : "";
|
||||
|
||||
[Name("Community Rating: Overall")]
|
||||
public float? CommunityRatingOverall { get; } = ZeroIsNull(libBook.Book.Rating?.OverallRating);
|
||||
|
||||
[Name("Community Rating: Performance")]
|
||||
public float? CommunityRatingPerformance { get; } = ZeroIsNull(libBook.Book.Rating?.PerformanceRating);
|
||||
|
||||
[Name("Community Rating: Story")]
|
||||
public float? CommunityRatingStory { get; } = ZeroIsNull(libBook.Book.Rating?.StoryRating);
|
||||
|
||||
[Name("Cover Id")]
|
||||
public string? PictureId { get; } = libBook.Book.PictureId;
|
||||
|
||||
[Name("Cover Id Large")]
|
||||
public string? PictureLarge { get; } = libBook.Book.PictureLarge;
|
||||
|
||||
[Name("Is Abridged?")]
|
||||
public bool IsAbridged { get; } = libBook.Book.IsAbridged;
|
||||
|
||||
[Name("Date Published")]
|
||||
public DateTime? DatePublished { get; } = libBook.Book.DatePublished;
|
||||
|
||||
[Name("Categories")]
|
||||
public string CategoriesNames { get; } = string.Join("; ", libBook.Book.LowestCategoryNames());
|
||||
|
||||
[Name("My Rating: Overall")]
|
||||
public float? MyRatingOverall { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.OverallRating);
|
||||
|
||||
[Name("My Rating: Performance")]
|
||||
public float? MyRatingPerformance { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.PerformanceRating);
|
||||
|
||||
[Name("My Rating: Story")]
|
||||
public float? MyRatingStory { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.StoryRating);
|
||||
|
||||
[Name("My Libation Tags")]
|
||||
public string MyLibationTags { get; } = libBook.Book.UserDefinedItem.Tags;
|
||||
|
||||
[Name("Book Liberated Status")]
|
||||
public string BookStatus { get; } = libBook.Book.UserDefinedItem.BookStatus.ToString();
|
||||
|
||||
[Name("PDF Liberated Status")]
|
||||
public string? PdfStatus { get; } = libBook.Book.UserDefinedItem.PdfStatus.ToString();
|
||||
|
||||
[Name("Content Type")]
|
||||
public string ContentType { get; } = libBook.Book.ContentType.ToString();
|
||||
|
||||
[Name("Language")]
|
||||
public string? Language { get; } = libBook.Book.Language;
|
||||
|
||||
[Name("Last Downloaded")]
|
||||
public DateTime? LastDownloaded { get; } = libBook.Book.UserDefinedItem.LastDownloaded;
|
||||
|
||||
[Name("Last Downloaded Version")]
|
||||
public string? LastDownloadedVersion { get; } = libBook.Book.UserDefinedItem.LastDownloadedVersion?.ToString();
|
||||
|
||||
[Name("Is Finished?")]
|
||||
public bool IsFinished { get; } = libBook.Book.UserDefinedItem.IsFinished;
|
||||
|
||||
[Name("Is Spatial?")]
|
||||
public bool IsSpatial { get; } = libBook.Book.IsSpatial;
|
||||
|
||||
[Name("Included Until")]
|
||||
public DateTime? IncludedUntil { get; } = libBook.IncludedUntil;
|
||||
|
||||
[Name("Last Downloaded File Version")]
|
||||
public string? LastDownloadedFileVersion { get; } = libBook.Book.UserDefinedItem.LastDownloadedFileVersion;
|
||||
|
||||
[Ignore /* csv ignore */]
|
||||
public AudioFormat? LastDownloadedFormat { get; } = libBook.Book.UserDefinedItem.LastDownloadedFormat;
|
||||
|
||||
[Name("Last Downloaded Codec"), JsonIgnore]
|
||||
public string CodecString => LastDownloadedFormat?.CodecString ?? "";
|
||||
|
||||
[Name("Last Downloaded Sample rate"), JsonIgnore]
|
||||
public int? SampleRate => LastDownloadedFormat?.SampleRate;
|
||||
|
||||
[Name("Last Downloaded Audio Channels"), JsonIgnore]
|
||||
public int? ChannelCount => LastDownloadedFormat?.ChannelCount;
|
||||
|
||||
[Name("Last Downloaded Bitrate"), JsonIgnore]
|
||||
public int? BitRate => LastDownloadedFormat?.BitRate;
|
||||
|
||||
private static float? ZeroIsNull(float? value) => value is 0 ? null : value;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using LibationSearchEngine;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
public interface ISearchEngine
|
||||
{
|
||||
SearchResultSet? GetSearchResultSet(string? searchString);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,322 +3,79 @@ using CsvHelper;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ApplicationServices
|
||||
namespace ApplicationServices;
|
||||
|
||||
public static class LibraryExporter
|
||||
{
|
||||
public class ExportDto
|
||||
public static void ToCsv(string saveFilePath, IEnumerable<LibraryBook>? libraryBooks = null)
|
||||
{
|
||||
public static string GetName(string fieldName)
|
||||
{
|
||||
var property = typeof(ExportDto).GetProperty(fieldName);
|
||||
var attribute = property.GetCustomAttributes(typeof(NameAttribute), true)[0];
|
||||
var description = (NameAttribute)attribute;
|
||||
var text = description.Names;
|
||||
return text[0];
|
||||
}
|
||||
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||
var dtos = libraryBooks.ToDtos();
|
||||
if (dtos.Count == 0)
|
||||
return;
|
||||
|
||||
[Name("Account")]
|
||||
public string Account { get; set; }
|
||||
|
||||
[Name("Date Added to library")]
|
||||
public DateTime DateAdded { get; set; }
|
||||
|
||||
[Name("Audible Product Id")]
|
||||
public string AudibleProductId { get; set; }
|
||||
|
||||
[Name("Locale")]
|
||||
public string Locale { get; set; }
|
||||
|
||||
[Name("Title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[Name("Subtitle")]
|
||||
public string Subtitle { get; set; }
|
||||
|
||||
[Name("Authors")]
|
||||
public string AuthorNames { get; set; }
|
||||
|
||||
[Name("Narrators")]
|
||||
public string NarratorNames { get; set; }
|
||||
|
||||
[Name("Length In Minutes")]
|
||||
public int LengthInMinutes { get; set; }
|
||||
|
||||
[Name("Description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[Name("Publisher")]
|
||||
public string Publisher { get; set; }
|
||||
|
||||
[Name("Has PDF")]
|
||||
public bool HasPdf { get; set; }
|
||||
|
||||
[Name("Series Names")]
|
||||
public string SeriesNames { get; set; }
|
||||
|
||||
[Name("Series Order")]
|
||||
public string SeriesOrder { get; set; }
|
||||
|
||||
[Name("Community Rating: Overall")]
|
||||
public float? CommunityRatingOverall { get; set; }
|
||||
|
||||
[Name("Community Rating: Performance")]
|
||||
public float? CommunityRatingPerformance { get; set; }
|
||||
|
||||
[Name("Community Rating: Story")]
|
||||
public float? CommunityRatingStory { get; set; }
|
||||
|
||||
[Name("Cover Id")]
|
||||
public string PictureId { get; set; }
|
||||
|
||||
[Name("Is Abridged?")]
|
||||
public bool IsAbridged { get; set; }
|
||||
|
||||
[Name("Date Published")]
|
||||
public DateTime? DatePublished { get; set; }
|
||||
|
||||
[Name("Categories")]
|
||||
public string CategoriesNames { get; set; }
|
||||
|
||||
[Name("My Rating: Overall")]
|
||||
public float? MyRatingOverall { get; set; }
|
||||
|
||||
[Name("My Rating: Performance")]
|
||||
public float? MyRatingPerformance { get; set; }
|
||||
|
||||
[Name("My Rating: Story")]
|
||||
public float? MyRatingStory { get; set; }
|
||||
|
||||
[Name("My Libation Tags")]
|
||||
public string MyLibationTags { get; set; }
|
||||
|
||||
[Name("Book Liberated Status")]
|
||||
public string BookStatus { get; set; }
|
||||
|
||||
[Name("PDF Liberated Status")]
|
||||
public string PdfStatus { get; set; }
|
||||
|
||||
[Name("Content Type")]
|
||||
public string ContentType { get; set; }
|
||||
|
||||
[Name("Language")]
|
||||
public string Language { get; set; }
|
||||
|
||||
[Name("LastDownloaded")]
|
||||
public DateTime? LastDownloaded { get; set; }
|
||||
|
||||
[Name("LastDownloadedVersion")]
|
||||
public string LastDownloadedVersion { get; set; }
|
||||
|
||||
[Name("IsFinished")]
|
||||
public bool IsFinished { get; set; }
|
||||
|
||||
[Name("IsSpatial")]
|
||||
public bool IsSpatial { get; set; }
|
||||
|
||||
[Name("Last Downloaded File Version")]
|
||||
public string LastDownloadedFileVersion { get; set; }
|
||||
|
||||
[Ignore /* csv ignore */]
|
||||
public AudioFormat LastDownloadedFormat { get; set; }
|
||||
|
||||
[Name("Last Downloaded Codec"), JsonIgnore]
|
||||
public string CodecString => LastDownloadedFormat?.CodecString ?? "";
|
||||
|
||||
[Name("Last Downloaded Sample rate"), JsonIgnore]
|
||||
public int? SampleRate => LastDownloadedFormat?.SampleRate;
|
||||
|
||||
[Name("Last Downloaded Audio Channels"), JsonIgnore]
|
||||
public int? ChannelCount => LastDownloadedFormat?.ChannelCount;
|
||||
|
||||
[Name("Last Downloaded Bitrate"), JsonIgnore]
|
||||
public int? BitRate => LastDownloadedFormat?.BitRate;
|
||||
using var csv = new CsvWriter(new System.IO.StreamWriter(saveFilePath), CultureInfo.CurrentCulture);
|
||||
csv.WriteHeader(typeof(ExportDto));
|
||||
csv.NextRecord();
|
||||
csv.WriteRecords(dtos);
|
||||
}
|
||||
|
||||
public static class LibToDtos
|
||||
public static void ToJson(string saveFilePath, IEnumerable<LibraryBook>? libraryBooks = null)
|
||||
{
|
||||
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
||||
=> library.Select(a => new ExportDto
|
||||
{
|
||||
Account = a.Account,
|
||||
DateAdded = a.DateAdded,
|
||||
AudibleProductId = a.Book.AudibleProductId,
|
||||
Locale = a.Book.Locale,
|
||||
Title = a.Book.Title,
|
||||
Subtitle = a.Book.Subtitle,
|
||||
AuthorNames = a.Book.AuthorNames(),
|
||||
NarratorNames = a.Book.NarratorNames(),
|
||||
LengthInMinutes = a.Book.LengthInMinutes,
|
||||
Description = a.Book.Description,
|
||||
Publisher = a.Book.Publisher,
|
||||
HasPdf = a.Book.HasPdf(),
|
||||
SeriesNames = a.Book.SeriesNames(),
|
||||
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(),
|
||||
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating.ZeroIsNull(),
|
||||
CommunityRatingStory = a.Book.Rating?.StoryRating.ZeroIsNull(),
|
||||
PictureId = a.Book.PictureId,
|
||||
IsAbridged = a.Book.IsAbridged,
|
||||
DatePublished = a.Book.DatePublished,
|
||||
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
|
||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating.ZeroIsNull(),
|
||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating.ZeroIsNull(),
|
||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating.ZeroIsNull(),
|
||||
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||
ContentType = a.Book.ContentType.ToString(),
|
||||
Language = a.Book.Language,
|
||||
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
||||
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
||||
IsFinished = a.Book.UserDefinedItem.IsFinished,
|
||||
IsSpatial = a.Book.IsSpatial,
|
||||
LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "",
|
||||
LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat
|
||||
}).ToList();
|
||||
|
||||
private static float? ZeroIsNull(this float value) => value is 0 ? null : value;
|
||||
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||
var dtos = libraryBooks.ToDtos();
|
||||
var serializer = new JsonSerializer();
|
||||
using var writer = new JsonTextWriter(new System.IO.StreamWriter(saveFilePath)) { Formatting = Formatting.Indented };
|
||||
serializer.Serialize(writer, dtos);
|
||||
}
|
||||
public static class LibraryExporter
|
||||
|
||||
public static void ToXlsx(string saveFilePath, IEnumerable<LibraryBook>? libraryBooks = null)
|
||||
{
|
||||
public static void ToCsv(string saveFilePath)
|
||||
{
|
||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
if (!dtos.Any())
|
||||
return;
|
||||
using var writer = new System.IO.StreamWriter(saveFilePath);
|
||||
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
||||
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||
var dtos = libraryBooks.ToDtos();
|
||||
|
||||
csv.WriteHeader(typeof(ExportDto));
|
||||
csv.NextRecord();
|
||||
csv.WriteRecords(dtos);
|
||||
using var workbook = new XLWorkbook();
|
||||
var sheet = workbook.AddWorksheet("Library");
|
||||
var columns = typeof(ExportDto).GetProperties().Where(p => p.GetCustomAttribute<NameAttribute>() is not null).ToArray();
|
||||
|
||||
// headers
|
||||
var currentRow = sheet.FirstRow();
|
||||
var currentCell = currentRow.FirstCell();
|
||||
foreach (var column in columns)
|
||||
{
|
||||
currentCell.Value = GetColumnName(column);
|
||||
currentCell.Style.Font.Bold = true;
|
||||
currentCell = currentCell.CellRight();
|
||||
}
|
||||
|
||||
public static void ToJson(string saveFilePath)
|
||||
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
|
||||
|
||||
// Add data rows
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
var json = JsonConvert.SerializeObject(dtos, Formatting.Indented);
|
||||
System.IO.File.WriteAllText(saveFilePath, json);
|
||||
}
|
||||
currentRow = currentRow.RowBelow();
|
||||
currentCell = currentRow.FirstCell();
|
||||
|
||||
public static void ToXlsx(string saveFilePath)
|
||||
{
|
||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
using var workbook = new XLWorkbook();
|
||||
var sheet = workbook.AddWorksheet("Library");
|
||||
|
||||
|
||||
// headers
|
||||
var columns = new[] {
|
||||
nameof(ExportDto.Account),
|
||||
nameof(ExportDto.DateAdded),
|
||||
nameof(ExportDto.AudibleProductId),
|
||||
nameof(ExportDto.Locale),
|
||||
nameof(ExportDto.Title),
|
||||
nameof(ExportDto.Subtitle),
|
||||
nameof(ExportDto.AuthorNames),
|
||||
nameof(ExportDto.NarratorNames),
|
||||
nameof(ExportDto.LengthInMinutes),
|
||||
nameof(ExportDto.Description),
|
||||
nameof(ExportDto.Publisher),
|
||||
nameof(ExportDto.HasPdf),
|
||||
nameof(ExportDto.SeriesNames),
|
||||
nameof(ExportDto.SeriesOrder),
|
||||
nameof(ExportDto.CommunityRatingOverall),
|
||||
nameof(ExportDto.CommunityRatingPerformance),
|
||||
nameof(ExportDto.CommunityRatingStory),
|
||||
nameof(ExportDto.PictureId),
|
||||
nameof(ExportDto.IsAbridged),
|
||||
nameof(ExportDto.DatePublished),
|
||||
nameof(ExportDto.CategoriesNames),
|
||||
nameof(ExportDto.MyRatingOverall),
|
||||
nameof(ExportDto.MyRatingPerformance),
|
||||
nameof(ExportDto.MyRatingStory),
|
||||
nameof(ExportDto.MyLibationTags),
|
||||
nameof(ExportDto.BookStatus),
|
||||
nameof(ExportDto.PdfStatus),
|
||||
nameof(ExportDto.ContentType),
|
||||
nameof(ExportDto.Language),
|
||||
nameof(ExportDto.LastDownloaded),
|
||||
nameof(ExportDto.LastDownloadedVersion),
|
||||
nameof(ExportDto.IsFinished),
|
||||
nameof(ExportDto.IsSpatial),
|
||||
nameof(ExportDto.LastDownloadedFileVersion),
|
||||
nameof(ExportDto.CodecString),
|
||||
nameof(ExportDto.SampleRate),
|
||||
nameof(ExportDto.ChannelCount),
|
||||
nameof(ExportDto.BitRate)
|
||||
};
|
||||
|
||||
int rowIndex = 1, col = 1;
|
||||
var headerRow = sheet.Row(rowIndex++);
|
||||
foreach (var c in columns)
|
||||
foreach (var column in columns)
|
||||
{
|
||||
var headerCell = headerRow.Cell(col++);
|
||||
headerCell.Value = ExportDto.GetName(c);
|
||||
headerCell.Style.Font.Bold = true;
|
||||
var value = column.GetValue(dto);
|
||||
currentCell.Value = XLCellValue.FromObject(value);
|
||||
currentCell.Style.DateFormat.Format = currentCell.DataType is XLDataType.DateTime ? dateFormat : string.Empty;
|
||||
currentCell = currentCell.CellRight();
|
||||
}
|
||||
|
||||
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
|
||||
|
||||
// Add data rows
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
col = 1;
|
||||
var row = sheet.Row(rowIndex++);
|
||||
|
||||
row.Cell(col++).Value = dto.Account;
|
||||
row.Cell(col++).SetDate(dto.DateAdded, dateFormat);
|
||||
row.Cell(col++).Value = dto.AudibleProductId;
|
||||
row.Cell(col++).Value = dto.Locale;
|
||||
row.Cell(col++).Value = dto.Title;
|
||||
row.Cell(col++).Value = dto.Subtitle;
|
||||
row.Cell(col++).Value = dto.AuthorNames;
|
||||
row.Cell(col++).Value = dto.NarratorNames;
|
||||
row.Cell(col++).Value = dto.LengthInMinutes;
|
||||
row.Cell(col++).Value = dto.Description;
|
||||
row.Cell(col++).Value = dto.Publisher;
|
||||
row.Cell(col++).Value = dto.HasPdf;
|
||||
row.Cell(col++).Value = dto.SeriesNames;
|
||||
row.Cell(col++).Value = dto.SeriesOrder;
|
||||
row.Cell(col++).Value = dto.CommunityRatingOverall;
|
||||
row.Cell(col++).Value = dto.CommunityRatingPerformance;
|
||||
row.Cell(col++).Value = dto.CommunityRatingStory;
|
||||
row.Cell(col++).Value = dto.PictureId;
|
||||
row.Cell(col++).Value = dto.IsAbridged;
|
||||
row.Cell(col++).SetDate(dto.DatePublished, dateFormat);
|
||||
row.Cell(col++).Value = dto.CategoriesNames;
|
||||
row.Cell(col++).Value = dto.MyRatingOverall;
|
||||
row.Cell(col++).Value = dto.MyRatingPerformance;
|
||||
row.Cell(col++).Value = dto.MyRatingStory;
|
||||
row.Cell(col++).Value = dto.MyLibationTags;
|
||||
row.Cell(col++).Value = dto.BookStatus;
|
||||
row.Cell(col++).Value = dto.PdfStatus;
|
||||
row.Cell(col++).Value = dto.ContentType;
|
||||
row.Cell(col++).Value = dto.Language;
|
||||
row.Cell(col++).SetDate(dto.LastDownloaded, dateFormat);
|
||||
row.Cell(col++).Value = dto.LastDownloadedVersion;
|
||||
row.Cell(col++).Value = dto.IsFinished;
|
||||
row.Cell(col++).Value = dto.IsSpatial;
|
||||
row.Cell(col++).Value = dto.LastDownloadedFileVersion;
|
||||
row.Cell(col++).Value = dto.CodecString;
|
||||
row.Cell(col++).Value = dto.SampleRate;
|
||||
row.Cell(col++).Value = dto.ChannelCount;
|
||||
row.Cell(col++).Value = dto.BitRate;
|
||||
}
|
||||
|
||||
workbook.SaveAs(saveFilePath);
|
||||
}
|
||||
|
||||
private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat)
|
||||
{
|
||||
cell.Value = value;
|
||||
cell.Style.DateFormat.Format = dateFormat;
|
||||
}
|
||||
workbook.SaveAs(saveFilePath);
|
||||
}
|
||||
|
||||
private static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
||||
=> library.Select(a => new ExportDto(a)).ToList();
|
||||
|
||||
private static string GetColumnName(PropertyInfo property)
|
||||
=> property.GetCustomAttribute<NameAttribute>()?.Names?.FirstOrDefault() ?? property.Name;
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using LibationSearchEngine;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// The main search engine used Libation.
|
||||
/// Acts as an adapter to SearchEngineCommands.Search()
|
||||
/// </summary>
|
||||
public class MainSearchEngine : ISearchEngine
|
||||
{
|
||||
public static MainSearchEngine Instance { get; } = new MainSearchEngine();
|
||||
private MainSearchEngine() { }
|
||||
public SearchResultSet? GetSearchResultSet(string? searchString)
|
||||
=> string.IsNullOrEmpty(searchString) ? null : SearchEngineCommands.Search(searchString);
|
||||
}
|
||||
@@ -8,179 +8,178 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace ApplicationServices
|
||||
namespace ApplicationServices;
|
||||
|
||||
public static class RecordExporter
|
||||
{
|
||||
public static class RecordExporter
|
||||
public static void ToXlsx(string saveFilePath, IEnumerable<IRecord> records)
|
||||
{
|
||||
public static void ToXlsx(string saveFilePath, IEnumerable<IRecord> records)
|
||||
if (!records.Any())
|
||||
return;
|
||||
|
||||
using var workbook = new XLWorkbook();
|
||||
var worksheet = workbook.AddWorksheet("Records");
|
||||
|
||||
// headers
|
||||
var columns = new List<string>
|
||||
{
|
||||
if (!records.Any())
|
||||
return;
|
||||
nameof(Type.Name),
|
||||
nameof(IRecord.Created),
|
||||
nameof(IRecord.Start) + "_ms",
|
||||
};
|
||||
|
||||
using var workbook = new XLWorkbook();
|
||||
var worksheet = workbook.AddWorksheet("Records");
|
||||
if (records.OfType<IAnnotation>().Any())
|
||||
{
|
||||
columns.Add(nameof(IAnnotation.AnnotationId));
|
||||
columns.Add(nameof(IAnnotation.LastModified));
|
||||
}
|
||||
if (records.OfType<IRangeAnnotation>().Any())
|
||||
{
|
||||
columns.Add(nameof(IRangeAnnotation.End) + "_ms");
|
||||
columns.Add(nameof(IRangeAnnotation.Text));
|
||||
}
|
||||
if (records.OfType<Clip>().Any())
|
||||
columns.Add(nameof(Clip.Title));
|
||||
|
||||
// headers
|
||||
var columns = new List<string>
|
||||
int rowIndex = 1, col = 1;
|
||||
var headerRow = worksheet.Row(rowIndex++);
|
||||
foreach (var c in columns)
|
||||
{
|
||||
var headerCell = headerRow.Cell(col++);
|
||||
headerCell.Value = c;
|
||||
headerCell.Style.Font.Bold = true;
|
||||
}
|
||||
|
||||
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
|
||||
|
||||
// Add data rows
|
||||
foreach (var record in records)
|
||||
{
|
||||
col = 1;
|
||||
var row = worksheet.Row(rowIndex++);
|
||||
|
||||
row.Cell(col++).Value = record.GetType().Name;
|
||||
row.Cell(col++).SetDate(record.Created.DateTime, dateFormat);
|
||||
row.Cell(col++).Value = record.Start.TotalMilliseconds;
|
||||
|
||||
if (record is IAnnotation annotation)
|
||||
{
|
||||
nameof(Type.Name),
|
||||
nameof(IRecord.Created),
|
||||
nameof(IRecord.Start) + "_ms",
|
||||
};
|
||||
|
||||
if (records.OfType<IAnnotation>().Any())
|
||||
{
|
||||
columns.Add(nameof(IAnnotation.AnnotationId));
|
||||
columns.Add(nameof(IAnnotation.LastModified));
|
||||
}
|
||||
if (records.OfType<IRangeAnnotation>().Any())
|
||||
{
|
||||
columns.Add(nameof(IRangeAnnotation.End) + "_ms");
|
||||
columns.Add(nameof(IRangeAnnotation.Text));
|
||||
}
|
||||
if (records.OfType<Clip>().Any())
|
||||
columns.Add(nameof(Clip.Title));
|
||||
row.Cell(col++).Value = annotation.AnnotationId;
|
||||
row.Cell(col++).SetDate(annotation.LastModified.DateTime, dateFormat);
|
||||
|
||||
int rowIndex = 1, col = 1;
|
||||
var headerRow = worksheet.Row(rowIndex++);
|
||||
foreach (var c in columns)
|
||||
{
|
||||
var headerCell = headerRow.Cell(col++);
|
||||
headerCell.Value = c;
|
||||
headerCell.Style.Font.Bold = true;
|
||||
}
|
||||
|
||||
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
|
||||
|
||||
// Add data rows
|
||||
foreach (var record in records)
|
||||
{
|
||||
col = 1;
|
||||
var row = worksheet.Row(rowIndex++);
|
||||
|
||||
row.Cell(col++).Value = record.GetType().Name;
|
||||
row.Cell(col++).SetDate(record.Created.DateTime, dateFormat);
|
||||
row.Cell(col++).Value = record.Start.TotalMilliseconds;
|
||||
|
||||
if (record is IAnnotation annotation)
|
||||
if (annotation is IRangeAnnotation rangeAnnotation)
|
||||
{
|
||||
row.Cell(col++).Value = rangeAnnotation.End.TotalMilliseconds;
|
||||
row.Cell(col++).Value = rangeAnnotation.Text;
|
||||
|
||||
row.Cell(col++).Value = annotation.AnnotationId;
|
||||
row.Cell(col++).SetDate(annotation.LastModified.DateTime, dateFormat);
|
||||
|
||||
if (annotation is IRangeAnnotation rangeAnnotation)
|
||||
{
|
||||
row.Cell(col++).Value = rangeAnnotation.End.TotalMilliseconds;
|
||||
row.Cell(col++).Value = rangeAnnotation.Text;
|
||||
|
||||
if (rangeAnnotation is Clip clip)
|
||||
row.Cell(col++).Value = clip.Title;
|
||||
}
|
||||
if (rangeAnnotation is Clip clip)
|
||||
row.Cell(col++).Value = clip.Title;
|
||||
}
|
||||
}
|
||||
|
||||
workbook.SaveAs(saveFilePath);
|
||||
}
|
||||
|
||||
private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat)
|
||||
{
|
||||
cell.Value = value;
|
||||
cell.Style.DateFormat.Format = dateFormat;
|
||||
}
|
||||
public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable<IRecord> records)
|
||||
{
|
||||
if (!records.Any())
|
||||
return;
|
||||
workbook.SaveAs(saveFilePath);
|
||||
}
|
||||
|
||||
var recordsEx = extendRecords(records);
|
||||
private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat)
|
||||
{
|
||||
cell.Value = value;
|
||||
cell.Style.DateFormat.Format = dateFormat;
|
||||
}
|
||||
public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable<IRecord> records)
|
||||
{
|
||||
if (!records.Any())
|
||||
return;
|
||||
|
||||
var recordsObj = new JObject
|
||||
var recordsEx = extendRecords(records);
|
||||
|
||||
var recordsObj = new JObject
|
||||
{
|
||||
{ "title", libraryBook.Book.TitleWithSubtitle},
|
||||
{ "asin", libraryBook.Book.AudibleProductId},
|
||||
{ "exportTime", DateTime.Now},
|
||||
{ "records", JArray.FromObject(recordsEx) }
|
||||
};
|
||||
|
||||
System.IO.File.WriteAllText(saveFilePath, recordsObj.ToString(Newtonsoft.Json.Formatting.Indented));
|
||||
}
|
||||
|
||||
public static void ToCsv(string saveFilePath, IEnumerable<IRecord> records)
|
||||
{
|
||||
if (!records.Any())
|
||||
return;
|
||||
|
||||
using var writer = new System.IO.StreamWriter(saveFilePath);
|
||||
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
||||
|
||||
//Write headers for the present record type that has the most properties
|
||||
if (records.OfType<Clip>().Any())
|
||||
csv.WriteHeader(typeof(ClipEx));
|
||||
else if (records.OfType<Note>().Any())
|
||||
csv.WriteHeader(typeof(NoteEx));
|
||||
else if (records.OfType<Bookmark>().Any())
|
||||
csv.WriteHeader(typeof(BookmarkEx));
|
||||
else
|
||||
csv.WriteHeader(typeof(LastHeardEx));
|
||||
|
||||
var recordsEx = extendRecords(records);
|
||||
|
||||
csv.NextRecord();
|
||||
csv.WriteRecords(recordsEx.OfType<ClipEx>());
|
||||
csv.WriteRecords(recordsEx.OfType<NoteEx>());
|
||||
csv.WriteRecords(recordsEx.OfType<BookmarkEx>());
|
||||
csv.WriteRecords(recordsEx.OfType<LastHeardEx>());
|
||||
}
|
||||
|
||||
private static IEnumerable<IRecordEx> extendRecords(IEnumerable<IRecord> records)
|
||||
=> records
|
||||
.Select<IRecord, IRecordEx>(
|
||||
r => r switch
|
||||
{
|
||||
{ "title", libraryBook.Book.TitleWithSubtitle},
|
||||
{ "asin", libraryBook.Book.AudibleProductId},
|
||||
{ "exportTime", DateTime.Now},
|
||||
{ "records", JArray.FromObject(recordsEx) }
|
||||
};
|
||||
Clip c => new ClipEx(nameof(Clip), c),
|
||||
Note n => new NoteEx(nameof(Note), n),
|
||||
Bookmark b => new BookmarkEx(nameof(Bookmark), b),
|
||||
LastHeard l => new LastHeardEx(nameof(LastHeard), l),
|
||||
_ => throw new InvalidOperationException(),
|
||||
});
|
||||
|
||||
System.IO.File.WriteAllText(saveFilePath, recordsObj.ToString(Newtonsoft.Json.Formatting.Indented));
|
||||
}
|
||||
|
||||
public static void ToCsv(string saveFilePath, IEnumerable<IRecord> records)
|
||||
private interface IRecordEx { string Type { get; } }
|
||||
|
||||
private record LastHeardEx : LastHeard, IRecordEx
|
||||
{
|
||||
public string Type { get; }
|
||||
public LastHeardEx(string type, LastHeard original) : base(original)
|
||||
{
|
||||
if (!records.Any())
|
||||
return;
|
||||
|
||||
using var writer = new System.IO.StreamWriter(saveFilePath);
|
||||
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
||||
|
||||
//Write headers for the present record type that has the most properties
|
||||
if (records.OfType<Clip>().Any())
|
||||
csv.WriteHeader(typeof(ClipEx));
|
||||
else if (records.OfType<Note>().Any())
|
||||
csv.WriteHeader(typeof(NoteEx));
|
||||
else if (records.OfType<Bookmark>().Any())
|
||||
csv.WriteHeader(typeof(BookmarkEx));
|
||||
else
|
||||
csv.WriteHeader(typeof(LastHeardEx));
|
||||
|
||||
var recordsEx = extendRecords(records);
|
||||
|
||||
csv.NextRecord();
|
||||
csv.WriteRecords(recordsEx.OfType<ClipEx>());
|
||||
csv.WriteRecords(recordsEx.OfType<NoteEx>());
|
||||
csv.WriteRecords(recordsEx.OfType<BookmarkEx>());
|
||||
csv.WriteRecords(recordsEx.OfType<LastHeardEx>());
|
||||
Type = type;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<IRecordEx> extendRecords(IEnumerable<IRecord> records)
|
||||
=> records
|
||||
.Select<IRecord, IRecordEx>(
|
||||
r => r switch
|
||||
{
|
||||
Clip c => new ClipEx(nameof(Clip), c),
|
||||
Note n => new NoteEx(nameof(Note), n),
|
||||
Bookmark b => new BookmarkEx(nameof(Bookmark), b),
|
||||
LastHeard l => new LastHeardEx(nameof(LastHeard), l),
|
||||
_ => throw new InvalidOperationException(),
|
||||
});
|
||||
|
||||
|
||||
private interface IRecordEx { string Type { get; } }
|
||||
|
||||
private record LastHeardEx : LastHeard, IRecordEx
|
||||
private record BookmarkEx : Bookmark, IRecordEx
|
||||
{
|
||||
public string Type { get; }
|
||||
public BookmarkEx(string type, Bookmark original) : base(original)
|
||||
{
|
||||
public string Type { get; }
|
||||
public LastHeardEx(string type, LastHeard original) : base(original)
|
||||
{
|
||||
Type = type;
|
||||
}
|
||||
Type = type;
|
||||
}
|
||||
}
|
||||
|
||||
private record BookmarkEx : Bookmark, IRecordEx
|
||||
private record NoteEx : Note, IRecordEx
|
||||
{
|
||||
public string Type { get; }
|
||||
public NoteEx(string type, Note original) : base(original)
|
||||
{
|
||||
public string Type { get; }
|
||||
public BookmarkEx(string type, Bookmark original) : base(original)
|
||||
{
|
||||
Type = type;
|
||||
}
|
||||
Type = type;
|
||||
}
|
||||
}
|
||||
|
||||
private record NoteEx : Note, IRecordEx
|
||||
private record ClipEx : Clip, IRecordEx
|
||||
{
|
||||
public string Type { get; }
|
||||
public ClipEx(string type, Clip original) : base(original)
|
||||
{
|
||||
public string Type { get; }
|
||||
public NoteEx(string type, Note original) : base(original)
|
||||
{
|
||||
Type = type;
|
||||
}
|
||||
}
|
||||
|
||||
private record ClipEx : Clip, IRecordEx
|
||||
{
|
||||
public string Type { get; }
|
||||
public ClipEx(string type, Clip original) : base(original)
|
||||
{
|
||||
Type = type;
|
||||
}
|
||||
Type = type;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,29 @@
|
||||
using System;
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
|
||||
namespace ApplicationServices
|
||||
namespace ApplicationServices;
|
||||
|
||||
public static class SearchEngineCommands
|
||||
{
|
||||
public static class SearchEngineCommands
|
||||
{
|
||||
#region Search
|
||||
public static SearchResultSet Search(string searchString) => performSafeQuery(e =>
|
||||
e.Search(searchString)
|
||||
);
|
||||
/// <summary>Serializes all search index access so only one reader/writer is active at a time, avoiding write.lock contention.</summary>
|
||||
private static readonly object IndexLock = new();
|
||||
|
||||
private static T performSafeQuery<T>(Func<SearchEngine, T> func)
|
||||
#region Search
|
||||
public static SearchResultSet Search(string searchString) => performSafeQuery(e =>
|
||||
e.Search(searchString)
|
||||
);
|
||||
|
||||
private static T performSafeQuery<T>(Func<SearchEngine, T> func)
|
||||
{
|
||||
lock (IndexLock)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
repairShortIndex(engine);
|
||||
try
|
||||
{
|
||||
return func(engine);
|
||||
@@ -26,57 +33,157 @@ namespace ApplicationServices
|
||||
fullReIndex(engine);
|
||||
return func(engine);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
public static event EventHandler SearchEngineUpdated;
|
||||
|
||||
#region Update
|
||||
private static bool isUpdating;
|
||||
|
||||
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.
|
||||
// I did not benchmark before choosing the number here
|
||||
if (books.Count() > 15)
|
||||
FullReIndex();
|
||||
else
|
||||
catch (Exception ex) when (SearchEngine.IsRecoverableCorruptIndexException(ex))
|
||||
{
|
||||
foreach (var book in books)
|
||||
UpdateUserDefinedItems(book);
|
||||
Log.Warning(ex, "Search index unreadable or corrupt; rebuilding and retrying query.");
|
||||
fullReIndex(engine);
|
||||
return func(engine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
||||
public static void FullReIndex(List<LibraryBook> libraryBooks)
|
||||
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
|
||||
/// <summary>Set once the index has been measured against the library, so the check costs one query per run.</summary>
|
||||
private static bool indexCounted;
|
||||
|
||||
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
|
||||
{
|
||||
e.UpdateLiberatedStatus(book);
|
||||
e.UpdateTags(book.Book.AudibleProductId, book.Book.UserDefinedItem.Tags);
|
||||
e.UpdateUserRatings(book);
|
||||
}
|
||||
);
|
||||
/// <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;
|
||||
|
||||
private static void performSafeCommand(Action<SearchEngine> action)
|
||||
// before the work, not after: a check that throws must not run on every query for the rest of the session
|
||||
indexCounted = true;
|
||||
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
update(action);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
fullReIndex(new SearchEngine());
|
||||
update(action);
|
||||
}
|
||||
}
|
||||
var indexed = engine.GetIndexedBookCount();
|
||||
|
||||
private static void update(Action<SearchEngine> action)
|
||||
{
|
||||
if (action is null)
|
||||
// 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.
|
||||
// I did not benchmark before choosing the number here
|
||||
if (books.Count() > 15)
|
||||
FullReIndex();
|
||||
else
|
||||
{
|
||||
foreach (var book in books)
|
||||
UpdateUserDefinedItems(book);
|
||||
}
|
||||
}
|
||||
|
||||
public static void FullReIndex() => performSafeCommand(e => fullReIndex(e));
|
||||
public static void FullReIndex(List<LibraryBook> libraryBooks)
|
||||
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
|
||||
|
||||
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
|
||||
{
|
||||
e.UpdateLiberatedStatus(book);
|
||||
e.UpdateTags(book.Book.AudibleProductId, book.Book.UserDefinedItem.Tags);
|
||||
e.UpdateUserRatings(book);
|
||||
}
|
||||
);
|
||||
|
||||
private static void performSafeCommand(Action<SearchEngine> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
update(action);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
fullReIndex(new SearchEngine());
|
||||
update(action);
|
||||
}
|
||||
catch (Exception ex) when (SearchEngine.IsRecoverableCorruptIndexException(ex))
|
||||
{
|
||||
Log.Warning(ex, "Search index unreadable or corrupt; rebuilding and retrying.");
|
||||
fullReIndex(new SearchEngine());
|
||||
update(action);
|
||||
}
|
||||
}
|
||||
|
||||
private static void update(Action<SearchEngine> action)
|
||||
{
|
||||
if (action is null)
|
||||
return;
|
||||
|
||||
lock (IndexLock)
|
||||
{
|
||||
// support nesting incl recursion
|
||||
var prevIsUpdating = isUpdating;
|
||||
try
|
||||
@@ -85,22 +192,24 @@ namespace ApplicationServices
|
||||
|
||||
action(new SearchEngine());
|
||||
if (!prevIsUpdating)
|
||||
SearchEngineUpdated?.Invoke(null, null);
|
||||
SearchEngineUpdated?.Invoke(null, EventArgs.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isUpdating = prevIsUpdating;
|
||||
}
|
||||
}
|
||||
|
||||
private static void fullReIndex(SearchEngine engine)
|
||||
{
|
||||
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
fullReIndex(engine, library);
|
||||
}
|
||||
|
||||
private static void fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
|
||||
=> engine.CreateNewIndex(libraryBooks);
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <returns>How many books could not be indexed.</returns>
|
||||
private static int fullReIndex(SearchEngine engine)
|
||||
{
|
||||
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
return fullReIndex(engine, library);
|
||||
}
|
||||
|
||||
/// <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,44 @@
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using LibationSearchEngine;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// A temporary search engine created in InProgress/TempSearchEngine
|
||||
/// Used for Trash Bin searches to avoid interfering with the main search engine
|
||||
/// </summary>
|
||||
public class TempSearchEngine : ISearchEngine
|
||||
{
|
||||
public static string SearchEnginePath { get; }
|
||||
= System.IO.Path.Combine(Configuration.Instance.InProgress, nameof(TempSearchEngine));
|
||||
private SearchEngine SearchEngine { get; } = new SearchEngine(SearchEnginePath);
|
||||
|
||||
public bool ReindexSearchEngine(IEnumerable<LibraryBook> books)
|
||||
{
|
||||
try
|
||||
{
|
||||
SearchEngine.CreateNewIndex(books, overwrite: true);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public SearchResultSet? GetSearchResultSet(string? searchString)
|
||||
{
|
||||
if (string.IsNullOrEmpty(searchString))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return SearchEngine.Search(searchString);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,103 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApi;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Security;
|
||||
using LibationFileManager;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace AudibleUtilities
|
||||
namespace AudibleUtilities;
|
||||
|
||||
[DebuggerDisplay("{AccountId,nq} - {Locale}")]
|
||||
public class Account : IUpdatable, ILogMasked
|
||||
{
|
||||
public class Account : IUpdatable
|
||||
public event EventHandler? Updated;
|
||||
private void update(object? sender = null, EventArgs? e = null)
|
||||
=> Updated?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
// canonical. immutable. email or phone number
|
||||
public string AccountId { get; }
|
||||
|
||||
// user-friendly, non-canonical name. mutable
|
||||
public string? AccountName
|
||||
{
|
||||
public event EventHandler Updated;
|
||||
private void update(object sender = null, EventArgs e = null)
|
||||
=> Updated?.Invoke(this, new EventArgs());
|
||||
|
||||
// canonical. immutable. email or phone number
|
||||
public string AccountId { get; }
|
||||
|
||||
// user-friendly, non-canonical name. mutable
|
||||
private string _accountName;
|
||||
public string AccountName
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
get => _accountName;
|
||||
set
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
var v = value.Trim();
|
||||
if (v == _accountName)
|
||||
return;
|
||||
_accountName = v;
|
||||
update();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
var v = value.Trim();
|
||||
if (v == field)
|
||||
return;
|
||||
field = v;
|
||||
update();
|
||||
}
|
||||
|
||||
// whether to include this account when scanning libraries.
|
||||
// technically this is an app setting; not an attribute of account. but since it's managed with accounts, it makes sense to put this exception-to-the-rule here
|
||||
private bool _libraryScan = true;
|
||||
public bool LibraryScan
|
||||
{
|
||||
get => _libraryScan;
|
||||
set
|
||||
{
|
||||
if (value == _libraryScan)
|
||||
return;
|
||||
_libraryScan = value;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
private string _decryptKey = "";
|
||||
/// <summary>aka: activation bytes</summary>
|
||||
public string DecryptKey
|
||||
{
|
||||
get => _decryptKey;
|
||||
set
|
||||
{
|
||||
var v = (value ?? "").Trim();
|
||||
if (v == _decryptKey)
|
||||
return;
|
||||
_decryptKey = v;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
private Identity _identity;
|
||||
public Identity IdentityTokens
|
||||
{
|
||||
get => _identity;
|
||||
set
|
||||
{
|
||||
if (_identity is null && value is null)
|
||||
return;
|
||||
|
||||
if (_identity is not null)
|
||||
_identity.Updated -= update;
|
||||
|
||||
if (value is not null)
|
||||
value.Updated += update;
|
||||
|
||||
_identity = value;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public Locale Locale => IdentityTokens?.Locale;
|
||||
|
||||
public Account(string accountId)
|
||||
{
|
||||
AccountId = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountId, nameof(accountId)).Trim();
|
||||
}
|
||||
|
||||
public override string ToString() => $"{AccountId} - {Locale?.Name ?? "[empty]"}";
|
||||
|
||||
public string MaskedLogEntry => @$"AccountId={mask(AccountId)}|AccountName={mask(AccountName)}|Locale={Locale?.Name ?? "[empty]"}";
|
||||
private static string mask(string str)
|
||||
=> str is null ? "[null]"
|
||||
: str == string.Empty ? "[empty]"
|
||||
: str.ToMask();
|
||||
}
|
||||
|
||||
// whether to include this account when scanning libraries.
|
||||
// technically this is an app setting; not an attribute of account. but since it's managed with accounts, it makes sense to put this exception-to-the-rule here
|
||||
public bool LibraryScan
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
if (value == field)
|
||||
return;
|
||||
field = value;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.Reveal() ?? "").Trim();
|
||||
if (v == field.Reveal())
|
||||
return;
|
||||
field = v;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
public Identity? IdentityTokens
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
if (field is null && value is null)
|
||||
return;
|
||||
|
||||
if (field is not null)
|
||||
field.Updated -= update;
|
||||
|
||||
if (value is not null)
|
||||
value.Updated += update;
|
||||
|
||||
field = value;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public Locale? Locale => IdentityTokens?.Locale;
|
||||
|
||||
public Account(string accountId)
|
||||
{
|
||||
AccountId = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountId, nameof(accountId)).Trim();
|
||||
}
|
||||
|
||||
/// <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]"
|
||||
: str == string.Empty ? "[empty]"
|
||||
: str.ToMask();
|
||||
}
|
||||
@@ -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})";
|
||||
}
|
||||
}
|
||||
Loaded 100 of 904 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user