mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-08 11:47:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6efc64b927 | ||
|
|
d2cc9ff26f | ||
|
|
cfe4770387 | ||
|
|
33967adb15 | ||
|
|
9ef8dca4d5 | ||
|
|
af1029f4b5 | ||
|
|
63e7f0f44e | ||
|
|
65623e8e81 | ||
|
|
a5912efa0c | ||
|
|
dba3e6ca8e | ||
|
|
a4bc39ee6b | ||
|
|
675fecec67 | ||
|
|
be1eac5692 | ||
|
|
8bad7d90c3 | ||
|
|
e145e46579 | ||
|
|
5fd8ac481b | ||
|
|
b6c7612ec4 | ||
|
|
3456eb6c35 | ||
|
|
b90cc67b09 | ||
|
|
550a99ac90 | ||
|
|
9ed89eca9e | ||
|
|
197d645e10 | ||
|
|
bdfc99f209 | ||
|
|
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 | ||
|
|
ba15eb1a95 | ||
|
|
6263fedf84 | ||
|
|
0cbffc3f6c | ||
|
|
5f093b06ec | ||
|
|
f815c5fd47 | ||
|
|
69a8eaad4a | ||
|
|
01b5c18b2b | ||
|
|
5634fee2aa | ||
|
|
e98e4f10bc | ||
|
|
ec32ff77b2 | ||
|
|
683c984246 | ||
|
|
0fa5c4eb1e | ||
|
|
7507044b82 | ||
|
|
017902ab52 | ||
|
|
dcc5c1c640 | ||
|
|
19efa8c918 | ||
|
|
a34efb5e61 | ||
|
|
9533f80e89 | ||
|
|
fa238a0915 | ||
|
|
f98adef9e9 | ||
|
|
d85e5a0f98 | ||
|
|
365ac8167f | ||
|
|
4720779373 | ||
|
|
0c512162ab | ||
|
|
09ca419faf | ||
|
|
a2b1f13601 | ||
|
|
f4e7cf3418 | ||
|
|
8492a7ea3a | ||
|
|
1b5db9b28f | ||
|
|
5589a6cbd5 | ||
|
|
bfbad988c0 | ||
|
|
8db8615713 | ||
|
|
e8fa3f14b3 | ||
|
|
71552f2417 | ||
|
|
5b96f96a80 | ||
|
|
afffeb953c | ||
|
|
f4d8685058 | ||
|
|
b4cfd18976 | ||
|
|
e12548dacd | ||
|
|
fd95ac7a9c | ||
|
|
f7cd2b106b | ||
|
|
07e51f2191 | ||
|
|
fcd79c5561 | ||
|
|
ba98820989 | ||
|
|
b07e61e6a8 | ||
|
|
8c3fd19c70 | ||
|
|
fa8f761771 | ||
|
|
2c882e883d | ||
|
|
74c76a7414 | ||
|
|
17a0c21453 | ||
|
|
fc9c9dfe48 | ||
|
|
d5f0e39981 | ||
|
|
0f6493f4af | ||
|
|
454b490a06 | ||
|
|
ffea2648aa | ||
|
|
1ac967500c | ||
|
|
39e9f675d2 | ||
|
|
ed5afe5d0f | ||
|
|
ab075d0bef | ||
|
|
7fb1adb41b | ||
|
|
9735a8391c | ||
|
|
dbdfdbc536 | ||
|
|
3b86fc405f | ||
|
|
4ea7f04921 | ||
|
|
5b59b442ab | ||
|
|
b5d9c0a27a | ||
|
|
f5cbf89e13 | ||
|
|
00dc9e020d | ||
|
|
bfa0e4d338 | ||
|
|
5ceda408da | ||
|
|
716b1923a4 | ||
|
|
1148d8125d | ||
|
|
690fd10e42 | ||
|
|
736fbbf82f | ||
|
|
eda100b7ac | ||
|
|
ceb007500d | ||
|
|
05fad01624 | ||
|
|
e1d789ccdc | ||
|
|
d0f00f3f1e | ||
|
|
6ab82dba7b | ||
|
|
0045202334 | ||
|
|
4c80813651 | ||
|
|
6b637b35ab | ||
|
|
9b55ffa715 | ||
|
|
65da7890f1 | ||
|
|
72f92ec6c0 | ||
|
|
4efc084375 | ||
|
|
f955daa5ed | ||
|
|
144ab2162a | ||
|
|
6d0c4a9b3c | ||
|
|
8a682533c1 | ||
|
|
cecabc911e | ||
|
|
e35f5209dc | ||
|
|
4ffe70af0e | ||
|
|
233ba3184f | ||
|
|
ac4c168725 | ||
|
|
db588629c0 | ||
|
|
29be091a4b | ||
|
|
82a48db57b | ||
|
|
9f0f32a462 | ||
|
|
f64239b5ee | ||
|
|
bc8a35aedd | ||
|
|
2fca6b8b91 | ||
|
|
bc2eddd2dd | ||
|
|
ae012548bd | ||
|
|
76a59873ea | ||
|
|
3129fdba7b | ||
|
|
1771813849 | ||
|
|
7024bbf823 | ||
|
|
663f70b8bf | ||
|
|
7741e3caff | ||
|
|
c82eefa768 | ||
|
|
0e4231906a | ||
|
|
9bca84dca4 | ||
|
|
ca30fd41c6 | ||
|
|
be96f99461 | ||
|
|
f017fe419f | ||
|
|
ed42916cb2 | ||
|
|
0bb5bba3c8 | ||
|
|
a887bf4619 | ||
|
|
53eebcd6ba | ||
|
|
a09ae1316d | ||
|
|
7088bd4b8d | ||
|
|
b27325cdcb | ||
|
|
accedeb1b1 | ||
|
|
c98c7c095a | ||
|
|
9b217a4e18 | ||
|
|
a62a9ffc5b | ||
|
|
08aebf8ecf | ||
|
|
2f082a9656 | ||
|
|
1f473039e1 | ||
|
|
0f4197924e | ||
|
|
0f7ffacdf8 | ||
|
|
829b35c5a8 | ||
|
|
614b05d5ff | ||
|
|
26ccc77b47 | ||
|
|
64fb2ccf7c | ||
|
|
890747a902 | ||
|
|
1fdcea929f | ||
|
|
7848366818 | ||
|
|
40b4915b65 | ||
|
|
80b86086ca | ||
|
|
bff9b67b72 | ||
|
|
657a7bb6bc | ||
|
|
f0d7a7bf64 | ||
|
|
8bc098e7bd | ||
|
|
9280b29512 | ||
|
|
d8e9b9c505 | ||
|
|
554b308364 | ||
|
|
8d7872a376 | ||
|
|
747451d243 | ||
|
|
7e79e98771 | ||
|
|
4b7939541a | ||
|
|
a3734c76b1 | ||
|
|
ced4ea6c17 | ||
|
|
35ca6f2621 | ||
|
|
4dab16837e | ||
|
|
1cf889eed7 | ||
|
|
b65b1e819b | ||
|
|
3d50643ab0 | ||
|
|
abd18d74b0 | ||
|
|
0e49df06b8 | ||
|
|
38cc3e9725 | ||
|
|
c9af2bba4b | ||
|
|
2191c1536d | ||
|
|
5b9bf2fbb0 | ||
|
|
9b1ce8c1d7 | ||
|
|
9f8075041b | ||
|
|
944645379e | ||
|
|
cc72517284 | ||
|
|
0044820415 | ||
|
|
9f24027de1 | ||
|
|
24f95cb03d | ||
|
|
3aeea54615 | ||
|
|
f511041781 | ||
|
|
da9dc91469 | ||
|
|
e04e70d333 | ||
|
|
e0b566ee60 | ||
|
|
bf15d7302e | ||
|
|
8f01c644c0 | ||
|
|
ebd2cc96c5 | ||
|
|
0d1cc42ca7 | ||
|
|
e126dd09ce | ||
|
|
ec497f4f81 | ||
|
|
248fdfd2bc | ||
|
|
35862d619a | ||
|
|
ac2c67985d | ||
|
|
f8ae303417 | ||
|
|
0d24caeac2 | ||
|
|
7f1b357c52 | ||
|
|
ef67ae9d6a | ||
|
|
f35c82d59d | ||
|
|
10c01f4147 | ||
|
|
9366b3baca | ||
|
|
20e792c589 | ||
|
|
dfb63d3275 | ||
|
|
19db226f5a | ||
|
|
203ab00865 | ||
|
|
b11a4887d7 | ||
|
|
e73fc5e1eb | ||
|
|
8561a15061 | ||
|
|
28ba62aead | ||
|
|
176294cc55 | ||
|
|
152b0e362d | ||
|
|
4600d029dc | ||
|
|
1a5684799c | ||
|
|
0df17a2296 | ||
|
|
45472abd1f | ||
|
|
f2ea4539f2 | ||
|
|
52d3b9cb67 | ||
|
|
3d87f2cd9b | ||
|
|
e4a3d2ac79 | ||
|
|
8aa157f2f6 | ||
|
|
5ab6c1fe70 | ||
|
|
b23c46f79f | ||
|
|
0e987eef00 | ||
|
|
ace3d80e41 | ||
|
|
4bfb4e73ce | ||
|
|
7805a3ef11 | ||
|
|
08ca2a2db3 | ||
|
|
64a85b6aab | ||
|
|
1a38273d5f | ||
|
|
303dd7c471 | ||
|
|
313e3846c3 | ||
|
|
422c86345e | ||
|
|
ce952417fb | ||
|
|
5f4551822b | ||
|
|
3aebc7c885 | ||
|
|
3982edd0f1 | ||
|
|
f4dafac28f | ||
|
|
1090d29f74 | ||
|
|
1c336e1fe9 | ||
|
|
c7e9e9ac1e | ||
|
|
8232b2b5e5 | ||
|
|
9ca879cc3d | ||
|
|
ece48eb6d7 | ||
|
|
bffaea6026 | ||
|
|
e2aae85fd7 | ||
|
|
1777dc5a7e | ||
|
|
2dfe00f428 | ||
|
|
2cd0a022ff | ||
|
|
5d7ac699e6 | ||
|
|
7d806e0f3e | ||
|
|
0a9e489f48 | ||
|
|
17612dacd2 | ||
|
|
e61ad41d5a | ||
|
|
c77f2e2162 | ||
|
|
bfcd226795 | ||
|
|
0af7c4d90a | ||
|
|
e4826388be | ||
|
|
98a1fa4dda | ||
|
|
81e9ab7fb2 | ||
|
|
9c82d34ba4 | ||
|
|
a384bceab0 | ||
|
|
545540d9a4 | ||
|
|
f402912a92 | ||
|
|
aab4f1d9d6 | ||
|
|
f183b587b8 | ||
|
|
733a091ebd | ||
|
|
9043ea6334 | ||
|
|
40890f242a | ||
|
|
6c03f525bf | ||
|
|
dcda1a0cc2 | ||
|
|
e509f842e4 | ||
|
|
faa2e04b9f | ||
|
|
71afb5c9f4 | ||
|
|
d90ef3f4d4 | ||
|
|
f84bb753e9 | ||
|
|
b34970bd47 | ||
|
|
a37eb383cd | ||
|
|
614965e1ab | ||
|
|
52d611a74c | ||
|
|
653381b1df | ||
|
|
4e067f5b5b | ||
|
|
ee05ca4eb2 | ||
|
|
65e12d9a8f | ||
|
|
5dc1bafb94 | ||
|
|
3010f80834 | ||
|
|
6c20b85200 | ||
|
|
bf87180fe9 | ||
|
|
ae9aac789f | ||
|
|
e6cd182872 | ||
|
|
7eeb2dcd7f | ||
|
|
71bb0571d1 | ||
|
|
7bb5b2968e | ||
|
|
b051283fca | ||
|
|
53af2ee39e | ||
|
|
fab32a1744 | ||
|
|
e2dabc8a53 | ||
|
|
fd82f7ae5a | ||
|
|
df9535a83d | ||
|
|
85b6792468 | ||
|
|
e37abbf276 | ||
|
|
c3938c49a9 | ||
|
|
7658f21d7c | ||
|
|
c4827fc761 | ||
|
|
649b52af1d | ||
|
|
da06511951 | ||
|
|
88d3e5ff0c | ||
|
|
5f99e594d8 | ||
|
|
981a183992 | ||
|
|
ac036f65f1 | ||
|
|
b36e110b49 | ||
|
|
ef3c71a939 | ||
|
|
b2af93bed9 | ||
|
|
1f427919e6 | ||
|
|
c9c5bbb687 | ||
|
|
efbefa2784 | ||
|
|
51aabe5dd4 | ||
|
|
1c668adff8 | ||
|
|
4170dcc1d5 | ||
|
|
68cfae1d58 | ||
|
|
a790c7535c | ||
|
|
3b7d5a354f | ||
|
|
a9375f1520 | ||
|
|
47c9fcb883 | ||
|
|
5f5c9f65ed | ||
|
|
1417a4b992 | ||
|
|
2d6120f0c4 | ||
|
|
2a25b7e0ad | ||
|
|
4766ea7372 | ||
|
|
d195dd07dc | ||
|
|
bcbb7610ad | ||
|
|
6c5773df24 | ||
|
|
211f15af25 | ||
|
|
e3b0f80016 | ||
|
|
7b2c7e49e5 | ||
|
|
6a40d19393 | ||
|
|
3167744111 | ||
|
|
b6a3ba335a | ||
|
|
fa1ddc726a | ||
|
|
b3b0662dec | ||
|
|
5cb22cfd24 | ||
|
|
e911344850 | ||
|
|
8ec7e5a9d2 | ||
|
|
e1f749c3da | ||
|
|
ba060d15aa | ||
|
|
93fde236c8 | ||
|
|
13aad1a7cb | ||
|
|
65c64c4504 | ||
|
|
14ba04c28b | ||
|
|
96e886d207 | ||
|
|
c7279574a9 | ||
|
|
a522e1ff7e | ||
|
|
9ebc4444bd | ||
|
|
525afdf050 | ||
|
|
983cdf6ad5 | ||
|
|
09bb32a435 | ||
|
|
817ef33fbd | ||
|
|
be52b496a6 | ||
|
|
c0c99db6fa | ||
|
|
a1c8fb5921 | ||
|
|
4576c0e193 | ||
|
|
d592e9435e | ||
|
|
9ce6cb54ab | ||
|
|
c15d49fc64 | ||
|
|
99be869aa9 | ||
|
|
a0e875a79c | ||
|
|
6134becc70 | ||
|
|
eadf7cff79 | ||
|
|
87ca76f9cb | ||
|
|
43d1019059 | ||
|
|
ed87ded77a | ||
|
|
56d4205360 | ||
|
|
1f839606ae | ||
|
|
6eebe652d4 | ||
|
|
5fff22a0e1 | ||
|
|
cd7040cdc7 | ||
|
|
97b792868f | ||
|
|
984f931f67 | ||
|
|
e0dd9b845a | ||
|
|
f1c8b320c2 | ||
|
|
9b7d0cd909 | ||
|
|
99592ff84e | ||
|
|
f97cfe77f9 | ||
|
|
2954cb961b | ||
|
|
1e29b98b82 | ||
|
|
8b76da0dbe | ||
|
|
0a749d2d88 | ||
|
|
9ed6c1fd0d | ||
|
|
9825e2b552 | ||
|
|
011efe3676 | ||
|
|
2bdcc221f5 | ||
|
|
21bedca367 | ||
|
|
074fe79ded | ||
|
|
ac8c090c4c | ||
|
|
ade693bebb | ||
|
|
9bc53e45cd | ||
|
|
7d4eaa11e7 | ||
|
|
4521c5d5ed | ||
|
|
eb39f994e1 | ||
|
|
c19833b34e | ||
|
|
6dcf456d06 | ||
|
|
8a87462cf5 | ||
|
|
9da2a44eff | ||
|
|
7af8d8aa70 | ||
|
|
4801f37e7c | ||
|
|
4f5df44d40 | ||
|
|
63e28b13c1 | ||
|
|
f92b2b65b2 | ||
|
|
f7a4a95e3b | ||
|
|
71b8e9e51c | ||
|
|
c6788ccb48 | ||
|
|
0503ee1404 | ||
|
|
303192c6c3 | ||
|
|
6e21e96aa2 | ||
|
|
d1d0a7e487 | ||
|
|
2fd8ea91e1 | ||
|
|
92ee0b2e6d | ||
|
|
f0b5ae1cdc | ||
|
|
eb659cc7d7 | ||
|
|
cdd5f229d3 | ||
|
|
29edfb7c3f | ||
|
|
c0cb454d45 | ||
|
|
970a77c9e9 | ||
|
|
3488c8e0f5 | ||
|
|
5133720cc8 | ||
|
|
4150746f45 | ||
|
|
3a95e1e72f | ||
|
|
c74e26e1af | ||
|
|
ae54c95d46 | ||
|
|
56e6bd164b | ||
|
|
9b28bdceaa | ||
|
|
b7f7d9004d | ||
|
|
1be0991e62 | ||
|
|
ff8a2e59c5 | ||
|
|
453904261b | ||
|
|
f9340db90a | ||
|
|
5cd329dd26 | ||
|
|
b2a882b79d | ||
|
|
75df78a2f7 | ||
|
|
3ad52cbecc | ||
|
|
27b2fe741c | ||
|
|
d19fe2250c | ||
|
|
d16d0c8de2 | ||
|
|
c213d5d9f6 | ||
|
|
c73a023572 | ||
|
|
67389917fd | ||
|
|
b3264d5f42 | ||
|
|
962d9b550f | ||
|
|
91ce7272ae | ||
|
|
2f64ca6856 | ||
|
|
cfe5db436c | ||
|
|
3653fc8094 | ||
|
|
662c0ec871 | ||
|
|
44d39eabdb | ||
|
|
a0550d5c97 | ||
|
|
14eaca6d45 | ||
|
|
93ccc206ef | ||
|
|
d3f0fd711e | ||
|
|
3f4604e877 | ||
|
|
c316709af8 | ||
|
|
221d5c7f1c | ||
|
|
5a86a1a27b | ||
|
|
dc5e55de68 | ||
|
|
ee37864a42 | ||
|
|
efe347667c | ||
|
|
f27a18bdbb | ||
|
|
d1834659d9 | ||
|
|
7842b521d7 | ||
|
|
0822f0229d | ||
|
|
26aee4d29d | ||
|
|
17a80a23a8 | ||
|
|
e26fc9ca62 | ||
|
|
a03ccf1143 | ||
|
|
bb8dd615db | ||
|
|
9022a2889f | ||
|
|
ef049a3b02 | ||
|
|
77409750aa | ||
|
|
1702130b01 | ||
|
|
b6d1a7e3ba | ||
|
|
2907ba5c13 | ||
|
|
6df6c79ac8 | ||
|
|
3a9ca5d827 | ||
|
|
e1e663e327 | ||
|
|
4b00d5fd84 | ||
|
|
02dbf8aad0 | ||
|
|
8326389f5c | ||
|
|
34535b3ce1 | ||
|
|
7e5366ab95 | ||
|
|
690de9bc5c | ||
|
|
c976aa2bb2 | ||
|
|
27f659285d | ||
|
|
423a5e7720 | ||
|
|
9152e12fe1 | ||
|
|
f471c53139 | ||
|
|
66d055bb90 | ||
|
|
2bbb35363a | ||
|
|
1d3687cf9e | ||
|
|
daf925157f | ||
|
|
40eec9e674 | ||
|
|
6f0782053e | ||
|
|
04ad033ba0 | ||
|
|
f12f8ba3ee | ||
|
|
cc6feb21ff | ||
|
|
c4f2ec428d | ||
|
|
59689cb647 | ||
|
|
d7f3758ebc | ||
|
|
49775b019c | ||
|
|
e55e969349 | ||
|
|
42a93bfac1 | ||
|
|
f86c77a546 | ||
|
|
88c35e2a56 | ||
|
|
b405e8b6b2 | ||
|
|
92d283187d | ||
|
|
51b8cfe71f | ||
|
|
c80da5357b | ||
|
|
736d7c4a5f | ||
|
|
f175b7592e | ||
|
|
415e6e7bc6 | ||
|
|
d6a413e8d9 | ||
|
|
3049de6246 | ||
|
|
fb9b4eb77e | ||
|
|
e65b6c76a8 | ||
|
|
167a021eb1 | ||
|
|
ff3ac2d6fd | ||
|
|
f733079a49 | ||
|
|
893d68190d | ||
|
|
97f94d8782 | ||
|
|
4b2ce0c2d1 | ||
|
|
ee00417c6f | ||
|
|
768afd8ecd | ||
|
|
32c3fa85ce | ||
|
|
6986c8f018 | ||
|
|
f69c2b1cfc | ||
|
|
b11675c36a | ||
|
|
379c2ed62d | ||
|
|
7c8489b52f | ||
|
|
c61a863edd | ||
|
|
1d54f32ef3 | ||
|
|
fabe4afd94 | ||
|
|
61efa3c0c1 | ||
|
|
fe70daf0bc | ||
|
|
34033e7947 | ||
|
|
e8c63e9a6e | ||
|
|
9315165f80 | ||
|
|
ce624399ba | ||
|
|
63e9700c4a | ||
|
|
914e574bf8 | ||
|
|
b94f9bbc15 | ||
|
|
4e34834c35 | ||
|
|
3211b2dc85 | ||
|
|
ea6adeb58f | ||
|
|
90eccbf2f6 | ||
|
|
668cd7dba8 | ||
|
|
c08b2b575c | ||
|
|
07eaa48e10 | ||
|
|
3cf5fc1d99 | ||
|
|
15ad753fa1 | ||
|
|
75b984bdb2 | ||
|
|
f586d1d59f | ||
|
|
cb91a591f0 | ||
|
|
0c0c556c6a | ||
|
|
ff63b73c09 | ||
|
|
c1d56adbd2 | ||
|
|
bcd99fd208 | ||
|
|
d1df10d060 | ||
|
|
1fa415628f | ||
|
|
a83fe9e532 | ||
|
|
f85462ffec | ||
|
|
156349c293 | ||
|
|
5976706e40 | ||
|
|
1e40180f0c | ||
|
|
7d09728e6b | ||
|
|
4899ef3007 | ||
|
|
296c2b43eb | ||
|
|
932472cb91 | ||
|
|
1bf86b05ec | ||
|
|
5d5e3a6671 | ||
|
|
9720a573c7 | ||
|
|
1cf01aa92a | ||
|
|
4df9e5abbf | ||
|
|
9243aa47e7 | ||
|
|
c69f41a2a6 | ||
|
|
27c74e52ca | ||
|
|
bfa7f5cca9 | ||
|
|
22a3dcbc1f | ||
|
|
ec9d11cf52 | ||
|
|
fbc29dfb0a | ||
|
|
03d30ff6af | ||
|
|
ecfe0dc033 | ||
|
|
f2d475a9b0 | ||
|
|
86124fc609 | ||
|
|
db2b10d2a4 | ||
|
|
83402028fd | ||
|
|
423b5312f7 | ||
|
|
3be7d8e825 | ||
|
|
29803c6ba0 | ||
|
|
bb05847b25 | ||
|
|
5219ad53e1 | ||
|
|
30aa691aae | ||
|
|
83fa73cef5 | ||
|
|
2195574422 | ||
|
|
74ce408c8b | ||
|
|
85be15b843 | ||
|
|
b4b85cd485 | ||
|
|
0093968537 | ||
|
|
1b09b1fd48 | ||
|
|
ac87d70613 | ||
|
|
a5d98364fa | ||
|
|
ca0e639a19 | ||
|
|
b0e3022988 | ||
|
|
6765c2bfa7 | ||
|
|
94d3742317 | ||
|
|
bd3e833dc1 | ||
|
|
a386ace0e6 | ||
|
|
8221d7e202 | ||
|
|
fa92946d20 | ||
|
|
6d13325c4f | ||
|
|
7a9c6720c7 | ||
|
|
697f797509 | ||
|
|
ec9854212a | ||
|
|
46f6ba1710 | ||
|
|
7347244f0a | ||
|
|
c29c4c470c | ||
|
|
ee51fd9da6 | ||
|
|
2c4705de6e | ||
|
|
b4aa220051 | ||
|
|
4ab6da132b | ||
|
|
b006429a53 | ||
|
|
54d157d244 | ||
|
|
a4dfdf80e4 | ||
|
|
d8c90bc745 | ||
|
|
46accddd2d | ||
|
|
f40ecbc07e | ||
|
|
536982cb5f | ||
|
|
ea3d96329b | ||
|
|
e87fcbb16f | ||
|
|
541cf79b6f | ||
|
|
55fa82f92e | ||
|
|
4a0c2b2180 | ||
|
|
c77fe5d561 | ||
|
|
359d082ffd | ||
|
|
017bdba404 | ||
|
|
d4bf13b3fd | ||
|
|
87b695b2de | ||
|
|
222b16113e | ||
|
|
75c07c3209 | ||
|
|
e640edee7f | ||
|
|
6c48fc1f5e | ||
|
|
e5708a382b |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"CdmUrls": [
|
||||
"https://ollj0gz40d.execute-api.us-west-2.amazonaws.com/default/AudibleCdm"
|
||||
]
|
||||
}
|
||||
@@ -6,10 +6,14 @@ labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
PLEASE FILL OUT THE FOLLOWING. Bug reports with limited information or lacking an attached log file may get limited or delayed help.
|
||||
|
||||
___
|
||||
|
||||
## Describe the bug
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
## To Reproduce
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
@@ -17,15 +21,23 @@ Steps to reproduce the behavior:
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
## Expected behavior
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
## Screenshots
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Platform**
|
||||
|
||||
## Platform
|
||||
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
||||
|
||||
**Log Files**
|
||||
Attach your Libation log file here.
|
||||
## Log Files
|
||||
Attach your Libation log file here. If your user folder contains the file "LibationCrash.log", attach that also.
|
||||
|
||||
**Default Log File Locations**
|
||||
|Platform|Folder|
|
||||
|-|-|
|
||||
|Windows|`%userprofile%\Libation`|
|
||||
|macOS|`~/Library/Application Support/Libation`|
|
||||
|Linux|`~/.local/share/Libation`|
|
||||
|
||||
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'.
|
||||
@@ -6,14 +6,26 @@ labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
**No-go ideas**
|
||||
There are lots of great ideas and many are beyond what we intend to do for Libation. Some good ideas which we do not intend to pursue:
|
||||
|
||||
* comprehensive api/cli
|
||||
* aax/audiobook import
|
||||
* bulk rename of existing files
|
||||
* general metadata/tag editor
|
||||
* playback features
|
||||
* web gui
|
||||
* supporting non-audible vendors
|
||||
* official docker support
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -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,18 +6,21 @@ 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
|
||||
@@ -26,107 +29,55 @@ on:
|
||||
|
||||
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: '7.0.x'
|
||||
RELEASE_NAME: 'chardonnay'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: '${{ inputs.OS }}-${{ inputs.architecture }}'
|
||||
runs-on: ${{ inputs.runs_on }}
|
||||
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUNTIME_ID: "linux-${{ inputs.architecture }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
env:
|
||||
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
|
||||
- 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}"
|
||||
|
||||
- name: Unit test
|
||||
if: ${{ inputs.run_unit_tests }}
|
||||
if: ${{ inputs.run-unit-tests }}
|
||||
working-directory: ./Source
|
||||
run: dotnet test
|
||||
|
||||
- name: Publish
|
||||
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
|
||||
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/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@v3
|
||||
|
||||
- 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@v6
|
||||
if: ${{ inputs.sign-app }}
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
|
||||
p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
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,109 +6,82 @@ 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
|
||||
|
||||
env:
|
||||
DOTNET_CONFIGURATION: 'Release'
|
||||
DOTNET_VERSION: '7.0.x'
|
||||
publish-r2r:
|
||||
type: boolean
|
||||
retention-days:
|
||||
type: number
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: '${{ matrix.os }}-${{ matrix.release_name }}'
|
||||
name: "Windows-${{ matrix.release_name }}-${{ matrix.architecture }} (${{ matrix.ui }})"
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [Windows]
|
||||
ui: [Avalonia]
|
||||
architecture: [x64]
|
||||
release_name: [chardonnay]
|
||||
include:
|
||||
- os: Windows
|
||||
- architecture: x64
|
||||
ui: WinForms
|
||||
release_name: classic
|
||||
prefix: Classic-
|
||||
- architecture: arm64
|
||||
ui: Avalonia
|
||||
release_name: chardonnay
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
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 `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
LibationCli/LibationCli.csproj `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_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")
|
||||
|
||||
dotnet publish "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "LibationCli/LibationCli.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj" $PUBLISH_ARGS
|
||||
|
||||
- name: Zip artifact
|
||||
id: zip
|
||||
working-directory: ./Source/bin/Publish
|
||||
working-directory: ./bin
|
||||
run: |
|
||||
$bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
|
||||
$delfiles = @(
|
||||
"libmp3lame.x64.so",
|
||||
"libmp3lame.arm64.so",
|
||||
"libmp3lame.x64.dylib",
|
||||
"libmp3lame.arm64.dylib",
|
||||
"ffmpegaac.x64.so",
|
||||
"ffmpegaac.arm64.so",
|
||||
"ffmpegaac.x64.dylib",
|
||||
"ffmpegaac.arm64.dylib",
|
||||
"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 }}"
|
||||
"WindowsConfigApp.deps.json")
|
||||
|
||||
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
|
||||
$artifact="${{ matrix.prefix }}Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
|
||||
"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@v3
|
||||
- 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 }}
|
||||
+46
-27
@@ -6,24 +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
|
||||
|
||||
jobs:
|
||||
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:
|
||||
windows:
|
||||
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 }}
|
||||
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:
|
||||
matrix:
|
||||
@@ -31,20 +59,11 @@ 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,69 @@
|
||||
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
|
||||
|
||||
# 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@v6
|
||||
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@v6
|
||||
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
|
||||
@@ -8,7 +8,11 @@ on:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
description: 'Version number'
|
||||
description: "Version number"
|
||||
required: true
|
||||
release:
|
||||
type: boolean
|
||||
description: "Is this a release build?"
|
||||
required: true
|
||||
secrets:
|
||||
docker_username:
|
||||
@@ -16,31 +20,44 @@ on:
|
||||
docker_token:
|
||||
required: true
|
||||
|
||||
env:
|
||||
DOCKER_IMAGE: ${{ secrets.docker_username }}/libation
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
build_and_push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
if: ${{ inputs.release }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.docker_username }}
|
||||
password: ${{ secrets.docker_token }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
- name: Generate docker image tags
|
||||
id: metadata
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
push: true
|
||||
build-args: 'FOLDER_NAME=Linux-chardonnay'
|
||||
tags: ${{ env.DOCKER_IMAGE }}:latest,${{ env.DOCKER_IMAGE }}:${{ inputs.version }}
|
||||
flavor: |
|
||||
latest=true
|
||||
images: |
|
||||
name=${{ secrets.docker_username }}/libation
|
||||
tags: |
|
||||
type=raw,value=${{ inputs.version }},enable=${{ inputs.release }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ steps.metadata.outputs.tags != ''}}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
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'],
|
||||
});
|
||||
}
|
||||
@@ -5,17 +5,17 @@ name: release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
jobs:
|
||||
prerelease:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-slim
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
steps:
|
||||
- name: Get tag version
|
||||
id: get_version
|
||||
run: |
|
||||
export TAG='${{ github.ref_name }}'
|
||||
export TAG="${{ github.ref_name }}"
|
||||
echo "version=${TAG#v}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
docker:
|
||||
@@ -23,6 +23,7 @@ jobs:
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
version: ${{ needs.prerelease.outputs.version }}
|
||||
release: true
|
||||
secrets:
|
||||
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -30,32 +31,30 @@ 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]
|
||||
needs: [prerelease, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: "*(Classic-)Libation.*"
|
||||
|
||||
- name: Release
|
||||
id: release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Libation v${{ needs.prerelease.outputs.version }}
|
||||
body: <Put a body here>
|
||||
name: Libation ${{ needs.prerelease.outputs.version }}
|
||||
body: "<Put a body here>"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
|
||||
- name: Upload release assets
|
||||
uses: dwenegar/upload-release-assets@v1
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
with:
|
||||
release_id: '${{ steps.release.outputs.id }}'
|
||||
assets_path: ./artifacts
|
||||
files: |
|
||||
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@v10
|
||||
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@v6
|
||||
|
||||
- name: Sync labels
|
||||
uses: micnncim/action-label-syncer@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
manifest: .github/labels.yml
|
||||
prune: false
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Validate MetaInfo
|
||||
"on":
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
paths:
|
||||
- .github/workflows/validate-appstream-metainfo.yml
|
||||
- Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
|
||||
push:
|
||||
branches: ["master"]
|
||||
paths:
|
||||
- .github/workflows/validate-appstream-metainfo.yml
|
||||
- Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
|
||||
|
||||
jobs:
|
||||
validate-appstream-metainfo:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/flathub/flatpak-builder-lint:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Check the MetaInfo file
|
||||
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Check desktop file
|
||||
"on":
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
paths:
|
||||
- .github/workflows/validate-desktop-file.yml
|
||||
- Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
||||
push:
|
||||
branches: ["master"]
|
||||
paths:
|
||||
- .github/workflows/validate-desktop-file.yml
|
||||
- Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
||||
|
||||
jobs:
|
||||
validate-desktop-file:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- run: sudo apt --yes install desktop-file-utils
|
||||
- name: Check the desktop file
|
||||
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
||||
@@ -1,14 +1,47 @@
|
||||
# validate.yml
|
||||
# Validates that Libation will build on a pull request or push to master.
|
||||
# Validates that Libation will build on a pull request or push to master.
|
||||
---
|
||||
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: ${{ needs.get_version.outputs.version }}
|
||||
release: false
|
||||
secrets:
|
||||
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
+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
|
||||
+10
-8
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
||||
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
|
||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
|
||||
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.rpm",
|
||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz",
|
||||
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb",
|
||||
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.rpm",
|
||||
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz"
|
||||
"WindowsClassic": "Classic-Libation\\.\\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\\.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\\.dmg"
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
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: "https://www.paypal.com/paypalme/mcrackan" },
|
||||
],
|
||||
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: "https://www.paypal.com/paypalme/mcrackan" },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: "Naming Templates", link: "/docs/features/naming-templates" },
|
||||
{
|
||||
text: "Searching & Filtering",
|
||||
link: "/docs/features/searching-and-filtering",
|
||||
},
|
||||
{
|
||||
text: "Easy guide to searching",
|
||||
link: "/docs/features/lucene",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: "Development",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Getting Started",
|
||||
link: "/docs/development/getting-started",
|
||||
},
|
||||
{ text: "Contribute", link: "/docs/development/contribute" },
|
||||
{ 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,15 @@
|
||||
/* Custom styles for Libation documentation */
|
||||
|
||||
/* 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,196 @@
|
||||
/**
|
||||
* 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('.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 (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'
|
||||
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 = { name: e.name, url: e.url }
|
||||
if (n.includes('classic')) winClassic = { name: e.name, url: e.url }
|
||||
}
|
||||
if (winChardonnay && winClassic) windowsX64Choice = { chardonnay: winChardonnay, classic: winClassic }
|
||||
}
|
||||
if (!recommended && wantOs) {
|
||||
reason = 'No asset matched your system (OS: ' + os + ', arch: ' + arch + '). Try choosing a download manually.'
|
||||
} 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,
|
||||
}
|
||||
Vendored
+12
-1
@@ -6,7 +6,7 @@
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": ".NET Core Launch (console)",
|
||||
"name": ".NET Core Launch (console) Windows",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
@@ -15,6 +15,17 @@
|
||||
"cwd": "${workspaceFolder}",
|
||||
"stopAtEntry": false,
|
||||
"console": "internalConsole"
|
||||
},
|
||||
{
|
||||
"name": ".NET Core Launch (console) Linux",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build_linux",
|
||||
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"stopAtEntry": false,
|
||||
"console": "internalConsole"
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
Vendored
+17
@@ -37,6 +37,23 @@
|
||||
//"reveal": "silent"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "build_linux",
|
||||
"type": "shell",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj",
|
||||
"-p:TargetFramework=net9.0",
|
||||
"-p:TargetFrameworks=net9.0",
|
||||
"-p:RuntimeIdentifier=linux-x64"
|
||||
],
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
//"reveal": "silent"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<EnableMSTestRunner>true</EnableMSTestRunner>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"LibationFiles": "/config-internal"
|
||||
}
|
||||
+183
-50
@@ -1,68 +1,201 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Rewire echo to print date time
|
||||
echo() {
|
||||
if [[ -n $1 ]]; then
|
||||
printf "$(date '+%F %T'): %s\n" "$1"
|
||||
fi
|
||||
error() {
|
||||
log "ERROR" "$1"
|
||||
}
|
||||
|
||||
# ################################
|
||||
# Setup
|
||||
# ################################
|
||||
echo "Starting"
|
||||
if [[ -z "${SLEEP_TIME}" ]]; then
|
||||
echo "No sleep time passed in. Will run once and exit."
|
||||
else
|
||||
echo "Sleep time is set to ${SLEEP_TIME}"
|
||||
fi
|
||||
warn() {
|
||||
log "WARNING" "$1"
|
||||
}
|
||||
|
||||
echo ""
|
||||
info() {
|
||||
log "info" "$1"
|
||||
}
|
||||
|
||||
# Check if the config directory is passed in, and there is no link to it then create the link.
|
||||
if [ -d "/config" ] && [ ! -d "/root/Libation" ]; then
|
||||
echo "Linking config directory to the Libation config directory"
|
||||
ln -s /config/ /root/Libation
|
||||
fi
|
||||
debug() {
|
||||
if [ "${LOG_LEVEL}" = "debug" ]; then
|
||||
log "debug" "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
# If no config error and exit
|
||||
if [ ! -d "/config" ]; then
|
||||
echo "ERROR: No /config directory. You must pass in a volume containing your config mapped to /config"
|
||||
log() {
|
||||
LEVEL=$1
|
||||
MESSAGE=$2
|
||||
printf "$(date '+%F %T') %s: %s\n" "${LEVEL}" "${MESSAGE}"
|
||||
}
|
||||
|
||||
init_config_file() {
|
||||
FILE=$1
|
||||
FULLPATH=${LIBATION_CONFIG_DIR}/${FILE}
|
||||
if [ -f ${FULLPATH} ]; then
|
||||
info "loading ${FILE}"
|
||||
cp ${FULLPATH} ${LIBATION_CONFIG_INTERNAL}/
|
||||
return 0
|
||||
else
|
||||
warn "${FULLPATH} not found, creating empty file"
|
||||
echo "{}" > ${LIBATION_CONFIG_INTERNAL}/${FILE}
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
update_settings() {
|
||||
FILE=$1
|
||||
KEY=$2
|
||||
VALUE=$3
|
||||
info "setting ${KEY} to ${VALUE}"
|
||||
echo $(jq --arg k "${KEY}" --arg v "${VALUE}" '.[$k] = $v' ${LIBATION_CONFIG_INTERNAL}/${FILE}) > ${LIBATION_CONFIG_INTERNAL}/${FILE}.tmp
|
||||
mv ${LIBATION_CONFIG_INTERNAL}/${FILE}.tmp ${LIBATION_CONFIG_INTERNAL}/${FILE}
|
||||
}
|
||||
|
||||
is_mounted() {
|
||||
DIR=$1
|
||||
if grep -qs "${DIR} " /proc/mounts;
|
||||
then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_db() {
|
||||
DBFILE=$1
|
||||
if [ -f "${DBFILE}" ]; then
|
||||
warn "prexisting database found when creating"
|
||||
return 0
|
||||
else
|
||||
if ! touch "${DBFILE}"; then
|
||||
error "unable to create database, check permissions on host"
|
||||
exit 1
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
setup_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 ]]
|
||||
then
|
||||
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}'"
|
||||
|
||||
if [[ -f $FILE ]]
|
||||
then
|
||||
info "database found at ${FILE}"
|
||||
elif [[ $LIBATION_CREATE_DB == "true" ]]
|
||||
then
|
||||
warn "database not found, creating one at ${FILE}"
|
||||
create_db "${FILE}"
|
||||
else
|
||||
error "database not found and creation is disabled"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# If user passes in db from a /db/ folder and a db does not already exist / is not already linked
|
||||
FILE=/db/LibationContext.db
|
||||
if [ -f "${FILE}" ] && [ ! -f "/config/LibationContext.db" ]; then
|
||||
echo "Linking passed in Libation database from /db/ to the Libation config directory"
|
||||
ln -s $FILE /config/LibationContext.db
|
||||
fi
|
||||
|
||||
# Confirm we have a db in the config direcotry.
|
||||
if [ ! -f "/config/LibationContext.db" ]; then
|
||||
echo "ERROR: No Libation database detected, exiting."
|
||||
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
|
||||
fi
|
||||
|
||||
# ################################
|
||||
# Loop and liberate
|
||||
# ################################
|
||||
while true
|
||||
do
|
||||
echo ""
|
||||
echo "Scanning accounts"
|
||||
/libation/LibationCli scan
|
||||
echo "Liberating books"
|
||||
/libation/LibationCli liberate
|
||||
echo ""
|
||||
ln -s "${FILE}" "${LINK}"
|
||||
}
|
||||
|
||||
run() {
|
||||
info "scanning accounts"
|
||||
/libation/LibationCli scan
|
||||
info "liberating books"
|
||||
/libation/LibationCli liberate
|
||||
}
|
||||
|
||||
main() {
|
||||
info "initializing libation"
|
||||
init_config_file AccountsSettings.json
|
||||
init_config_file Settings.json
|
||||
|
||||
info "loading settings"
|
||||
update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
|
||||
update_settings Settings.json InProgress /tmp
|
||||
|
||||
info "loading database"
|
||||
# If user provides a separate database mount, use that
|
||||
if is_mounted "${LIBATION_DB_DIR}";
|
||||
then
|
||||
DB_LOCATION=${LIBATION_DB_DIR}
|
||||
# Otherwise, use the config directory
|
||||
else
|
||||
DB_LOCATION=${LIBATION_CONFIG_DIR}
|
||||
fi
|
||||
setup_db ${DB_LOCATION}
|
||||
|
||||
# Try to warn if books dir wasn't mounted in
|
||||
if ! is_mounted "${LIBATION_BOOKS_DIR}";
|
||||
then
|
||||
warn "${LIBATION_BOOKS_DIR} does not appear to be mounted, books will not be saved"
|
||||
fi
|
||||
|
||||
# Let the user know what the run type will be
|
||||
if [[ -z "${SLEEP_TIME}" ]]; then
|
||||
SLEEP_TIME=-1
|
||||
fi
|
||||
|
||||
if [ "${SLEEP_TIME}" == -1 ]; then
|
||||
info "running once"
|
||||
else
|
||||
info "running every ${SLEEP_TIME}"
|
||||
fi
|
||||
|
||||
# loop
|
||||
while true
|
||||
do
|
||||
run
|
||||
|
||||
# Liberate only once if SLEEP_TIME was set to -1
|
||||
if [ "${SLEEP_TIME}" = -1 ]; then
|
||||
if [ "${SLEEP_TIME}" == -1 ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Sleeping for ${SLEEP_TIME}"
|
||||
sleep "${SLEEP_TIME}"
|
||||
done
|
||||
done
|
||||
|
||||
echo "Exiting"
|
||||
info "exiting"
|
||||
}
|
||||
|
||||
main
|
||||
+34
-14
@@ -1,22 +1,42 @@
|
||||
# Dockerfile
|
||||
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
|
||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG TARGETARCH
|
||||
|
||||
COPY Source /Source
|
||||
RUN dotnet publish -c Release -o /Source/bin/Publish/Linux-chardonnay /Source/LibationCli/LibationCli.csproj -p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
|
||||
COPY Docker/liberate.sh /Source/bin/Publish/Linux-chardonnay
|
||||
RUN dotnet publish \
|
||||
/Source/LibationCli/LibationCli.csproj \
|
||||
--os linux \
|
||||
--arch ${TARGETARCH} \
|
||||
--configuration Release \
|
||||
--output /Source/bin/Publish/Linux-chardonnay \
|
||||
-p:PublishProtocol=FileSystem \
|
||||
-p:PublishReadyToRun=true \
|
||||
-p:SelfContained=true
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0
|
||||
ARG USER_UID=1001
|
||||
ARG USER_GID=1001
|
||||
|
||||
# Set the character set that will be used for folder and filenames when liberating
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
|
||||
ENV SLEEP_TIME=-1
|
||||
ENV LIBATION_CONFIG_INTERNAL=/config-internal
|
||||
ENV LIBATION_CONFIG_DIR=/config
|
||||
ENV LIBATION_DB_DIR=/db
|
||||
ENV LIBATION_DB_FILE=
|
||||
ENV LIBATION_CREATE_DB=true
|
||||
ENV LIBATION_BOOKS_DIR=/data
|
||||
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:7.0
|
||||
RUN apt-get update && apt-get -y upgrade && \
|
||||
apt-get install -y jq && \
|
||||
mkdir -m777 ${LIBATION_CONFIG_INTERNAL} ${LIBATION_BOOKS_DIR}
|
||||
|
||||
ENV SLEEP_TIME "30m"
|
||||
COPY --from=build /Source/bin/Publish/Linux-chardonnay /libation
|
||||
COPY Docker/* /libation
|
||||
|
||||
# Sets the character set that will be used for folder and filenames when liberating
|
||||
ENV LANG C.UTF-8
|
||||
ENV LC_ALL C.UTF-8
|
||||
USER ${USER_UID}:${USER_GID}
|
||||
|
||||
RUN mkdir /db /config /data
|
||||
|
||||
COPY --from=build-env /Source/bin/Publish/Linux-chardonnay /libation
|
||||
|
||||
|
||||
CMD ["./libation/liberate.sh"]
|
||||
CMD ["/libation/liberate.sh"]
|
||||
@@ -1,79 +1,3 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
# This page has been moved to https://getlibation.com/docs/advanced/advanced
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?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)
|
||||
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
```
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/advanced/advanced.md))
|
||||
@@ -0,0 +1,3 @@
|
||||
# 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
-38
@@ -1,39 +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/MBucari?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
### Disclaimer
|
||||
The docker image is provided as-is. We hope it can be useful to you but it is not officially supported.
|
||||
|
||||
### Setup
|
||||
In order to use the docker image, you'll need to provide it with a copy of the `AccountsSettings.json`, `Settings.json`, and `LibationContext.db` files. These files can usually be found in the Libation folder in your user's home directory. If you haven't run Libation yet, you'll need to launch it to generate these files and setup your accounts. Once you have them, copy these files to a new location, such as `/opt/libation/config`. Before using them we'll need to make a couple edits so that the filepaths referenced are correct when running from the docker image.
|
||||
|
||||
In Settings.json, make the following changes:
|
||||
* Change `Books` to `/data`
|
||||
* Change `InProgress` to `/tmp`
|
||||
|
||||
### Running
|
||||
Once the configuration files are copied and edited, 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
|
||||
```
|
||||
|
||||
By default the container will scan for new books every 30 minutes and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. Additionally, if you pass in `-1` it will scan and download books once and then exit.
|
||||
|
||||
```
|
||||
sudo docker run -d \
|
||||
-v /opt/libation/config:/config \
|
||||
-v /opt/libation/books:/data \
|
||||
-e SLEEP_TIME='10m' \
|
||||
--name libation \
|
||||
--restart=always \
|
||||
rmcrackan/libation
|
||||
```
|
||||
# This page has been moved to https://getlibation.com/docs/installation/docker
|
||||
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/installation/docker.md))
|
||||
@@ -0,0 +1,3 @@
|
||||
# This page has been moved to https://getlibation.com/docs/frequently-asked-questions
|
||||
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/frequently-asked-questions.md))
|
||||
@@ -1,150 +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/MBucari?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)
|
||||
|
||||
|
||||
|
||||
### [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
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/getting-started.md))
|
||||
@@ -1,30 +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/MBucari?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
### Install and Run Libation on Ubuntu
|
||||
|
||||
New Libation releases are automatically packed into .deb and .rpm package and are available from the Libation repository's releases page.
|
||||
|
||||
|
||||
Run this command in your terminal to dowbnload and install Libation, replacing the url with the latest Libation package url:
|
||||
|
||||
- Debian
|
||||
```Console
|
||||
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.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.rpm &&
|
||||
sudo yum install ./libation.rpm
|
||||
```
|
||||
|
||||
|
||||
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,45 +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/MBucari?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 10.15 (Catalina) and above
|
||||
|
||||
## Install Libation
|
||||
|
||||
- Download the file from the latest release and extract it.
|
||||
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz`
|
||||
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
|
||||
- Move the extracted Libation app bundle to your applications folder.
|
||||
- Open a terminal (Go > Utilities > Terminal)
|
||||
- Copy/paste/run the following command (you'll be prompted to enter your 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!
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Runnign 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))
|
||||
@@ -0,0 +1,3 @@
|
||||
# This page has been moved to https://getlibation.com/docs/development/nix-linux-setup
|
||||
|
||||
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/development/nix-linux-setup.md))
|
||||
@@ -1,116 +1,3 @@
|
||||
# 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)
|
||||
- [Name List Formatters](#name-list-formatters)
|
||||
- [Integer Formatters](#integer-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|Text|
|
||||
|\<title short\>|Title. Stop at first colon|Text|
|
||||
|\<author\>|Author(s)|Name List|
|
||||
|\<first author\>|First author|Text|
|
||||
|\<narrator\>|Narrator(s)|Name List|
|
||||
|\<first narrator\>|First narrator|Text|
|
||||
|\<series\>|Name of series|Text|
|
||||
|\<series#\>|Number order in series|Text|
|
||||
|\<bitrate\>|File's original bitrate (Kbps)|Integer|
|
||||
|\<samplerate\>|File's original audio sample rate|Integer|
|
||||
|\<channels\>|Number of audio channels|Integer|
|
||||
|\<account\>|Audible account of this book|Text|
|
||||
|\<locale\>|Region/country|Text|
|
||||
|\<year\>|Year published|Integer|
|
||||
|\<language\>|Book's language|Text|
|
||||
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
||||
|\<file date\>|File creation date/time.|DateTime|
|
||||
|\<pub date\>|Audiobook publication date|DateTime|
|
||||
|\<date added\>|Date the book added to your Audible account|DateTime|
|
||||
|\<ch count\> **‡**|Number of chapters|Integer|
|
||||
|\<ch title\> **‡**|Chapter title|Text|
|
||||
|\<ch#\> **‡**|Chapter number|Integer|
|
||||
|\<ch# 0\> **‡**|Chapter number with leading zeros|Integer|
|
||||
|
||||
**†** 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|
|
||||
|
||||
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.
|
||||
|
||||
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\>
|
||||
|
||||
|
||||
# Tag Formatters
|
||||
**Text**, **Name List**, **Integer**, 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|
|
||||
|
||||
## Name List Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|separator()|Speficy the text used to join multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|
||||
|format(\{T \| F \| M \| L \| S\})|Formats the human name using 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><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\} |`<author[format({L}, {F}) separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|
||||
|sort(F \| M \| L)|Sorts the names by first, middle, 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|
|
||||
|
||||
## Integer Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|# (a number)|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
|
||||
|
||||
## 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/MBucari?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.
Binary file not shown.
Binary file not shown.
+40
-27
@@ -1,28 +1,41 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
|
||||
<path id="glass" d=
|
||||
"M139,2
|
||||
A 192,200 0 0 0 103,84
|
||||
A 222,334 41 0 0 241,320
|
||||
V478
|
||||
H160
|
||||
A 16,16 0 0 0 160,510
|
||||
H352
|
||||
A16 16 0 0 0 352,478
|
||||
H271
|
||||
V320
|
||||
A 222,334 -41 0 0 409,84
|
||||
A 192,200 0 0 0 373,2
|
||||
M355,32
|
||||
A 192,200 0 0 1 381,127
|
||||
A 187.5,334 -35 0 1 256,286
|
||||
A 187.5,334 35 0 1 131,127
|
||||
A 192,200 0 0 1 157,32
|
||||
H355
|
||||
z" />
|
||||
|
||||
<path id="wine-level" d=
|
||||
"M146,128
|
||||
A 168,300 35 0 0 256,270
|
||||
A 168,300 -35 0 0 366,128
|
||||
z"/>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
|
||||
<defs>
|
||||
<g id="glass">
|
||||
<path transform="translate(16 16)" fill-rule="evenodd" d=
|
||||
"M177,16
|
||||
H79
|
||||
A 32.0781 63.7932 -1.5106 0 0 66 80
|
||||
A 158.789 471.1259 41.9466 0 0 90 131
|
||||
A 81.7197 122.0515 35.3745 0 0 128 143.3484
|
||||
A 81.7197 122.0515 -35.3745 0 0 166 131
|
||||
A 158.789 471.1259 -41.9466 0 0 190 80
|
||||
A 32.0781 63.7932 1.5106 0 0 177 16
|
||||
L 184 0
|
||||
A 44.7901 78.5247 1.1521 0 1 194 122
|
||||
A 97.0039 135.3148 -36.2124 0 1 136 159
|
||||
V 240
|
||||
H 176
|
||||
A 8 8 0 0 1 176 256
|
||||
H 80
|
||||
A 8 8 0 0 1 80 240
|
||||
H 120
|
||||
V 159
|
||||
A 97.0039 135.3148 36.2124 0 1 62 122
|
||||
A 44.7901 78.5247 -1.1521 0 1 72 0
|
||||
H184
|
||||
z"/>
|
||||
</g>
|
||||
<g transform="translate(16 16)" id="wine-level">
|
||||
<path d=
|
||||
"M182,64
|
||||
H 74
|
||||
A 115.9979 308.8033 38.9474 0 0 128 134.4277
|
||||
A 115.9979 308.8033 -38.9474 0 0 182,64
|
||||
z"/>
|
||||
</g>
|
||||
</defs>
|
||||
<use href="#glass" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
|
||||
<use href="#wine-level" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
|
||||
<use href="#glass" fill="Black" />
|
||||
<use href="#wine-level" fill="Black" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 585 B After Width: | Height: | Size: 1.2 KiB |
@@ -1,30 +1,31 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
|
||||
|
||||
<g transform="translate(0 80) rotate(90 256,256)">
|
||||
<path id="glass" d=
|
||||
"M139,2
|
||||
A 192,200 0 0 0 103,84
|
||||
A 222,334 41 0 0 241,320
|
||||
V478
|
||||
H160
|
||||
A 16,16 0 0 0 160,510
|
||||
H352
|
||||
A16 16 0 0 0 352,478
|
||||
H271
|
||||
V320
|
||||
A 222,334 -41 0 0 409,84
|
||||
A 192,200 0 0 0 373,2
|
||||
M355,32
|
||||
A 192,200 0 0 1 381,127
|
||||
A 187.5,334 -35 0 1 256,286
|
||||
A 187.5,334 35 0 1 131,127
|
||||
A 192,200 0 0 1 157,32
|
||||
H355
|
||||
z" />
|
||||
<path id="wine-level" d=
|
||||
"M345,44
|
||||
A 192,184 0 0 1 366,126
|
||||
A 320,180 55 0 1 345,226
|
||||
z"/>
|
||||
</g>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
|
||||
<g>
|
||||
<path transform="rotate(90 128,128) translate(60 -16)" fill-rule="evenodd" d=
|
||||
"M177,16
|
||||
H79
|
||||
A 32.0781 63.7932 -1.5106 0 0 66 80
|
||||
A 158.789 471.1259 41.9466 0 0 90 131
|
||||
A 81.7197 122.0515 35.3745 0 0 128 143.3484
|
||||
A 81.7197 122.0515 -35.3745 0 0 166 131
|
||||
A 158.789 471.1259 -41.9466 0 0 190 80
|
||||
A 32.0781 63.7932 1.5106 0 0 177 16
|
||||
L 184 0
|
||||
A 44.7901 78.5247 1.1521 0 1 194 122
|
||||
A 97.0039 135.3148 -36.2124 0 1 136 159
|
||||
V 240
|
||||
H 176
|
||||
A 8 8 0 0 1 176 256
|
||||
H 80
|
||||
A 8 8 0 0 1 80 240
|
||||
H 120
|
||||
V 159
|
||||
A 97.0039 135.3148 36.2124 0 1 62 122
|
||||
A 44.7901 78.5247 -1.1521 0 1 72 0
|
||||
H184
|
||||
M170,115
|
||||
V24
|
||||
A 19.5181 45.9183 -3.3549 0 1 182.4322 69.5
|
||||
A 19.5181 45.9183 3.3549 0 1 170 115
|
||||
z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 638 B After Width: | Height: | Size: 936 B |
@@ -1,69 +1,57 @@
|
||||
# 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/MBucari?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.
|
||||
|
||||
# Table of Contents
|
||||
## Features
|
||||
|
||||
- [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)
|
||||
- [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)
|
||||
- [Docker](Documentation/Docker.md)
|
||||
- **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.
|
||||
|
||||
## Getting started
|
||||
## Getting started with Libation
|
||||
|
||||
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)
|
||||
|
||||
* 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.
|
||||
- **[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 click here to PayPal.me
|
||||
...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
|
||||
@@ -53,13 +45,7 @@ if [ $? -ne 0 ]
|
||||
fi
|
||||
|
||||
|
||||
delfiles=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||
if [[ "$ARCH" == "arm64" ]]
|
||||
then
|
||||
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
|
||||
else
|
||||
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
|
||||
fi
|
||||
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||
|
||||
for n in "${delfiles[@]}"
|
||||
do
|
||||
@@ -109,9 +95,6 @@ ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
|
||||
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
|
||||
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
|
||||
fi
|
||||
# workaround until this file is moved to the user's home directory
|
||||
touch /usr/lib/libation/appsettings.json
|
||||
chmod 666 /usr/lib/libation/appsettings.json
|
||||
" >> $FOLDER_DEBIAN/postinst
|
||||
|
||||
echo "Creating control file..."
|
||||
@@ -122,6 +105,7 @@ Essential: no
|
||||
Priority: optional
|
||||
Maintainer: github.com/rmcrackan
|
||||
Description: liberate your audiobooks
|
||||
Recommends: libgtk-3-0, libwebkit2gtk-4.1-0
|
||||
" >> $FOLDER_DEBIAN/control
|
||||
|
||||
echo "Changing permissions for pre- and post-install files..."
|
||||
|
||||
+49
-30
@@ -3,6 +3,7 @@
|
||||
BIN_DIR=$1; shift
|
||||
VERSION=$1; shift
|
||||
ARCH=$1; shift
|
||||
SIGN_WITH_KEY=$1; shift
|
||||
|
||||
if [ -z "$BIN_DIR" ]
|
||||
then
|
||||
@@ -28,12 +29,9 @@ then
|
||||
exit
|
||||
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
|
||||
@@ -60,9 +58,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,41 +72,62 @@ mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
||||
echo "Moving Info.plist file..."
|
||||
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
|
||||
|
||||
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
|
||||
|
||||
|
||||
delfiles=( 'libmp3lame.arm64.so' 'libmp3lame.x64.so' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.so' 'ffmpegaac.x64.so' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
|
||||
if [[ "$ARCH" == "arm64" ]]
|
||||
then
|
||||
delfiles+=('libmp3lame.x64.dylib' 'ffmpegaac.x64.dylib')
|
||||
else
|
||||
delfiles+=('libmp3lame.arm64.dylib' 'ffmpegaac.arm64.dylib')
|
||||
fi
|
||||
|
||||
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
|
||||
hdiutil create -srcFolder ./Libation -o "./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
|
||||
|
||||
echo "Done!"
|
||||
if [ "$SIGN_WITH_KEY" == "true" ]; then
|
||||
echo "Signing $DMG_FILE"
|
||||
codesign --deep --sign "${identity}" "./bundle/$DMG_FILE"
|
||||
fi
|
||||
|
||||
echo "Done!"
|
||||
@@ -28,24 +28,14 @@ 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=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||
if [[ "$ARCH" == "x64" ]]
|
||||
then
|
||||
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
|
||||
ARCH_RPM="x86_64"
|
||||
ARCH="amd64"
|
||||
else
|
||||
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
|
||||
ARCH_RPM="aarch64"
|
||||
fi
|
||||
|
||||
@@ -64,7 +54,7 @@ License: GPLv3+
|
||||
URL: https://github.com/rmcrackan/Libation
|
||||
Source0: https://github.com/rmcrackan/Libation
|
||||
|
||||
Requires: bash
|
||||
Requires: bash gtk3 webkit2gtk4.1
|
||||
|
||||
|
||||
%define __os_install_post %{nil}
|
||||
@@ -94,8 +84,6 @@ install * %{buildroot}%{_libdir}/%{name}/
|
||||
|
||||
if [ \$1 -eq 1 ] ; then
|
||||
# Initial installation
|
||||
touch %{_libdir}/%{name}/appsettings.json
|
||||
chmod 666 %{_libdir}/%{name}/appsettings.json
|
||||
|
||||
ln -s %{_libdir}/%{name}/Libation %{_bindir}/libation
|
||||
ln -s %{_libdir}/%{name}/Hangover %{_bindir}/hangover
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.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="1.0.3" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="3.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,74 +1,150 @@
|
||||
using AAXClean;
|
||||
using Mpeg4Lib;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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;
|
||||
base.SetCoverArt(coverArt);
|
||||
if (coverArt is not null && AaxFile?.MetadataItems is not null)
|
||||
AaxFile.MetadataItems.Cover = coverArt;
|
||||
}
|
||||
|
||||
protected AaxFile AaxFile { get; private set; }
|
||||
protected Mp4Operation AaxConversion { get; set; }
|
||||
public override async Task CancelAsync()
|
||||
{
|
||||
await base.CancelAsync();
|
||||
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
||||
}
|
||||
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||
|
||||
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
||||
public override void SetCoverArt(byte[] coverArt)
|
||||
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)
|
||||
{
|
||||
base.SetCoverArt(coverArt);
|
||||
if (coverArt is not null && AaxFile?.AppleTags is not null)
|
||||
AaxFile.AppleTags.Cover = coverArt;
|
||||
//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;
|
||||
}
|
||||
|
||||
public override async Task CancelAsync()
|
||||
else if (DownloadOptions.InputType is FileType.Aax)
|
||||
{
|
||||
IsCanceled = true;
|
||||
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
||||
FinalizeDownload();
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
var key = keys[0].KeyPart1;
|
||||
aax.SetDecryptionKey(keys[0].KeyPart1);
|
||||
WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}");
|
||||
return aax;
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
else if (DownloadOptions.InputType is FileType.Aaxc)
|
||||
{
|
||||
AaxFile = new AaxFile(InputFileStream);
|
||||
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.");
|
||||
|
||||
if (DownloadOptions.AudibleKey?.Length == 8 && DownloadOptions.AudibleIV is null)
|
||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey);
|
||||
else
|
||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||
|
||||
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("TCOM", AaxFile.AppleTags.Narrator);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
|
||||
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("©", "©");
|
||||
}
|
||||
|
||||
//Finishing configuring lame encoder.
|
||||
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||
MpegUtil.ConfigureLameOptions(
|
||||
AaxFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.Downsample,
|
||||
DownloadOptions.MatchSourceBitrate);
|
||||
|
||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
||||
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
||||
|
||||
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
|
||||
|
||||
return !IsCanceled;
|
||||
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,145 +1,119 @@
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using FileManager;
|
||||
using Mpeg4Lib;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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 outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, 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;
|
||||
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||
}
|
||||
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)}.");
|
||||
|
||||
/*
|
||||
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()
|
||||
{
|
||||
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(splitChapters));
|
||||
|
||||
if (AaxConversion.IsCompletedSuccessfully)
|
||||
await moveMoovToBeginning(workingFileStream?.Name);
|
||||
|
||||
return AaxConversion.IsCompletedSuccessfully;
|
||||
}
|
||||
finally
|
||||
{
|
||||
workingFileStream?.Dispose();
|
||||
FinalizeDownload();
|
||||
}
|
||||
}
|
||||
|
||||
private Mp4Operation decryptMultiAsync(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, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
MultiConvertFileProperties props = new()
|
||||
{
|
||||
OutputFileName = OutputFileName,
|
||||
PartsPosition = currentChapter,
|
||||
PartsTotal = splitChapters.Count,
|
||||
Title = newSplitCallback?.Chapter?.Title,
|
||||
};
|
||||
|
||||
moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult();
|
||||
|
||||
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
|
||||
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
|
||||
newSplitCallback.TrackNumber = currentChapter;
|
||||
newSplitCallback.TrackCount = splitChapters.Count;
|
||||
|
||||
OnFileCreated(workingFileStream.Name);
|
||||
}
|
||||
|
||||
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||
{
|
||||
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
||||
FileUtility.SaferDelete(fileName);
|
||||
return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
}
|
||||
}
|
||||
|
||||
private Mp4Operation moveMoovToBeginning(string filename)
|
||||
{
|
||||
if (DownloadOptions.OutputFormat is OutputFormat.M4b
|
||||
&& DownloadOptions.MoveMoovToBeginning
|
||||
&& filename is not null
|
||||
&& File.Exists(filename))
|
||||
{
|
||||
return Mp4File.RelocateMoovAsync(filename);
|
||||
}
|
||||
else return Mp4Operation.CompletedOperation;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,90 +3,111 @@ using AAXClean.Codecs;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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();
|
||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions)
|
||||
{
|
||||
var step = 1;
|
||||
var step = 1;
|
||||
|
||||
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++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
||||
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)
|
||||
{
|
||||
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)}.");
|
||||
|
||||
MpegUtil.ConfigureLameOptions(
|
||||
AaxFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.Downsample,
|
||||
DownloadOptions.MatchSourceBitrate,
|
||||
DownloadOptions.ChapterInfo);
|
||||
}
|
||||
}
|
||||
|
||||
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
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
|
||||
{
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
await (AaxConversion = decryptAsync(AaxFile, outputFile));
|
||||
|
||||
using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
OnFileCreated(OutputFileName);
|
||||
|
||||
try
|
||||
{
|
||||
await (AaxConversion = decryptAsync(outputFile));
|
||||
|
||||
return AaxConversion.IsCompletedSuccessfully;
|
||||
}
|
||||
finally
|
||||
{
|
||||
FinalizeDownload();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> Step_MoveMoov()
|
||||
{
|
||||
AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName);
|
||||
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(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,232 +6,221 @@ using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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<string> FileCreated;
|
||||
|
||||
public bool IsCanceled { get; protected set; }
|
||||
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||
protected string OutputFileName { get; }
|
||||
protected IDownloadOptions DownloadOptions { get; }
|
||||
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
|
||||
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||
private bool downloadFinished;
|
||||
|
||||
private readonly NetworkFileStreamPersister nfsPersister;
|
||||
private readonly DownloadProgress zeroProgress;
|
||||
private readonly string jsonDownloadState;
|
||||
private readonly string tempFilePath;
|
||||
|
||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
get
|
||||
{
|
||||
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
|
||||
//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;
|
||||
|
||||
var outDir = Path.GetDirectoryName(OutputFileName);
|
||||
if (!Directory.Exists(outDir))
|
||||
Directory.CreateDirectory(outDir);
|
||||
private NetworkFileStreamPersister? m_nfsPersister;
|
||||
private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream();
|
||||
private readonly DownloadProgress zeroProgress;
|
||||
private readonly string jsonDownloadState;
|
||||
private readonly string tempFilePath;
|
||||
|
||||
if (!Directory.Exists(cacheDirectory))
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
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;
|
||||
|
||||
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
|
||||
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
||||
if (!Directory.Exists(OutputDirectory))
|
||||
Directory.CreateDirectory(OutputDirectory);
|
||||
|
||||
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
||||
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
|
||||
if (!Directory.Exists(cacheDirectory))
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
|
||||
// delete file after validation is complete
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json");
|
||||
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
||||
|
||||
nfsPersister = OpenNetworkFileStream();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RunAsync()
|
||||
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)
|
||||
{
|
||||
await InputFileStream.BeginDownloadingAsync();
|
||||
var progressTask = Task.Run(reportProgress);
|
||||
|
||||
AsyncSteps[$"Cleanup"] = CleanupAsync;
|
||||
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
||||
|
||||
await progressTask;
|
||||
|
||||
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
||||
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
||||
|
||||
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 abstract Task CancelAsync();
|
||||
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
||||
|
||||
public virtual void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
if (coverArt is not null)
|
||||
OnRetrievedCoverArt(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);
|
||||
protected void OnFileCreated(string path)
|
||||
=> FileCreated?.Invoke(this, path);
|
||||
|
||||
protected virtual void FinalizeDownload()
|
||||
{
|
||||
nfsPersister?.Dispose();
|
||||
downloadFinished = true;
|
||||
}
|
||||
|
||||
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
|
||||
{
|
||||
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
|
||||
{
|
||||
var recordsFile = await DownloadOptions.SaveClipsAndBookmarksAsync(OutputFileName);
|
||||
|
||||
if (File.Exists(recordsFile))
|
||||
OnFileCreated(recordsFile);
|
||||
}
|
||||
Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters.");
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
protected async Task<bool> Step_CreateCueAsync()
|
||||
// not a critical step. its failure should not prevent future steps from running
|
||||
try
|
||||
{
|
||||
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
|
||||
|
||||
// not a critical step. its failure should not prevent future steps from running
|
||||
try
|
||||
{
|
||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
||||
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
||||
OnFileCreated(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed");
|
||||
}
|
||||
return !IsCanceled;
|
||||
var tempFile = GetNewTempFilePath(".cue");
|
||||
await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo));
|
||||
OnTempFileCreated(tempFile);
|
||||
}
|
||||
|
||||
private async Task<bool> CleanupAsync()
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (IsCanceled) return false;
|
||||
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
|
||||
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
|
||||
DownloadOptions.RetainEncryptedFile)
|
||||
{
|
||||
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
||||
|
||||
//Write aax decryption key
|
||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
||||
FileUtility.SaferDelete(keyPath);
|
||||
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
||||
|
||||
OnFileCreated(aaxPath);
|
||||
OnFileCreated(keyPath);
|
||||
}
|
||||
else
|
||||
FileUtility.SaferDelete(tempFilePath);
|
||||
|
||||
return !IsCanceled;
|
||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed");
|
||||
}
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
private NetworkFileStreamPersister OpenNetworkFileStream()
|
||||
private NetworkFileStreamPersister OpenNetworkFileStream()
|
||||
{
|
||||
NetworkFileStreamPersister? nfsp = default;
|
||||
try
|
||||
{
|
||||
NetworkFileStreamPersister nfsp = default;
|
||||
try
|
||||
{
|
||||
if (!File.Exists(jsonDownloadState))
|
||||
return nfsp = 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
|
||||
{
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
FileUtility.SaferDelete(tempFilePath);
|
||||
if (!File.Exists(jsonDownloadState))
|
||||
return nfsp = newNetworkFilePersister();
|
||||
}
|
||||
finally
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
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
|
||||
@@ -105,7 +105,7 @@ public class AverageSpeed
|
||||
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
|
||||
|
||||
/// <param name="slowWindow">Total moving average time window</param>
|
||||
/// <param name="slowSignificance">T-test signifance level at which the newest speed will be considered different from the slow window's mean speed.</param>
|
||||
/// <param name="slowSignificance">T-test significance level at which the newest speed will be considered different from the slow window's mean speed.</param>
|
||||
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
|
||||
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
|
||||
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
|
||||
@@ -119,13 +119,13 @@ public class AverageSpeed
|
||||
/// <summary>Add a new position to the moving average</summary>
|
||||
public void AddPosition(double position)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var now = DateTime.UtcNow;
|
||||
if (start == default)
|
||||
start = now;
|
||||
|
||||
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,32 +1,54 @@
|
||||
using AAXClean;
|
||||
using Mpeg4Lib;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public class KeyData
|
||||
{
|
||||
public interface IDownloadOptions
|
||||
{
|
||||
event EventHandler<long> DownloadSpeedChanged;
|
||||
string DownloadUrl { get; }
|
||||
string UserAgent { get; }
|
||||
string AudibleKey { get; }
|
||||
string AudibleIV { get; }
|
||||
TimeSpan RuntimeLength { get; }
|
||||
OutputFormat OutputFormat { get; }
|
||||
bool TrimOutputToChapterLength { get; }
|
||||
bool RetainEncryptedFile { get; }
|
||||
bool StripUnabridged { get; }
|
||||
bool CreateCueSheet { get; }
|
||||
bool DownloadClipsBookmarks { get; }
|
||||
long DownloadSpeedBps { get; }
|
||||
ChapterInfo ChapterInfo { get; }
|
||||
bool FixupFile { get; }
|
||||
NAudio.Lame.LameConfig LameConfig { get; }
|
||||
bool Downsample { get; }
|
||||
bool MatchSourceBitrate { get; }
|
||||
bool MoveMoovToBeginning { get; }
|
||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
||||
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||
Task<string> SaveClipsAndBookmarksAsync(string fileName);
|
||||
}
|
||||
public byte[] KeyPart1 { get; }
|
||||
public byte[]? KeyPart2 { get; }
|
||||
|
||||
public KeyData(byte[] keyPart1, byte[]? keyPart2 = null)
|
||||
{
|
||||
KeyPart1 = keyPart1;
|
||||
KeyPart2 = keyPart2;
|
||||
}
|
||||
|
||||
[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,41 +1,71 @@
|
||||
using AAXClean;
|
||||
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)
|
||||
{
|
||||
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
|
||||
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();
|
||||
|
||||
if (mp4File.MetadataItems.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);
|
||||
}
|
||||
|
||||
//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,336 +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;
|
||||
public event EventHandler DownloadCompleted;
|
||||
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;
|
||||
|
||||
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 Task _backgroundDownloadTask { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constants
|
||||
|
||||
//Download buffer size
|
||||
private const int DOWNLOAD_BUFF_SZ = 32 * 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 checkd 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
|
||||
};
|
||||
|
||||
_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()
|
||||
#endregion
|
||||
|
||||
#region Downloader
|
||||
|
||||
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
||||
private void OnUpdate(bool waitForWrite = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||
try
|
||||
if (waitForWrite || DateTime.UtcNow > NextUpdateTime)
|
||||
{
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "An error was encountered while saving the download progress to JSON");
|
||||
//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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 (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 (_backgroundDownloadTask is not null)
|
||||
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
||||
|
||||
Uri = uriToSameFile;
|
||||
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||
Serilog.Log.Error(ex, "An error was encountered while saving the download progress to JSON");
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
_backgroundDownloadTask = 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}).");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
|
||||
|
||||
foreach (var header in RequestHeaders)
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
|
||||
var response = await new HttpClient().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}.");
|
||||
|
||||
//Content length is the length of the range request, and it is only equal
|
||||
//to the complete file length if requesting Range: bytes=0-
|
||||
if (WritePosition == 0)
|
||||
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
|
||||
|
||||
var networkStream = await response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
||||
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||
|
||||
//Download the file in the background.
|
||||
_backgroundDownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
|
||||
}
|
||||
|
||||
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
||||
private async Task DownloadFile(Stream networkStream)
|
||||
{
|
||||
var downloadPosition = WritePosition;
|
||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||
|
||||
try
|
||||
{
|
||||
DateTime startTime = DateTime.Now;
|
||||
long bytesReadSinceThrottle = 0;
|
||||
int bytesRead;
|
||||
do
|
||||
{
|
||||
bytesRead = await networkStream.ReadAsync(buff, _cancellationSource.Token);
|
||||
await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token);
|
||||
|
||||
downloadPosition += bytesRead;
|
||||
|
||||
if (downloadPosition > nextFlush)
|
||||
{
|
||||
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||
WritePosition = downloadPosition;
|
||||
OnUpdate();
|
||||
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
_downloadedPiece.Set();
|
||||
}
|
||||
|
||||
#region throttle
|
||||
|
||||
bytesReadSinceThrottle += bytesRead;
|
||||
|
||||
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
|
||||
{
|
||||
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.Now).TotalMilliseconds;
|
||||
if (delayMS > 0)
|
||||
await Task.Delay(delayMS, _cancellationSource.Token);
|
||||
|
||||
startTime = DateTime.Now;
|
||||
bytesReadSinceThrottle = 0;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
|
||||
|
||||
WritePosition = downloadPosition;
|
||||
|
||||
if (!IsCancelled && WritePosition < ContentLength)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
if (WritePosition > ContentLength)
|
||||
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
|
||||
{
|
||||
networkStream.Close();
|
||||
_writeFile.Close();
|
||||
_downloadedPiece.Set();
|
||||
OnUpdate();
|
||||
DownloadCompleted?.Invoke(this, null);
|
||||
}
|
||||
}
|
||||
|
||||
#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
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_backgroundDownloadTask is null)
|
||||
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
||||
return ContentLength;
|
||||
}
|
||||
}
|
||||
|
||||
[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 (_backgroundDownloadTask 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
|
||||
&& _backgroundDownloadTask?.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();
|
||||
_backgroundDownloadTask?.GetAwaiter().GetResult();
|
||||
_downloadedPiece?.Dispose();
|
||||
_cancellationSource?.Dispose();
|
||||
_readFile.Dispose();
|
||||
_writeFile.Dispose();
|
||||
OnUpdate();
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
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)
|
||||
{
|
||||
//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.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeFile.Dispose();
|
||||
blockResponse.Dispose();
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
DateTime startTime = DateTime.UtcNow;
|
||||
long bytesReadSinceThrottle = 0;
|
||||
int bytesRead;
|
||||
do
|
||||
{
|
||||
bytesRead = await networkStream.ReadAsync(buff, _cancellationSource.Token);
|
||||
await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token);
|
||||
|
||||
downloadPosition += bytesRead;
|
||||
|
||||
if (downloadPosition > nextFlush)
|
||||
{
|
||||
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||
WritePosition = downloadPosition;
|
||||
OnUpdate();
|
||||
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
_downloadedPiece?.Set();
|
||||
}
|
||||
|
||||
#region throttle
|
||||
|
||||
bytesReadSinceThrottle += bytesRead;
|
||||
|
||||
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);
|
||||
|
||||
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 (OperationCanceledException)
|
||||
{
|
||||
Serilog.Log.Information("Download was cancelled");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloadedPiece?.Set();
|
||||
OnUpdate(waitForWrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
#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
|
||||
{
|
||||
get
|
||||
{
|
||||
if (DownloadTask is null)
|
||||
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
||||
return ContentLength;
|
||||
}
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter;
|
||||
|
||||
public record TempFile
|
||||
{
|
||||
public LongPath FilePath { get; init; }
|
||||
public string Extension { get; }
|
||||
public MultiConvertFileProperties? PartProperties { get; init; }
|
||||
public TempFile(LongPath filePath, string? extension = null)
|
||||
{
|
||||
FilePath = filePath;
|
||||
extension ??= System.IO.Path.GetExtension(filePath);
|
||||
Extension = FileUtility.GetStandardizedExtension(extension).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,33 @@
|
||||
using FileManager;
|
||||
using System;
|
||||
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 outFileName, string cacheDirectory, IDownloadOptions dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
{
|
||||
AsyncSteps.Name = "Download Unencrypted Audiobook";
|
||||
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||
AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
|
||||
}
|
||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
{
|
||||
await (InputFileStream.DownloadTask ?? Task.CompletedTask);
|
||||
|
||||
public override Task CancelAsync()
|
||||
if (IsCanceled)
|
||||
return false;
|
||||
else
|
||||
{
|
||||
IsCanceled = true;
|
||||
FinalizeDownload();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
{
|
||||
TaskCompletionSource completionSource = new();
|
||||
|
||||
InputFileStream.DownloadCompleted += (_, _) => completionSource.SetResult();
|
||||
|
||||
await completionSource.Task;
|
||||
|
||||
if (IsCanceled)
|
||||
return false;
|
||||
else
|
||||
{
|
||||
FinalizeDownload();
|
||||
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName);
|
||||
OnFileCreated(OutputFileName);
|
||||
return true;
|
||||
}
|
||||
var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||
FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath);
|
||||
OnTempFileCreated(tempFile);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Version>10.3.2.1</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>13.3.4.1</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.ZipFile" Version="1.0.1" />
|
||||
<PackageReference Include="Octokit" Version="14.0.0" />
|
||||
<!-- Do not remove unused Serilog.Sinks -->
|
||||
<!-- Only File sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
@@ -19,4 +23,4 @@
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,403 +1,623 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using ApplicationServices;
|
||||
using AudibleUtilities;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.IO;
|
||||
using Dinah.Core.Logging;
|
||||
using LibationFileManager;
|
||||
using System.Runtime.InteropServices;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AppScaffolding
|
||||
namespace AppScaffolding;
|
||||
|
||||
public enum ReleaseIdentifier
|
||||
{
|
||||
public enum ReleaseIdentifier
|
||||
None,
|
||||
WindowsClassic = OS.Windows | Variety.Classic | Architecture.X64,
|
||||
WindowsAvalonia = OS.Windows | Variety.Chardonnay | Architecture.X64,
|
||||
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
|
||||
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
|
||||
LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64,
|
||||
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64,
|
||||
WindowsAvalonia_Arm64 = OS.Windows | Variety.Chardonnay | Architecture.Arm64,
|
||||
}
|
||||
|
||||
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
|
||||
[Flags]
|
||||
public enum Variety
|
||||
{
|
||||
None,
|
||||
Classic = 0x10000,
|
||||
Chardonnay = 0x20000,
|
||||
}
|
||||
|
||||
public static class LibationScaffolding
|
||||
{
|
||||
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
|
||||
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
|
||||
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
|
||||
/// <summary>Documentation for naming template syntax</summary>
|
||||
public const string NamingTemplatesDocUrl = WebsiteUrl + "/docs/features/naming-templates";
|
||||
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
|
||||
public static Variety Variety { get; private set; }
|
||||
|
||||
// AppScaffolding
|
||||
private static Assembly? _executingAssembly;
|
||||
private static Assembly ExecutingAssembly
|
||||
=> _executingAssembly ??= Assembly.GetExecutingAssembly();
|
||||
|
||||
// LibationWinForms or LibationCli
|
||||
private static Assembly? _entryAssembly;
|
||||
private static Assembly? EntryAssembly
|
||||
=> _entryAssembly ??= Assembly.GetEntryAssembly();
|
||||
|
||||
// previously: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
||||
private static Version? _buildVersion;
|
||||
public static Version? BuildVersion
|
||||
=> _buildVersion ??= new[] { ExecutingAssembly.GetName(), EntryAssembly?.GetName() }.Max(a => a?.Version);
|
||||
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
{
|
||||
None,
|
||||
WindowsClassic = OS.Windows | Variety.Classic | Architecture.X64,
|
||||
WindowsAvalonia = OS.Windows | Variety.Chardonnay | Architecture.X64,
|
||||
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
|
||||
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
|
||||
LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64,
|
||||
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64
|
||||
// must occur before access to Configuration instance
|
||||
// // outdated. kept here as an example of what belongs in this area
|
||||
// // Migrations.migrate_to_v5_2_0__pre_config();
|
||||
|
||||
Configuration.SetLibationVersion(BuildVersion);
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
// do not use Configuration before this line //
|
||||
// //
|
||||
//***********************************************//
|
||||
return Configuration.Instance;
|
||||
}
|
||||
|
||||
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
|
||||
[Flags]
|
||||
public enum Variety
|
||||
/// <summary>most migrations go in here</summary>
|
||||
public static void RunPostConfigMigrations(Configuration config, bool ephemeralSettings = false)
|
||||
{
|
||||
None,
|
||||
Classic = 0x10000,
|
||||
Chardonnay = 0x20000,
|
||||
}
|
||||
|
||||
public static class LibationScaffolding
|
||||
{
|
||||
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
|
||||
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
|
||||
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
|
||||
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
|
||||
public static Variety Variety { get; private set; }
|
||||
|
||||
public static void SetReleaseIdentifier(Variety varietyType)
|
||||
if (ephemeralSettings)
|
||||
{
|
||||
Variety = Enum.IsDefined(varietyType) ? varietyType : Variety.None;
|
||||
var settings = JObject.Parse(File.ReadAllText(config.LibationFiles.SettingsFilePath));
|
||||
config.LoadEphemeralSettings(settings);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
|
||||
}
|
||||
DeleteOpenSqliteFiles(config);
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
|
||||
var releaseID = (ReleaseIdentifier)((int)varietyType | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
|
||||
//
|
||||
// migrations go below here
|
||||
//
|
||||
|
||||
if (Enum.IsDefined(releaseID))
|
||||
ReleaseIdentifier = releaseID;
|
||||
else
|
||||
Migrations.migrate_to_v6_6_9(config);
|
||||
Migrations.migrate_to_v11_5_0(config);
|
||||
Migrations.migrate_to_v11_6_5(config);
|
||||
Migrations.migrate_to_v12_0_1(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete shared memory and write-ahead log SQLite database files which may prevent access to the database.
|
||||
/// These file may or may not cause libation to hang on CreateContext,
|
||||
/// so try our luck by swallowing any exceptions and continuing.
|
||||
/// </summary>
|
||||
private static void DeleteOpenSqliteFiles(Configuration config)
|
||||
{
|
||||
var walFile = SqliteStorage.DatabasePath + "-wal";
|
||||
var shmFile = SqliteStorage.DatabasePath + "-shm";
|
||||
if (File.Exists(walFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
ReleaseIdentifier = ReleaseIdentifier.None;
|
||||
Serilog.Log.Logger.Warning("Unknown release identifier @{DebugInfo}", new { Variety = varietyType, Configuration.OS, RuntimeInformation.ProcessArchitecture });
|
||||
FileManager.FileUtility.SaferDelete(walFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {WalFile}", walFile);
|
||||
}
|
||||
}
|
||||
|
||||
// AppScaffolding
|
||||
private static Assembly _executingAssembly;
|
||||
private static Assembly ExecutingAssembly
|
||||
=> _executingAssembly ??= Assembly.GetExecutingAssembly();
|
||||
|
||||
// LibationWinForms or LibationCli
|
||||
private static Assembly _entryAssembly;
|
||||
private static Assembly EntryAssembly
|
||||
=> _entryAssembly ??= Assembly.GetEntryAssembly();
|
||||
|
||||
// previously: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
||||
private static Version _buildVersion;
|
||||
public static Version BuildVersion
|
||||
=> _buildVersion
|
||||
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
||||
.Max(a => a.Version);
|
||||
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
if (File.Exists(shmFile))
|
||||
{
|
||||
// must occur before access to Configuration instance
|
||||
// // outdated. kept here as an example of what belongs in this area
|
||||
// // Migrations.migrate_to_v5_2_0__pre_config();
|
||||
|
||||
Configuration.SetLibationVersion(BuildVersion);
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
// do not use Configuration before this line //
|
||||
// //
|
||||
//***********************************************//
|
||||
return Configuration.Instance;
|
||||
}
|
||||
|
||||
/// <summary>most migrations go in here</summary>
|
||||
public static void RunPostConfigMigrations(Configuration config)
|
||||
{
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
|
||||
//
|
||||
// migrations go below here
|
||||
//
|
||||
|
||||
Migrations.migrate_to_v6_6_9(config);
|
||||
}
|
||||
|
||||
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
||||
public static void RunPostMigrationScaffolding(Configuration config)
|
||||
{
|
||||
ensureSerilogConfig(config);
|
||||
configureLogging(config);
|
||||
logStartupState(config);
|
||||
|
||||
// all else should occur after logging
|
||||
|
||||
wireUpSystemEvents(config);
|
||||
}
|
||||
|
||||
private static void ensureSerilogConfig(Configuration config)
|
||||
{
|
||||
if (config.GetObject("Serilog") is JObject serilog)
|
||||
try
|
||||
{
|
||||
if (serilog["WriteTo"] is JArray sinks && sinks.FirstOrDefault(s => s["Name"].Value<string>() is "File") is JToken fileSink)
|
||||
FileManager.FileUtility.SaferDelete(shmFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {ShmFile}", shmFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
static bool migrationsRun = false;
|
||||
|
||||
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
||||
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
|
||||
{
|
||||
if (System.Threading.Interlocked.CompareExchange(ref migrationsRun, true, false))
|
||||
return;
|
||||
Variety = Enum.IsDefined(variety) ? variety : Variety.None;
|
||||
|
||||
var releaseID = (ReleaseIdentifier)((int)variety | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
|
||||
|
||||
ReleaseIdentifier = Enum.IsDefined(releaseID) ? releaseID : ReleaseIdentifier.None;
|
||||
|
||||
ensureSerilogConfig(config);
|
||||
configureLogging(config);
|
||||
logStartupState(config);
|
||||
|
||||
// all else should occur after logging
|
||||
|
||||
wireUpSystemEvents(config);
|
||||
}
|
||||
|
||||
private static void ensureSerilogConfig(Configuration config)
|
||||
{
|
||||
if (config.GetObject("Serilog") is JObject serilog)
|
||||
{
|
||||
bool fileChanged = false;
|
||||
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
|
||||
{
|
||||
zipFileSink["Name"] = "File";
|
||||
fileChanged = true;
|
||||
}
|
||||
var hooks = typeof(FileSinkHook).AssemblyQualifiedName;
|
||||
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
|
||||
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
|
||||
{
|
||||
fileSinkArgs["hooks"] = hooks;
|
||||
fileChanged = true;
|
||||
}
|
||||
|
||||
if (fileChanged)
|
||||
config.SetNonString(serilog.DeepClone(), "Serilog");
|
||||
return;
|
||||
}
|
||||
|
||||
var serilogObj = new JObject
|
||||
{
|
||||
{ "MinimumLevel", "Information" },
|
||||
{ "WriteTo", new JArray
|
||||
{
|
||||
fileSink["Name"] = "ZipFile";
|
||||
config.SetNonString(serilog.DeepClone(), "Serilog");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ABOUT SINKS
|
||||
// Only File sink is currently used. By user request (June 2024) others packages are included for experimental use.
|
||||
|
||||
var serilogObj = new JObject
|
||||
{
|
||||
{ "MinimumLevel", "Information" },
|
||||
{ "WriteTo", new JArray
|
||||
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||
new JObject
|
||||
{
|
||||
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||
new JObject
|
||||
{
|
||||
{ "Name", "ZipFile" },
|
||||
{ "Args",
|
||||
new JObject
|
||||
{
|
||||
// for this sink to work, a path must be provided. we override this below
|
||||
{ "path", Path.Combine(config.LibationFiles, "_Log.log") },
|
||||
{ "rollingInterval", "Month" },
|
||||
// Serilog template formatting examples
|
||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
|
||||
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
||||
// {Properties:j} needed for expanded exception logging
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" }
|
||||
}
|
||||
{ "Name", "File" },
|
||||
{ "Args",
|
||||
new JObject
|
||||
{
|
||||
// for this sink to work, a path must be provided. we override this below
|
||||
{ "path", Path.Combine(config.LibationFiles.Location, "Log.log") },
|
||||
{ "rollingInterval", "Month" },
|
||||
// Serilog template formatting examples
|
||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
|
||||
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
||||
// {Properties:j} needed for expanded exception logging
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" },
|
||||
{ "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
|
||||
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
||||
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
||||
};
|
||||
config.SetNonString(serilogObj, "Serilog");
|
||||
}
|
||||
}
|
||||
},
|
||||
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
|
||||
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
||||
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
||||
};
|
||||
config.SetNonString(serilogObj, "Serilog");
|
||||
}
|
||||
|
||||
// to restore original: Console.SetOut(origOut);
|
||||
private static TextWriter origOut { get; } = Console.Out;
|
||||
// to restore original: Console.SetOut(origOut);
|
||||
private static TextWriter origOut { get; } = Console.Out;
|
||||
|
||||
private static void configureLogging(Configuration config)
|
||||
{
|
||||
config.ConfigureLogging();
|
||||
private static void configureLogging(Configuration config)
|
||||
{
|
||||
config.ConfigureLogging();
|
||||
DbContexts.TryEmitPendingInitialDatabaseStatistics();
|
||||
|
||||
// capture most Console.WriteLine() and write to serilog. See below tests for details.
|
||||
// Some dependencies print helpful info via Console.WriteLine. We'd like to log it.
|
||||
//
|
||||
// If Serilog also writes to Console, this might be asking for trouble. ie: infinite loops.
|
||||
// To use that way, SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
|
||||
// However, empirical testing so far has shown no issues.
|
||||
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
||||
// capture most Console.WriteLine() and write to serilog. See below tests for details.
|
||||
// Some dependencies print helpful info via Console.WriteLine. We'd like to log it.
|
||||
//
|
||||
// If Serilog also writes to Console, this might be asking for trouble. ie: infinite loops.
|
||||
// To use that way, SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
|
||||
// However, empirical testing so far has shown no issues.
|
||||
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
||||
|
||||
#region Console => Serilog tests
|
||||
/*
|
||||
// all below apply to "Console." and "Console.Out."
|
||||
#region Console => Serilog tests
|
||||
/*
|
||||
// all below apply to "Console." and "Console.Out."
|
||||
|
||||
// captured
|
||||
Console.WriteLine("str");
|
||||
Console.WriteLine(new { a = "anon" });
|
||||
Console.WriteLine("{0}", "format");
|
||||
Console.WriteLine("{0}{1}", "zero|", "one");
|
||||
Console.WriteLine("{0}{1}{2}", "zero|", "one|", "two");
|
||||
Console.WriteLine("{0}", new object[] { "arr" });
|
||||
// captured
|
||||
Console.WriteLine("str");
|
||||
Console.WriteLine(new { a = "anon" });
|
||||
Console.WriteLine("{0}", "format");
|
||||
Console.WriteLine("{0}{1}", "zero|", "one");
|
||||
Console.WriteLine("{0}{1}{2}", "zero|", "one|", "two");
|
||||
Console.WriteLine("{0}", new object[] { "arr" });
|
||||
|
||||
// not captured
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(true);
|
||||
Console.WriteLine('0');
|
||||
Console.WriteLine(1);
|
||||
Console.WriteLine(2m);
|
||||
Console.WriteLine(3f);
|
||||
Console.WriteLine(4d);
|
||||
Console.WriteLine(5L);
|
||||
Console.WriteLine((uint)6);
|
||||
Console.WriteLine((ulong)7);
|
||||
// not captured
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(true);
|
||||
Console.WriteLine('0');
|
||||
Console.WriteLine(1);
|
||||
Console.WriteLine(2m);
|
||||
Console.WriteLine(3f);
|
||||
Console.WriteLine(4d);
|
||||
Console.WriteLine(5L);
|
||||
Console.WriteLine((uint)6);
|
||||
Console.WriteLine((ulong)7);
|
||||
|
||||
Console.Write("str");
|
||||
Console.Write(true);
|
||||
Console.Write('0');
|
||||
Console.Write(1);
|
||||
Console.Write(2m);
|
||||
Console.Write(3f);
|
||||
Console.Write(4d);
|
||||
Console.Write(5L);
|
||||
Console.Write((uint)6);
|
||||
Console.Write((ulong)7);
|
||||
Console.Write(new { a = "anon" });
|
||||
Console.Write("{0}", "format");
|
||||
Console.Write("{0}{1}", "zero|", "one");
|
||||
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
|
||||
Console.Write("{0}", new object[] { "arr" });
|
||||
*/
|
||||
#endregion
|
||||
Console.Write("str");
|
||||
Console.Write(true);
|
||||
Console.Write('0');
|
||||
Console.Write(1);
|
||||
Console.Write(2m);
|
||||
Console.Write(3f);
|
||||
Console.Write(4d);
|
||||
Console.Write(5L);
|
||||
Console.Write((uint)6);
|
||||
Console.Write((ulong)7);
|
||||
Console.Write(new { a = "anon" });
|
||||
Console.Write("{0}", "format");
|
||||
Console.Write("{0}{1}", "zero|", "one");
|
||||
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
|
||||
Console.Write("{0}", new object[] { "arr" });
|
||||
*/
|
||||
#endregion
|
||||
|
||||
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
||||
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
||||
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
||||
}
|
||||
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
||||
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
||||
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
||||
}
|
||||
|
||||
private static void logStartupState(Configuration config)
|
||||
{
|
||||
private static void logStartupState(Configuration config)
|
||||
{
|
||||
#if DEBUG
|
||||
var mode = "Debug";
|
||||
var mode = "Debug";
|
||||
#else
|
||||
var mode = "Release";
|
||||
var mode = "Release";
|
||||
#endif
|
||||
if (System.Diagnostics.Debugger.IsAttached)
|
||||
mode += " (Debugger attached)";
|
||||
if (Debugger.IsAttached)
|
||||
mode += " (Debugger attached)";
|
||||
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||
{
|
||||
AppName = EntryAssembly.GetName().Name,
|
||||
Version = BuildVersion.ToString(),
|
||||
ReleaseIdentifier,
|
||||
Configuration.OS,
|
||||
InteropFactory.InteropFunctionsType,
|
||||
Mode = mode,
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
|
||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
|
||||
config.BetaOptIn,
|
||||
config.UseCoverAsFolderIcon,
|
||||
config.LibationFiles,
|
||||
AudibleFileStorage.BooksDirectory,
|
||||
|
||||
config.InProgress,
|
||||
|
||||
AudibleFileStorage.DownloadsInProgressDirectory,
|
||||
DownloadsInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DownloadsInProgressDirectory).Count(),
|
||||
|
||||
AudibleFileStorage.DecryptInProgressDirectory,
|
||||
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
||||
});
|
||||
|
||||
if (InteropFactory.InteropFunctionsType is null)
|
||||
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||
static int fileCount(FileManager.LongPath? longPath)
|
||||
{
|
||||
if (longPath is null)
|
||||
return -1;
|
||||
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
|
||||
catch { return -1; }
|
||||
}
|
||||
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||
{
|
||||
AppName = EntryAssembly?.GetName().Name,
|
||||
Version = BuildVersion?.ToString(),
|
||||
ReleaseIdentifier,
|
||||
Configuration.OS,
|
||||
Environment.OSVersion,
|
||||
InteropFactory.InteropFunctionsType,
|
||||
Mode = mode,
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
|
||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
||||
|
||||
config.AutoScan,
|
||||
config.BetaOptIn,
|
||||
config.UseCoverAsFolderIcon,
|
||||
config.LibationFiles,
|
||||
AudibleFileStorage.BooksDirectory,
|
||||
|
||||
config.InProgress,
|
||||
|
||||
AudibleFileStorage.DownloadsInProgressDirectory,
|
||||
DownloadsInProgressFiles = fileCount(AudibleFileStorage.DownloadsInProgressDirectory),
|
||||
|
||||
AudibleFileStorage.DecryptInProgressDirectory,
|
||||
DecryptInProgressFiles = fileCount(AudibleFileStorage.DecryptInProgressDirectory),
|
||||
|
||||
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
|
||||
});
|
||||
|
||||
if (InteropFactory.InteropFunctionsType is null)
|
||||
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||
}
|
||||
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
{
|
||||
LibraryCommands.LibrarySizeChanged += (object? _, List<DataLayer.LibraryBook> libraryBooks)
|
||||
=> SearchEngineCommands.FullReIndex(libraryBooks);
|
||||
|
||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
|
||||
=> SearchEngineCommands.UpdateBooks(books);
|
||||
}
|
||||
|
||||
public static VersionCheckResult GetLatestRelease()
|
||||
{
|
||||
var (version, latest, zip, checkSucceeded, definitive) = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||
|
||||
if (!checkSucceeded || !definitive)
|
||||
return new VersionCheckResult(VersionCheckOutcome.UnableToDetermine);
|
||||
|
||||
if (version is null || latest is null || zip is null)
|
||||
return new VersionCheckResult(VersionCheckOutcome.UpToDate);
|
||||
|
||||
// we have an update
|
||||
var zipUrl = zip.BrowserDownloadUrl;
|
||||
|
||||
Log.Logger.Information("Update available: {@DebugInfo}", new
|
||||
{
|
||||
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
|
||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
|
||||
latestRelease = version.ToString(),
|
||||
latest.HtmlUrl,
|
||||
zipUrl
|
||||
});
|
||||
|
||||
return new VersionCheckResult(VersionCheckOutcome.UpdateAvailable, new UpgradeProperties(zipUrl, latest.HtmlUrl, zip.Name, version, latest.Body));
|
||||
}
|
||||
private static (Version? releaseVersion, Octokit.Release?, Octokit.ReleaseAsset? zip, bool checkSucceeded, bool definitive) getLatestRelease(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
var task = getLatestReleaseAsync();
|
||||
if (task.Wait(timeout))
|
||||
return (task.Result.releaseVersion, task.Result.latest, task.Result.zip, true, task.Result.definitive);
|
||||
|
||||
Log.Logger.Information("Version check timed out");
|
||||
}
|
||||
|
||||
public static UpgradeProperties GetLatestRelease()
|
||||
catch (AggregateException aggEx)
|
||||
{
|
||||
// timed out
|
||||
(var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||
|
||||
if (latest is null || zip is null)
|
||||
return null;
|
||||
|
||||
var latestVersionString = latest.TagName.Trim('v');
|
||||
if (!Version.TryParse(latestVersionString, out var latestRelease))
|
||||
return null;
|
||||
|
||||
// we're up to date
|
||||
if (latestRelease <= BuildVersion)
|
||||
return null;
|
||||
|
||||
// we have an update
|
||||
|
||||
var zipUrl = zip?.BrowserDownloadUrl;
|
||||
|
||||
Log.Logger.Information("Update available: {@DebugInfo}", new
|
||||
{
|
||||
latestRelease = latestRelease.ToString(),
|
||||
latest.HtmlUrl,
|
||||
zipUrl
|
||||
});
|
||||
|
||||
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body);
|
||||
}
|
||||
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
var task = getLatestRelease();
|
||||
if (task.Wait(timeout))
|
||||
return task.Result;
|
||||
|
||||
Log.Logger.Information("Timed out");
|
||||
}
|
||||
catch (AggregateException aggEx)
|
||||
{
|
||||
var inner = aggEx.InnerException;
|
||||
if (inner?.Message?.Contains("API rate limit", StringComparison.OrdinalIgnoreCase) == true)
|
||||
Log.Logger.Error(aggEx, "Checking for new version too often");
|
||||
}
|
||||
return (null, null);
|
||||
else
|
||||
Log.Logger.Error(aggEx, "Version check failed. Agg exception");
|
||||
}
|
||||
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
||||
catch (Exception ex)
|
||||
{
|
||||
const string ownerAccount = "rmcrackan";
|
||||
const string repoName = "Libation";
|
||||
// different text to make it easier to identify in logs, vs the AggregateException case above
|
||||
Log.Logger.Error(ex, "Version check failed. General exception");
|
||||
}
|
||||
return (null, null, null, false, false);
|
||||
}
|
||||
private static async System.Threading.Tasks.Task<(Version? releaseVersion, Octokit.Release? latest, Octokit.ReleaseAsset? zip, bool definitive)> getLatestReleaseAsync()
|
||||
{
|
||||
const string ownerAccount = "rmcrackan";
|
||||
const string repoName = "Libation";
|
||||
|
||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
||||
|
||||
//Download the release index
|
||||
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
||||
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
||||
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
|
||||
|
||||
string regexPattern;
|
||||
//Ensure that latest release is greater than the current version
|
||||
var latestVersionString = latestRelease.TagName.Trim('v');
|
||||
if (!Version.TryParse(latestVersionString, out var releaseVersion) || releaseVersion <= BuildVersion)
|
||||
return (null, null, null, true);
|
||||
|
||||
try
|
||||
//Download the release index
|
||||
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
||||
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||
|
||||
string? regexPattern;
|
||||
|
||||
try
|
||||
{
|
||||
regexPattern = releaseIndex.Value<string>(InteropFactory.Create().ReleaseIdString);
|
||||
}
|
||||
catch
|
||||
{
|
||||
regexPattern = null;
|
||||
}
|
||||
if (string.IsNullOrEmpty(regexPattern))
|
||||
regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
||||
if (string.IsNullOrEmpty(regexPattern))
|
||||
{
|
||||
Log.Logger.Warning("Release index has no entry for this platform (ReleaseIdentifier: {ReleaseId}). Version check inconclusive.", ReleaseIdentifier);
|
||||
return (null, null, null, false);
|
||||
}
|
||||
|
||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
var zip = latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name));
|
||||
|
||||
return (releaseVersion, latestRelease, zip, true);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Migrations
|
||||
{
|
||||
public static void migrate_to_v6_6_9(Configuration config)
|
||||
{
|
||||
var writeToPath = $"Serilog.WriteTo";
|
||||
|
||||
// remove WriteTo[].Name == Console
|
||||
{
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1))
|
||||
{
|
||||
regexPattern = releaseIndex.Value<string>(InteropFactory.Create().ReleaseIdString);
|
||||
for (var i = length1 - 1; i >= 0; i--)
|
||||
{
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value);
|
||||
|
||||
if (exists && value == "Console")
|
||||
UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i);
|
||||
}
|
||||
}
|
||||
catch
|
||||
}
|
||||
|
||||
// add Serilog.Exceptions -- WithExceptionDetails
|
||||
{
|
||||
// outputTemplate should contain "{Properties:j}"
|
||||
{
|
||||
regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
||||
// re-calculate. previous loop may have changed the length
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2))
|
||||
{
|
||||
var propertyName = "outputTemplate";
|
||||
for (var i = 0; i < length2; i++)
|
||||
{
|
||||
var jsonPath = $"{writeToPath}[{i}].Args";
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value);
|
||||
|
||||
var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}";
|
||||
|
||||
if (exists && value != newValue)
|
||||
UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
// Serilog.Using must include "Serilog.Exceptions"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions");
|
||||
|
||||
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
||||
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
|
||||
|
||||
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
||||
// Serilog.Enrich must include "WithExceptionDetails"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Migrations
|
||||
class FilterState_6_6_9
|
||||
{
|
||||
public static void migrate_to_v6_6_9(Configuration config)
|
||||
public bool UseDefault { get; set; }
|
||||
public List<string> Filters { get; set; } = new();
|
||||
}
|
||||
|
||||
public static void migrate_to_v12_0_1(Configuration config)
|
||||
{
|
||||
//Migrate from version 1 file cache to the dictionary-based version 2 cache
|
||||
const string FILENAME_V1 = "FileLocations.json";
|
||||
const string FILENAME_V2 = "FileLocationsV2.json";
|
||||
|
||||
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V1);
|
||||
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V2);
|
||||
|
||||
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
|
||||
{
|
||||
var writeToPath = $"Serilog.WriteTo";
|
||||
|
||||
// remove WriteTo[].Name == Console
|
||||
try
|
||||
{
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1))
|
||||
{
|
||||
for (var i = length1 - 1; i >= 0; i--)
|
||||
{
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value);
|
||||
//FilePathCache loads the cache in its static constructor,
|
||||
//so perform migration without using FilePathCache.CacheEntry
|
||||
if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0)
|
||||
return;
|
||||
|
||||
if (exists && value == "Console")
|
||||
UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
Dictionary<string, JArray> cache = new();
|
||||
|
||||
// add Serilog.Exceptions -- WithExceptionDetails
|
||||
{
|
||||
// outputTemplate should contain "{Properties:j}"
|
||||
{
|
||||
// re-calculate. previous loop may have changed the length
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2))
|
||||
//Convert to c# objects to speed up searching by ID inside the iterator
|
||||
var allItems
|
||||
= v1Cache
|
||||
.Select(i => new
|
||||
{
|
||||
var propertyName = "outputTemplate";
|
||||
for (var i = 0; i < length2; i++)
|
||||
Id = i["Id"]?.Value<string>(),
|
||||
Path = i["Path"]?["Path"]?.Value<string>()
|
||||
}).Where(i => i.Id != null)
|
||||
.ToArray();
|
||||
|
||||
foreach (var id in allItems.Select(i => i.Id).OfType<string>().Distinct())
|
||||
{
|
||||
//Use this opportunity to purge non-existent files and re-classify file types
|
||||
//(due to *.aax files previously not being classified as FileType.AAXC)
|
||||
var items = allItems
|
||||
.Where(i => i.Id == id && File.Exists(i.Path))
|
||||
.Select(i => new JObject
|
||||
{
|
||||
var jsonPath = $"{writeToPath}[{i}].Args";
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value);
|
||||
{ "Id", i.Id },
|
||||
{ "FileType", (int)FileTypes.GetFileTypeFromPath(i.Path!) },
|
||||
{ "Path", new JObject{ { "Path", i.Path } } }
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}";
|
||||
if (items.Length == 0)
|
||||
continue;
|
||||
|
||||
if (exists && value != newValue)
|
||||
UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue);
|
||||
}
|
||||
}
|
||||
cache[id] = new JArray(items);
|
||||
}
|
||||
|
||||
// Serilog.Using must include "Serilog.Exceptions"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions");
|
||||
var cacheJson = new JObject { { "Dictionary", JObject.FromObject(cache) } };
|
||||
var cacheFileText = cacheJson.ToString(Formatting.Indented);
|
||||
|
||||
// Serilog.Enrich must include "WithExceptionDetails"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
||||
void migrate()
|
||||
{
|
||||
File.WriteAllText(jsonFileV2, cacheFileText);
|
||||
File.Delete(jsonFileV1);
|
||||
}
|
||||
|
||||
try { migrate(); }
|
||||
catch (IOException)
|
||||
{
|
||||
try { migrate(); }
|
||||
catch (IOException)
|
||||
{
|
||||
migrate();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* eat */ }
|
||||
}
|
||||
}
|
||||
|
||||
public static void migrate_to_v11_6_5(Configuration config)
|
||||
{
|
||||
//Settings migration for unsupported sample rates (#1116)
|
||||
if (config.MaxSampleRate < AAXClean.SampleRate.Hz_8000)
|
||||
config.MaxSampleRate = AAXClean.SampleRate.Hz_8000;
|
||||
else if (config.MaxSampleRate > AAXClean.SampleRate.Hz_48000)
|
||||
config.MaxSampleRate = AAXClean.SampleRate.Hz_48000;
|
||||
}
|
||||
|
||||
public static void migrate_to_v11_5_0(Configuration config)
|
||||
{
|
||||
// Read file, but convert old format to new (with Name field) as necessary.
|
||||
if (!File.Exists(QuickFilters.JsonFile))
|
||||
{
|
||||
QuickFilters.InMemoryState = new();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (JsonConvert.DeserializeObject<QuickFilters.FilterState>(File.ReadAllText(QuickFilters.JsonFile))
|
||||
is QuickFilters.FilterState inMemState)
|
||||
{
|
||||
QuickFilters.InMemoryState = inMemState;
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Eat
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (JsonConvert.DeserializeObject<FilterState_6_6_9>(File.ReadAllText(QuickFilters.JsonFile))
|
||||
is FilterState_6_6_9 inMemState)
|
||||
{
|
||||
// Copy old structure to new.
|
||||
QuickFilters.InMemoryState = new();
|
||||
QuickFilters.InMemoryState.UseDefault = inMemState.UseDefault;
|
||||
foreach (var oldFilter in inMemState.Filters)
|
||||
QuickFilters.InMemoryState.Filters.Add(new QuickFilters.NamedFilter(oldFilter, null));
|
||||
|
||||
return;
|
||||
}
|
||||
Debug.Assert(false, "Should not get here, QuickFilters.json deserialization issue");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Eat
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,17 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="30.0.1" />
|
||||
<PackageReference Include="NPOI" Version="2.6.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>
|
||||
<ProjectReference Include="..\DtoImporterService\DtoImporterService.csproj" />
|
||||
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
|
||||
<ProjectReference Include="..\DataLayer.Postgres\DataLayer.Postgres.csproj" />
|
||||
<ProjectReference Include="..\DataLayer.Sqlite\DataLayer.Sqlite.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
|
||||
@@ -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<Book> Books)> 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.Book,
|
||||
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.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
||||
.Select(a => a.Book)
|
||||
.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.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
||||
.Select(a => a.Book)
|
||||
.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.Books.UpdateBookStatus(a.newStatus);
|
||||
}
|
||||
}
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
foreach (var a in actionSets)
|
||||
await a.LibraryBooks.UpdateBookStatusAsync(a.newStatus);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,126 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DataLayer;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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()
|
||||
=> LibationContext.Create(SqliteStorage.ConnectionString);
|
||||
private static bool _sqliteDbValidated;
|
||||
|
||||
/// <summary>Use for full library querying. No lazy loading</summary>
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
|
||||
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);
|
||||
LibationContextFactory.ApplyMigrations(
|
||||
context,
|
||||
string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString) ? SqliteStorage.DatabasePath : null);
|
||||
|
||||
// Validate SQLite DB file was created and is accessible (once per process; OS may delay availability)
|
||||
if (!_sqliteDbValidated && string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString))
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibrary_Flat_NoTracking(includeParents);
|
||||
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();
|
||||
}
|
||||
|
||||
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,319 +1,81 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ClosedXML.Excel;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using NPOI.XSSF.UserModel;
|
||||
using Serilog;
|
||||
using Newtonsoft.Json;
|
||||
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("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("Audio Format")]
|
||||
public string AudioFormat { get; set; }
|
||||
|
||||
[Name("Language")]
|
||||
public string Language { get; set; }
|
||||
|
||||
[Name("LastDownloaded")]
|
||||
public DateTime? LastDownloaded { get; set; }
|
||||
|
||||
[Name("LastDownloadedVersion")]
|
||||
public string LastDownloadedVersion { get; set; }
|
||||
}
|
||||
public static class LibToDtos
|
||||
{
|
||||
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,
|
||||
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,
|
||||
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
|
||||
CommunityRatingStory = a.Book.Rating?.StoryRating,
|
||||
PictureId = a.Book.PictureId,
|
||||
IsAbridged = a.Book.IsAbridged,
|
||||
DatePublished = a.Book.DatePublished,
|
||||
CategoriesNames = a.Book.CategoriesNames().Any() ? a.Book.CategoriesNames().Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
||||
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||
ContentType = a.Book.ContentType.ToString(),
|
||||
AudioFormat = a.Book.AudioFormat.ToString(),
|
||||
Language = a.Book.Language,
|
||||
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
||||
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
||||
|
||||
}).ToList();
|
||||
using var csv = new CsvWriter(new System.IO.StreamWriter(saveFilePath), CultureInfo.CurrentCulture);
|
||||
csv.WriteHeader(typeof(ExportDto));
|
||||
csv.NextRecord();
|
||||
csv.WriteRecords(dtos);
|
||||
}
|
||||
public static class LibraryExporter
|
||||
|
||||
public static void ToJson(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);
|
||||
|
||||
csv.WriteHeader(typeof(ExportDto));
|
||||
csv.NextRecord();
|
||||
csv.WriteRecords(dtos);
|
||||
}
|
||||
|
||||
public static void ToJson(string saveFilePath)
|
||||
{
|
||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented);
|
||||
System.IO.File.WriteAllText(saveFilePath, json);
|
||||
}
|
||||
|
||||
public static void ToXlsx(string saveFilePath)
|
||||
{
|
||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
var workbook = new XSSFWorkbook();
|
||||
var sheet = workbook.CreateSheet("Library");
|
||||
|
||||
var detailSubtotalFont = workbook.CreateFont();
|
||||
detailSubtotalFont.IsBold = true;
|
||||
|
||||
var detailSubtotalCellStyle = workbook.CreateCellStyle();
|
||||
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
|
||||
|
||||
// headers
|
||||
var rowIndex = 0;
|
||||
var row = sheet.CreateRow(rowIndex);
|
||||
|
||||
var columns = new[] {
|
||||
nameof(ExportDto.Account),
|
||||
nameof(ExportDto.DateAdded),
|
||||
nameof(ExportDto.AudibleProductId),
|
||||
nameof(ExportDto.Locale),
|
||||
nameof(ExportDto.Title),
|
||||
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.AudioFormat),
|
||||
nameof(ExportDto.Language),
|
||||
nameof(ExportDto.LastDownloaded),
|
||||
nameof(ExportDto.LastDownloadedVersion),
|
||||
};
|
||||
var col = 0;
|
||||
foreach (var c in columns)
|
||||
{
|
||||
var cell = row.CreateCell(col++);
|
||||
var name = ExportDto.GetName(c);
|
||||
cell.SetCellValue(name);
|
||||
cell.CellStyle = detailSubtotalCellStyle;
|
||||
}
|
||||
|
||||
var dateFormat = workbook.CreateDataFormat();
|
||||
var dateStyle = workbook.CreateCellStyle();
|
||||
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
|
||||
|
||||
rowIndex++;
|
||||
|
||||
// Add data rows
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
col = 0;
|
||||
|
||||
row = sheet.CreateRow(rowIndex);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.Account);
|
||||
|
||||
var dateCell = row.CreateCell(col++);
|
||||
dateCell.CellStyle = dateStyle;
|
||||
dateCell.SetCellValue(dto.DateAdded);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
||||
row.CreateCell(col++).SetCellValue(dto.Locale);
|
||||
row.CreateCell(col++).SetCellValue(dto.Title);
|
||||
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
||||
row.CreateCell(col++).SetCellValue(dto.Description);
|
||||
row.CreateCell(col++).SetCellValue(dto.Publisher);
|
||||
row.CreateCell(col++).SetCellValue(dto.HasPdf);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
||||
|
||||
col = createCell(row, col, dto.CommunityRatingOverall);
|
||||
col = createCell(row, col, dto.CommunityRatingPerformance);
|
||||
col = createCell(row, col, dto.CommunityRatingStory);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.PictureId);
|
||||
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
|
||||
|
||||
var datePubCell = row.CreateCell(col++);
|
||||
datePubCell.CellStyle = dateStyle;
|
||||
if (dto.DatePublished.HasValue)
|
||||
datePubCell.SetCellValue(dto.DatePublished.Value);
|
||||
else
|
||||
datePubCell.SetCellValue("");
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
|
||||
|
||||
col = createCell(row, col, dto.MyRatingOverall);
|
||||
col = createCell(row, col, dto.MyRatingPerformance);
|
||||
col = createCell(row, col, dto.MyRatingStory);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
||||
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||
|
||||
if (dto.LastDownloaded.HasValue)
|
||||
{
|
||||
dateCell = row.CreateCell(col);
|
||||
dateCell.CellStyle = dateStyle;
|
||||
dateCell.SetCellValue(dto.LastDownloaded.Value);
|
||||
}
|
||||
|
||||
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
|
||||
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||
workbook.Write(fileData);
|
||||
}
|
||||
private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat)
|
||||
{
|
||||
if (nullableFloat.HasValue)
|
||||
row.CreateCell(col++).SetCellValue(nullableFloat.Value);
|
||||
else
|
||||
row.CreateCell(col++).SetCellValue("");
|
||||
return col;
|
||||
}
|
||||
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 void ToXlsx(string saveFilePath, IEnumerable<LibraryBook>? libraryBooks = null)
|
||||
{
|
||||
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||
var dtos = libraryBooks.ToDtos();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
|
||||
|
||||
// Add data rows
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
currentRow = currentRow.RowBelow();
|
||||
currentCell = currentRow.FirstCell();
|
||||
|
||||
foreach (var column in columns)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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,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);
|
||||
}
|
||||
@@ -1,198 +1,185 @@
|
||||
using AudibleApi.Common;
|
||||
using ClosedXML.Excel;
|
||||
using CsvHelper;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NPOI.XSSF.UserModel;
|
||||
using System;
|
||||
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 XSSFWorkbook();
|
||||
var sheet = workbook.CreateSheet("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));
|
||||
|
||||
var detailSubtotalFont = workbook.CreateFont();
|
||||
detailSubtotalFont.IsBold = true;
|
||||
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 detailSubtotalCellStyle = workbook.CreateCellStyle();
|
||||
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
|
||||
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
|
||||
|
||||
// headers
|
||||
var rowIndex = 0;
|
||||
var row = sheet.CreateRow(rowIndex);
|
||||
// Add data rows
|
||||
foreach (var record in records)
|
||||
{
|
||||
col = 1;
|
||||
var row = worksheet.Row(rowIndex++);
|
||||
|
||||
var columns = new List<string>
|
||||
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);
|
||||
|
||||
var col = 0;
|
||||
foreach (var c in columns)
|
||||
{
|
||||
var cell = row.CreateCell(col++);
|
||||
cell.SetCellValue(c);
|
||||
cell.CellStyle = detailSubtotalCellStyle;
|
||||
}
|
||||
|
||||
var dateFormat = workbook.CreateDataFormat();
|
||||
var dateStyle = workbook.CreateCellStyle();
|
||||
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
|
||||
|
||||
// Add data rows
|
||||
foreach (var record in records)
|
||||
{
|
||||
col = 0;
|
||||
|
||||
row = sheet.CreateRow(++rowIndex);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(record.GetType().Name);
|
||||
|
||||
var dateCreatedCell = row.CreateCell(col++);
|
||||
dateCreatedCell.CellStyle = dateStyle;
|
||||
dateCreatedCell.SetCellValue(record.Created.DateTime);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(record.Start.TotalMilliseconds);
|
||||
|
||||
if (record is IAnnotation annotation)
|
||||
if (annotation is IRangeAnnotation rangeAnnotation)
|
||||
{
|
||||
row.CreateCell(col++).SetCellValue(annotation.AnnotationId);
|
||||
row.Cell(col++).Value = rangeAnnotation.End.TotalMilliseconds;
|
||||
row.Cell(col++).Value = rangeAnnotation.Text;
|
||||
|
||||
var lastModifiedCell = row.CreateCell(col++);
|
||||
lastModifiedCell.CellStyle = dateStyle;
|
||||
lastModifiedCell.SetCellValue(annotation.LastModified.DateTime);
|
||||
|
||||
if (annotation is IRangeAnnotation rangeAnnotation)
|
||||
{
|
||||
row.CreateCell(col++).SetCellValue(rangeAnnotation.End.TotalMilliseconds);
|
||||
row.CreateCell(col++).SetCellValue(rangeAnnotation.Text);
|
||||
|
||||
if (rangeAnnotation is Clip clip)
|
||||
row.CreateCell(col++).SetCellValue(clip.Title);
|
||||
}
|
||||
if (rangeAnnotation is Clip clip)
|
||||
row.Cell(col++).Value = clip.Title;
|
||||
}
|
||||
}
|
||||
|
||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||
workbook.Write(fileData);
|
||||
}
|
||||
|
||||
public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable<IRecord> records)
|
||||
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;
|
||||
|
||||
var recordsEx = extendRecords(records);
|
||||
|
||||
var recordsObj = new JObject
|
||||
{
|
||||
if (!records.Any())
|
||||
return;
|
||||
{ "title", libraryBook.Book.TitleWithSubtitle},
|
||||
{ "asin", libraryBook.Book.AudibleProductId},
|
||||
{ "exportTime", DateTime.Now},
|
||||
{ "records", JArray.FromObject(recordsEx) }
|
||||
};
|
||||
|
||||
var recordsEx = extendRecords(records);
|
||||
System.IO.File.WriteAllText(saveFilePath, recordsObj.ToString(Newtonsoft.Json.Formatting.Indented));
|
||||
}
|
||||
|
||||
var recordsObj = new JObject
|
||||
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.Title},
|
||||
{ "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,20 +1,26 @@
|
||||
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();
|
||||
try
|
||||
@@ -26,55 +32,72 @@ namespace ApplicationServices
|
||||
fullReIndex(engine);
|
||||
return func(engine);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
public static event EventHandler SearchEngineUpdated;
|
||||
|
||||
#region Update
|
||||
private static bool isUpdating;
|
||||
|
||||
public static void UpdateBooks(IEnumerable<Book> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
||||
public static event EventHandler? SearchEngineUpdated;
|
||||
|
||||
internal static void UpdateUserDefinedItems(Book book) => performSafeCommand(e =>
|
||||
{
|
||||
e.UpdateLiberatedStatus(book);
|
||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
|
||||
e.UpdateUserRatings(book);
|
||||
}
|
||||
);
|
||||
#region Update
|
||||
private static bool isUpdating;
|
||||
|
||||
private static void performSafeCommand(Action<SearchEngine> action)
|
||||
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
|
||||
{
|
||||
try
|
||||
{
|
||||
update(action);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
fullReIndex(new SearchEngine());
|
||||
update(action);
|
||||
}
|
||||
foreach (var book in books)
|
||||
UpdateUserDefinedItems(book);
|
||||
}
|
||||
}
|
||||
|
||||
private static void update(Action<SearchEngine> action)
|
||||
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
||||
public static void FullReIndex(List<LibraryBook> libraryBooks)
|
||||
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
|
||||
|
||||
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
|
||||
{
|
||||
if (action is null)
|
||||
return;
|
||||
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
|
||||
@@ -83,19 +106,22 @@ 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();
|
||||
engine.CreateNewIndex(library);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,103 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApi;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace AudibleUtilities
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public class Account : IUpdatable
|
||||
{
|
||||
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</summary>
|
||||
[AllowNull]
|
||||
public string? DecryptKey
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
var v = (value ?? "").Trim();
|
||||
if (v == field)
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -1,142 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApi;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AudibleUtilities
|
||||
namespace AudibleUtilities;
|
||||
|
||||
// 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended
|
||||
// from newtonsoft (https://www.newtonsoft.com/json/help/html/SerializationGuide.htm):
|
||||
// .NET : IList, IEnumerable, IList<T>, Array
|
||||
// JSON : Array (properties on the collection will not be serialized)
|
||||
public class AccountsSettings : IUpdatable
|
||||
{
|
||||
// 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended
|
||||
// from newtonsoft (https://www.newtonsoft.com/json/help/html/SerializationGuide.htm):
|
||||
// .NET : IList, IEnumerable, IList<T>, Array
|
||||
// JSON : Array (properties on the collection will not be serialized)
|
||||
public class AccountsSettings : IUpdatable
|
||||
public event EventHandler? Updated;
|
||||
private void update(object? sender = null, EventArgs? e = null)
|
||||
{
|
||||
public event EventHandler Updated;
|
||||
private void update(object sender = null, EventArgs e = null)
|
||||
{
|
||||
foreach (var account in Accounts)
|
||||
validate(account);
|
||||
update_no_validate();
|
||||
}
|
||||
private void update_no_validate() => Updated?.Invoke(this, new EventArgs());
|
||||
|
||||
public AccountsSettings() { }
|
||||
|
||||
// for some reason this will make the json instantiator use _accounts_json.set()
|
||||
[JsonConstructor]
|
||||
protected AccountsSettings(List<Account> accountsSettings) { }
|
||||
|
||||
#region Accounts
|
||||
private List<Account> _accounts_backing = new List<Account>();
|
||||
[JsonProperty(PropertyName = nameof(Accounts))]
|
||||
private List<Account> _accounts_json
|
||||
{
|
||||
get => _accounts_backing;
|
||||
// 'set' is only used by json deser
|
||||
set
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
|
||||
foreach (var account in value)
|
||||
_add(account);
|
||||
|
||||
update_no_validate();
|
||||
}
|
||||
}
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly();
|
||||
#endregion
|
||||
|
||||
#region de/serialize
|
||||
public static AccountsSettings FromJson(string json)
|
||||
=> JsonConvert.DeserializeObject<AccountsSettings>(json, Identity.GetJsonSerializerSettings());
|
||||
|
||||
public string ToJson(Formatting formatting = Formatting.Indented)
|
||||
=> JsonConvert.SerializeObject(this, formatting, Identity.GetJsonSerializerSettings());
|
||||
#endregion
|
||||
|
||||
// more common naming convention alias for internal collection
|
||||
public IReadOnlyList<Account> GetAll() => Accounts;
|
||||
|
||||
public Account Upsert(string accountId, string locale)
|
||||
{
|
||||
var acct = GetAccount(accountId, locale);
|
||||
|
||||
if (acct is not null)
|
||||
return acct;
|
||||
|
||||
var l = Localization.Get(locale);
|
||||
var id = new Identity(l);
|
||||
|
||||
var account = new Account(accountId) { IdentityTokens = id };
|
||||
Add(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
public void Add(Account account)
|
||||
{
|
||||
_add(account);
|
||||
update_no_validate();
|
||||
}
|
||||
|
||||
public void _add(Account account)
|
||||
{
|
||||
foreach (var account in Accounts)
|
||||
validate(account);
|
||||
update_no_validate();
|
||||
}
|
||||
private void update_no_validate() => Updated?.Invoke(this, new EventArgs());
|
||||
|
||||
_accounts_backing.Add(account);
|
||||
account.Updated += update;
|
||||
}
|
||||
public AccountsSettings() { }
|
||||
|
||||
public Account GetAccount(string accountId, string locale)
|
||||
// for some reason this will make the json instantiator use _accounts_json.set()
|
||||
[JsonConstructor]
|
||||
protected AccountsSettings(List<Account> accountsSettings) { }
|
||||
|
||||
#region Accounts
|
||||
private readonly List<Account> _accounts_backing = new List<Account>();
|
||||
[JsonProperty(PropertyName = nameof(Accounts))]
|
||||
private List<Account> _accounts_json
|
||||
{
|
||||
get => _accounts_backing;
|
||||
// 'set' is only used by json deser
|
||||
set
|
||||
{
|
||||
if (locale is null)
|
||||
return null;
|
||||
if (value is null)
|
||||
return;
|
||||
|
||||
return Accounts.SingleOrDefault(a => a.AccountId == accountId && a.IdentityTokens.Locale.Name == locale);
|
||||
}
|
||||
foreach (var account in value)
|
||||
_add(account);
|
||||
|
||||
public bool Delete(string accountId, string locale)
|
||||
{
|
||||
var acct = GetAccount(accountId, locale);
|
||||
if (acct is null)
|
||||
return false;
|
||||
return Delete(acct);
|
||||
}
|
||||
|
||||
public bool Delete(Account account)
|
||||
{
|
||||
if (!_accounts_backing.Contains(account))
|
||||
return false;
|
||||
|
||||
account.Updated -= update;
|
||||
var result = _accounts_backing.Remove(account);
|
||||
update_no_validate();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void validate(Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
var accountId = account.AccountId;
|
||||
var locale = account?.IdentityTokens?.Locale?.Name;
|
||||
|
||||
var acct = GetAccount(accountId, locale);
|
||||
|
||||
// new: ok
|
||||
if (acct is null)
|
||||
return;
|
||||
|
||||
// same account instance: ok
|
||||
if (acct == account)
|
||||
return;
|
||||
|
||||
// same account id + locale, different instance: bad
|
||||
throw new InvalidOperationException("Cannot add an account with the same account Id and Locale");
|
||||
}
|
||||
}
|
||||
|
||||
private string? _cdm;
|
||||
[JsonProperty]
|
||||
public string? Cdm
|
||||
{
|
||||
get => _cdm;
|
||||
set
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
|
||||
_cdm = value;
|
||||
update_no_validate();
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly();
|
||||
#endregion
|
||||
|
||||
#region de/serialize
|
||||
public static AccountsSettings? FromJson(string json)
|
||||
=> JsonConvert.DeserializeObject<AccountsSettings>(json, Identity.GetJsonSerializerSettings());
|
||||
|
||||
public string ToJson(Formatting formatting = Formatting.Indented)
|
||||
=> JsonConvert.SerializeObject(this, formatting, Identity.GetJsonSerializerSettings());
|
||||
#endregion
|
||||
|
||||
// more common naming convention alias for internal collection
|
||||
public IReadOnlyList<Account> GetAll() => Accounts;
|
||||
|
||||
public Account Upsert(string accountId, string? locale)
|
||||
{
|
||||
var acct = GetAccount(accountId, locale);
|
||||
|
||||
if (acct is not null)
|
||||
return acct;
|
||||
|
||||
var l = Localization.Get(locale);
|
||||
var id = new Identity(l);
|
||||
|
||||
var account = new Account(accountId) { IdentityTokens = id };
|
||||
Add(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
public void Add(Account account)
|
||||
{
|
||||
_add(account);
|
||||
update_no_validate();
|
||||
}
|
||||
|
||||
public void _add(Account account)
|
||||
{
|
||||
validate(account);
|
||||
|
||||
_accounts_backing.Add(account);
|
||||
account.Updated += update;
|
||||
}
|
||||
|
||||
public Account? GetAccount(string accountId, string? locale)
|
||||
{
|
||||
if (locale is null)
|
||||
return null;
|
||||
|
||||
return Accounts.SingleOrDefault(a => a.AccountId == accountId && a.Locale?.Name == locale);
|
||||
}
|
||||
|
||||
public bool Delete(string accountId, string locale)
|
||||
{
|
||||
var acct = GetAccount(accountId, locale);
|
||||
if (acct is null)
|
||||
return false;
|
||||
return Delete(acct);
|
||||
}
|
||||
|
||||
public bool Delete(Account account)
|
||||
{
|
||||
if (!_accounts_backing.Contains(account))
|
||||
return false;
|
||||
|
||||
account.Updated -= update;
|
||||
var result = _accounts_backing.Remove(account);
|
||||
update_no_validate();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void validate(Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
var accountId = account.AccountId;
|
||||
var locale = account?.IdentityTokens?.Locale?.Name;
|
||||
|
||||
var acct = GetAccount(accountId, locale);
|
||||
|
||||
// new: ok
|
||||
if (acct is null)
|
||||
return;
|
||||
|
||||
// same account instance: ok
|
||||
if (acct == account)
|
||||
return;
|
||||
|
||||
// same account id + locale, different instance: bad
|
||||
throw new InvalidOperationException("Cannot add an account with the same account Id and Locale");
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,29 @@
|
||||
using System;
|
||||
using AudibleApi.Authorization;
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core.IO;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
|
||||
namespace AudibleUtilities
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public class AccountsSettingsPersister : JsonFilePersister<AccountsSettings>
|
||||
{
|
||||
public class AccountsSettingsPersister : JsonFilePersister<AccountsSettings>
|
||||
{
|
||||
public static event EventHandler Saving;
|
||||
public static event EventHandler Saved;
|
||||
public static event EventHandler? Saving;
|
||||
public static event EventHandler? Saved;
|
||||
|
||||
protected override void OnSaving() => Saving?.Invoke(null, null);
|
||||
protected override void OnSaved() => Saved?.Invoke(null, null);
|
||||
protected override void OnSaving() => Saving?.Invoke(null, EventArgs.Empty);
|
||||
protected override void OnSaved() => Saved?.Invoke(null, EventArgs.Empty);
|
||||
|
||||
/// <summary>Alias for Target </summary>
|
||||
public AccountsSettings AccountsSettings => Target;
|
||||
/// <summary>Alias for Target </summary>
|
||||
public AccountsSettings AccountsSettings => Target;
|
||||
|
||||
/// <summary>uses path. create file if doesn't yet exist</summary>
|
||||
public AccountsSettingsPersister(AccountsSettings target, string path, string jsonPath = null)
|
||||
: base(target, path, jsonPath) { }
|
||||
/// <summary>uses path. create file if doesn't yet exist</summary>
|
||||
public AccountsSettingsPersister(AccountsSettings target, string path, string? jsonPath = null)
|
||||
: base(target, path, jsonPath) { }
|
||||
|
||||
/// <summary>load from existing file</summary>
|
||||
public AccountsSettingsPersister(string path, string jsonPath = null)
|
||||
: base(path, jsonPath) { }
|
||||
/// <summary>load from existing file</summary>
|
||||
public AccountsSettingsPersister(string path, string? jsonPath = null)
|
||||
: base(path, jsonPath) { }
|
||||
|
||||
protected override JsonSerializerSettings GetSerializerSettings()
|
||||
=> Identity.GetJsonSerializerSettings();
|
||||
}
|
||||
protected override JsonSerializerSettings GetSerializerSettings()
|
||||
=> Identity.GetJsonSerializerSettings();
|
||||
}
|
||||
@@ -1,288 +1,312 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using System.Diagnostics;
|
||||
using AudibleApi;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Common;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
||||
public class ApiExtended
|
||||
{
|
||||
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
||||
public class ApiExtended
|
||||
public static Func<Account, ILoginChoiceEager>? LoginChoiceFactory { get; set; }
|
||||
public Api Api { get; private set; }
|
||||
|
||||
private const int MaxConcurrency = 10;
|
||||
private const int BatchSize = 50;
|
||||
|
||||
private ApiExtended(Api api) => Api = api;
|
||||
|
||||
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||
{
|
||||
public Api Api { get; private set; }
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
|
||||
var locale = ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
|
||||
|
||||
private const int MaxConcurrency = 10;
|
||||
private const int BatchSize = 50;
|
||||
|
||||
private ApiExtended(Api api) => Api = api;
|
||||
|
||||
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, ILoginChoiceEager loginChoiceEager)
|
||||
try
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account?.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = account?.Locale?.Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
loginChoiceEager,
|
||||
account.Locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
|
||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
|
||||
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
AccountMaskedLogEntry = account.MaskedLogEntry
|
||||
});
|
||||
|
||||
return await CreateAsync(account.AccountId, account.Locale.Name);
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
|
||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(string username, string localeName)
|
||||
catch
|
||||
{
|
||||
if (LoginChoiceFactory is null)
|
||||
throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
|
||||
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
Username = username.ToMask(),
|
||||
LocaleName = localeName,
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
Localization.Get(localeName),
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName));
|
||||
LoginChoiceFactory(account),
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
}
|
||||
|
||||
private static AsyncRetryPolicy policy { get; }
|
||||
= Policy.Handle<Exception>()
|
||||
// 2 retries == 3 total
|
||||
.RetryAsync(2);
|
||||
private static AsyncRetryPolicy policy { get; }
|
||||
= Policy.Handle<Exception>()
|
||||
// 2 retries == 3 total
|
||||
.RetryAsync(2);
|
||||
|
||||
public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions libraryOptions, bool importEpisodes = true)
|
||||
public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions libraryOptions)
|
||||
{
|
||||
// bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or not tokens are refreshed
|
||||
// worse, this 1st dummy call doesn't seem to help:
|
||||
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
|
||||
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
|
||||
return policy.ExecuteAsync(() => getItemsAsync(libraryOptions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A debugging method used to simulate a library scan from a LibraryScans.zip json file.
|
||||
/// Simply replace the Api call to GetLibraryItemsPagesAsync() with a call to this method.
|
||||
/// </summary>
|
||||
private static async IAsyncEnumerable<Item[]> GetItemsFromJsonFile()
|
||||
{
|
||||
var libraryScanJsonPath = @"Path/to/libraryscan.json";
|
||||
using var jsonFile = System.IO.File.OpenText(libraryScanJsonPath);
|
||||
|
||||
var json = await JToken.ReadFromAsync(new Newtonsoft.Json.JsonTextReader(jsonFile));
|
||||
if (json?["Items"] is not JArray items)
|
||||
yield break;
|
||||
|
||||
foreach (var batch in items.OfType<JObject>().Select(Item.FromJson).OfType<Item>().Chunk(BatchSize))
|
||||
yield return batch;
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Beginning library scan.");
|
||||
|
||||
List<Item> items = new();
|
||||
var sw = Stopwatch.StartNew();
|
||||
var totalTime = TimeSpan.Zero;
|
||||
using var semaphore = new SemaphoreSlim(MaxConcurrency);
|
||||
|
||||
var episodeChannel = Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
||||
var batchReaderTask = readAllAsinsAsync(episodeChannel.Reader, semaphore);
|
||||
|
||||
//Scan the library for all added books.
|
||||
//Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried.
|
||||
await foreach (var itemsBatch in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore))
|
||||
{
|
||||
// bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or not tokens are refreshed
|
||||
// worse, this 1st dummy call doesn't seem to help:
|
||||
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
|
||||
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
|
||||
return policy.ExecuteAsync(() => getItemsAsync(libraryOptions, importEpisodes));
|
||||
if (Configuration.Instance.ImportEpisodes)
|
||||
{
|
||||
var episodes = itemsBatch.Where(i => i.IsEpisodes).ToList();
|
||||
var series = itemsBatch.Where(i => i.IsSeriesParent).ToList();
|
||||
|
||||
var parentAsins = episodes
|
||||
.SelectMany(i => i.Relationships ?? [])
|
||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
||||
.Select(r => r.Asin)
|
||||
.OfType<string>();
|
||||
|
||||
var episodeAsins = series
|
||||
.SelectMany(i => i.Relationships ?? [])
|
||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
|
||||
.Select(r => r.Asin)
|
||||
.OfType<string>();
|
||||
|
||||
foreach (var asin in parentAsins.Concat(episodeAsins))
|
||||
episodeChannel.Writer.TryWrite(asin);
|
||||
|
||||
items.AddRange(episodes);
|
||||
items.AddRange(series);
|
||||
}
|
||||
|
||||
var booksInBatch
|
||||
= itemsBatch
|
||||
.Where(i => !i.IsSeriesParent && !i.IsEpisodes)
|
||||
.Where(i => i.IsAyce is not true || Configuration.Instance.ImportPlusTitles);
|
||||
items.AddRange(booksInBatch);
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
|
||||
sw.Stop();
|
||||
totalTime += sw.Elapsed;
|
||||
Serilog.Log.Logger.Debug("Library scan complete after {elappsed_ms} ms. Found {count} books and series. Waiting on series episode scans to complete.", sw.ElapsedMilliseconds, items.Count);
|
||||
sw.Restart();
|
||||
|
||||
//Signal that we're done adding asins
|
||||
episodeChannel.Writer.Complete();
|
||||
|
||||
//Wait for all episodes/parents to be retrived
|
||||
var allEps = await batchReaderTask;
|
||||
|
||||
sw.Stop();
|
||||
totalTime += sw.Elapsed;
|
||||
Serilog.Log.Logger.Debug("Episode scan complete after {elappsed_ms} ms. Found {count} episodes and series .", sw.ElapsedMilliseconds, allEps.Count);
|
||||
sw.Restart();
|
||||
|
||||
Serilog.Log.Logger.Debug("Begin indexing series episodes");
|
||||
items.AddRange(allEps);
|
||||
|
||||
//Set the Item.Series info for episodes and parents.
|
||||
foreach (var parent in items.Where(i => i.IsSeriesParent))
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Beginning library scan.");
|
||||
var children = items.Where(i => i.IsEpisodes && i.Relationships?.Any(r => r.Asin == parent.Asin) is true);
|
||||
SetSeries(parent, children);
|
||||
}
|
||||
|
||||
List<Item> items = new();
|
||||
int orphansRemoved = items.RemoveAll(i => (i.IsEpisodes || i.IsSeriesParent) && i.Series is null);
|
||||
if (orphansRemoved > 0)
|
||||
Serilog.Log.Debug("{orphansRemoved} podcast orphans not imported", orphansRemoved);
|
||||
|
||||
sw.Stop();
|
||||
totalTime += sw.Elapsed;
|
||||
Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds);
|
||||
Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms.");
|
||||
|
||||
Array.ForEach(ISanitizer.GetAllSanitizers(), s => s.Sanitize(items));
|
||||
var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)).ToList();
|
||||
if (allExceptions?.Count > 0)
|
||||
throw new ImportValidationException(items, allExceptions);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
#region episodes and podcasts
|
||||
|
||||
/// <summary>
|
||||
/// Read asins from the channel and request catalog item info in batches of <see cref="BatchSize"/>. Blocks until <paramref name="channelReader"/> is closed.
|
||||
/// </summary>
|
||||
/// <param name="channelReader">Input asins to batch</param>
|
||||
/// <param name="semaphore">Shared semaphore to limit concurrency</param>
|
||||
/// <returns>All <see cref="Item"/>s of asins written to the channel.</returns>
|
||||
private async Task<List<Item>> readAllAsinsAsync(ChannelReader<string> channelReader, SemaphoreSlim semaphore)
|
||||
{
|
||||
int batchNum = 1;
|
||||
List<Task<List<Item>>> getTasks = new();
|
||||
|
||||
while (await channelReader.WaitToReadAsync())
|
||||
{
|
||||
List<string> asins = new();
|
||||
|
||||
while (asins.Count < BatchSize && await channelReader.WaitToReadAsync())
|
||||
{
|
||||
var asin = await channelReader.ReadAsync();
|
||||
|
||||
if (!asins.Contains(asin))
|
||||
asins.Add(asin);
|
||||
}
|
||||
await semaphore.WaitAsync();
|
||||
getTasks.Add(getProductsAsync(batchNum++, asins, semaphore));
|
||||
}
|
||||
|
||||
var completed = await Task.WhenAll(getTasks);
|
||||
//We only want Series parents and Series episodes. Exclude other relationship types (e.g. 'season')
|
||||
return completed.SelectMany(l => l).Where(i => i.IsSeriesParent || i.IsEpisodes).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getProductsAsync(int batchNum, List<string> asins, SemaphoreSlim semaphore)
|
||||
{
|
||||
Serilog.Log.Logger.Debug($"Batch {batchNum} Begin: Fetching {asins.Count} asins");
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var totalTime = TimeSpan.Zero;
|
||||
using var semaphore = new SemaphoreSlim(MaxConcurrency);
|
||||
|
||||
var episodeChannel = Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
||||
var batchReaderTask = readAllAsinsAsync(episodeChannel.Reader, semaphore);
|
||||
|
||||
//Scan the library for all added books.
|
||||
//Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried.
|
||||
await foreach (var item in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore))
|
||||
{
|
||||
if (importEpisodes)
|
||||
{
|
||||
var episodes = item.Where(i => i.IsEpisodes).ToList();
|
||||
var series = item.Where(i => i.IsSeriesParent).ToList();
|
||||
|
||||
var parentAsins = episodes
|
||||
.SelectMany(i => i.Relationships)
|
||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
||||
.Select(r => r.Asin);
|
||||
|
||||
var episodeAsins = series
|
||||
.SelectMany(i => i.Relationships)
|
||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
|
||||
.Select(r => r.Asin);
|
||||
|
||||
foreach (var asin in parentAsins.Concat(episodeAsins))
|
||||
episodeChannel.Writer.TryWrite(asin);
|
||||
|
||||
items.AddRange(episodes);
|
||||
items.AddRange(series);
|
||||
}
|
||||
|
||||
items.AddRange(item.Where(i => !i.IsSeriesParent && !i.IsEpisodes));
|
||||
}
|
||||
|
||||
var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media
|
||||
| CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc
|
||||
| CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview
|
||||
| CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series
|
||||
| CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs);
|
||||
sw.Stop();
|
||||
totalTime += sw.Elapsed;
|
||||
Serilog.Log.Logger.Debug("Library scan complete after {elappsed_ms} ms. Found {count} books and series. Waiting on series episode scans to complete.", sw.ElapsedMilliseconds, items.Count);
|
||||
sw.Restart();
|
||||
|
||||
//Signal that we're done adding asins
|
||||
episodeChannel.Writer.Complete();
|
||||
|
||||
//Wait for all episodes/parents to be retrived
|
||||
var allEps = await batchReaderTask;
|
||||
|
||||
sw.Stop();
|
||||
totalTime += sw.Elapsed;
|
||||
Serilog.Log.Logger.Debug("Episode scan complete after {elappsed_ms} ms. Found {count} episodes and series .", sw.ElapsedMilliseconds, allEps.Count);
|
||||
sw.Restart();
|
||||
|
||||
Serilog.Log.Logger.Debug("Begin indexing series episodes");
|
||||
items.AddRange(allEps);
|
||||
|
||||
//Set the Item.Series info for episodes and parents.
|
||||
foreach (var parent in items.Where(i => i.IsSeriesParent))
|
||||
{
|
||||
var children = items.Where(i => i.IsEpisodes && i.Relationships.Any(r => r.Asin == parent.Asin));
|
||||
SetSeries(parent, children);
|
||||
}
|
||||
|
||||
int orphansRemoved = items.RemoveAll(i => (i.IsEpisodes || i.IsSeriesParent) && i.Series is null);
|
||||
if (orphansRemoved > 0)
|
||||
Serilog.Log.Debug("{orphansRemoved} podcast orphans not imported", orphansRemoved);
|
||||
|
||||
sw.Stop();
|
||||
totalTime += sw.Elapsed;
|
||||
Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds);
|
||||
Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms.");
|
||||
|
||||
var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)).ToList();
|
||||
if (allExceptions?.Count > 0)
|
||||
throw new ImportValidationException(items, allExceptions);
|
||||
Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms");
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
#region episodes and podcasts
|
||||
|
||||
/// <summary>
|
||||
/// Read asins from the channel and request catalog item info in batches of <see cref="BatchSize"/>. Blocks until <paramref name="channelReader"/> is closed.
|
||||
/// </summary>
|
||||
/// <param name="channelReader">Input asins to batch</param>
|
||||
/// <param name="semaphore">Shared semaphore to limit concurrency</param>
|
||||
/// <returns>All <see cref="Item"/>s of asins written to the channel.</returns>
|
||||
private async Task<List<Item>> readAllAsinsAsync(ChannelReader<string> channelReader, SemaphoreSlim semaphore)
|
||||
catch (Exception ex)
|
||||
{
|
||||
int batchNum = 1;
|
||||
List<Task<List<Item>>> getTasks = new();
|
||||
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new { asins });
|
||||
throw;
|
||||
}
|
||||
finally { semaphore.Release(); }
|
||||
}
|
||||
|
||||
while (await channelReader.WaitToReadAsync())
|
||||
public static void SetSeries(Item parent, IEnumerable<Item> children)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(parent, nameof(parent));
|
||||
ArgumentValidator.EnsureNotNull(children, nameof(children));
|
||||
|
||||
//A series parent will always have exactly 1 Series
|
||||
parent.Series = new[]
|
||||
{
|
||||
new Series
|
||||
{
|
||||
List<string> asins = new();
|
||||
|
||||
while (asins.Count < BatchSize && await channelReader.WaitToReadAsync())
|
||||
{
|
||||
var asin = await channelReader.ReadAsync();
|
||||
|
||||
if (!asins.Contains(asin))
|
||||
asins.Add(asin);
|
||||
}
|
||||
await semaphore.WaitAsync();
|
||||
getTasks.Add(getProductsAsync(batchNum++, asins, semaphore));
|
||||
Asin = parent.Asin,
|
||||
Sequence = "-1",
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
|
||||
var completed = await Task.WhenAll(getTasks);
|
||||
//We only want Series parents and Series episodes. Explude other relationship types (e.g. 'season')
|
||||
return completed.SelectMany(l => l).Where(i => i.IsSeriesParent || i.IsEpisodes).ToList();
|
||||
if (parent.PurchaseDate == default)
|
||||
{
|
||||
parent.PurchaseDate = children.Select(c => c.PurchaseDate).Order().FirstOrDefault(d => d != default);
|
||||
|
||||
if (parent.PurchaseDate == default)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("{series} doesn't have a purchase date. Using UtcNow", parent);
|
||||
parent.PurchaseDate = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getProductsAsync(int batchNum, List<string> asins, SemaphoreSlim semaphore)
|
||||
int lastEpNum = -1, dupeCount = 0;
|
||||
foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime))
|
||||
{
|
||||
Serilog.Log.Logger.Debug($"Batch {batchNum} Begin: Fetching {asins.Count} asins");
|
||||
try
|
||||
string sequence;
|
||||
if (child.EpisodeNumber is null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
sw.Stop();
|
||||
|
||||
Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms");
|
||||
|
||||
return items;
|
||||
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||
sequence = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0";
|
||||
}
|
||||
catch (Exception ex)
|
||||
else
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new { asins });
|
||||
throw;
|
||||
//multipart episodes may have the same episode number
|
||||
if (child.EpisodeNumber == lastEpNum)
|
||||
dupeCount++;
|
||||
else
|
||||
lastEpNum = child.EpisodeNumber.Value;
|
||||
|
||||
sequence = (lastEpNum + dupeCount).ToString();
|
||||
}
|
||||
finally { semaphore.Release(); }
|
||||
}
|
||||
|
||||
public static void SetSeries(Item parent, IEnumerable<Item> children)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(parent, nameof(parent));
|
||||
ArgumentValidator.EnsureNotNull(children, nameof(children));
|
||||
|
||||
//A series parent will always have exactly 1 Series
|
||||
parent.Series = new[]
|
||||
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
|
||||
child.PurchaseDate = parent.PurchaseDate;
|
||||
// parent is essentially a series
|
||||
child.Series = new[]
|
||||
{
|
||||
new Series
|
||||
{
|
||||
Asin = parent.Asin,
|
||||
Sequence = "-1",
|
||||
Sequence = sequence,
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
|
||||
if (parent.PurchaseDate == default)
|
||||
{
|
||||
parent.PurchaseDate = children.Select(c => c.PurchaseDate).Order().FirstOrDefault(d => d != default);
|
||||
|
||||
if (parent.PurchaseDate == default)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("{series} doesn't have a purchase date. Using UtcNow", parent);
|
||||
parent.PurchaseDate = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
int lastEpNum = -1, dupeCount = 0;
|
||||
foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime))
|
||||
{
|
||||
string sequence;
|
||||
if (child.EpisodeNumber is null)
|
||||
{
|
||||
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||
sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0";
|
||||
}
|
||||
else
|
||||
{
|
||||
//multipart episodes may have the same episode number
|
||||
if (child.EpisodeNumber == lastEpNum)
|
||||
dupeCount++;
|
||||
else
|
||||
lastEpNum = child.EpisodeNumber.Value;
|
||||
|
||||
sequence = (lastEpNum + dupeCount).ToString();
|
||||
}
|
||||
|
||||
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
|
||||
child.PurchaseDate = parent.PurchaseDate;
|
||||
// parent is essentially a series
|
||||
child.Series = new[]
|
||||
{
|
||||
new Series
|
||||
{
|
||||
Asin = parent.Asin,
|
||||
Sequence = sequence,
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using AudibleApi.Common;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public interface ISanitizer
|
||||
{
|
||||
void Sanitize(IEnumerable<Item> items);
|
||||
|
||||
public static ISanitizer[] GetAllSanitizers() => [
|
||||
new ContributorSanitizer()
|
||||
];
|
||||
}
|
||||
|
||||
public class ContributorSanitizer : ISanitizer
|
||||
{
|
||||
public void Sanitize(IEnumerable<Item> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
item.Authors = SanitizePersonArray(item.Authors);
|
||||
item.Narrators = SanitizePersonArray(item.Narrators);
|
||||
}
|
||||
}
|
||||
|
||||
private static Person[]? SanitizePersonArray(Person?[]? contributors)
|
||||
=> contributors
|
||||
?.OfType<Person>()
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||
.ToArray();
|
||||
}
|
||||
@@ -1,47 +1,85 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AudibleUtilities
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public class AccountSettingsLoadErrorEventArgs : ErrorEventArgs
|
||||
{
|
||||
public static class AudibleApiStorage
|
||||
/// <summary>
|
||||
/// Create a new, empty <see cref="AccountsSettings"/> file if true, otherwise throw
|
||||
/// </summary>
|
||||
public bool Handled { get; set; }
|
||||
/// <summary>
|
||||
/// The file path of the AccountsSettings.json file
|
||||
/// </summary>
|
||||
public string SettingsFilePath { get; }
|
||||
|
||||
public AccountSettingsLoadErrorEventArgs(string path, Exception exception)
|
||||
: base(exception)
|
||||
{
|
||||
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
|
||||
|
||||
public static void EnsureAccountsSettingsFileExists()
|
||||
{
|
||||
// saves. BEWARE: this will overwrite an existing file
|
||||
if (!File.Exists(AccountsSettingsFile))
|
||||
_ = new AccountsSettingsPersister(new AccountsSettings(), AccountsSettingsFile);
|
||||
}
|
||||
|
||||
/// <summary>If you use this, be a good citizen and DISPOSE of it</summary>
|
||||
public static AccountsSettingsPersister GetAccountsSettingsPersister() => new AccountsSettingsPersister(AccountsSettingsFile);
|
||||
|
||||
public static string GetIdentityTokensJsonPath(this Account account)
|
||||
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
|
||||
public static string GetIdentityTokensJsonPath(string username, string localeName)
|
||||
{
|
||||
var usernameSanitized = trimSurroundingQuotes(JsonConvert.ToString(username));
|
||||
var localeNameSanitized = trimSurroundingQuotes(JsonConvert.ToString(localeName));
|
||||
|
||||
return $"$.Accounts[?(@.AccountId == '{usernameSanitized}' && @.IdentityTokens.LocaleName == '{localeNameSanitized}')].IdentityTokens";
|
||||
}
|
||||
private static string trimSurroundingQuotes(string str)
|
||||
{
|
||||
// SubString algo is better than .Trim("\"")
|
||||
// orig string "
|
||||
// json string "\""
|
||||
// Eg:
|
||||
// => str.Trim("\"")
|
||||
// output \
|
||||
// vs
|
||||
// => str.Substring(1, str.Length - 2)
|
||||
// output \"
|
||||
// also works with surrounding single quotes
|
||||
|
||||
return str.Substring(1, str.Length - 2);
|
||||
}
|
||||
SettingsFilePath = path;
|
||||
}
|
||||
}
|
||||
|
||||
public static class AudibleApiStorage
|
||||
{
|
||||
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "AccountsSettings.json");
|
||||
|
||||
public static event EventHandler<AccountSettingsLoadErrorEventArgs>? LoadError;
|
||||
|
||||
public static void EnsureAccountsSettingsFileExists()
|
||||
{
|
||||
// saves. BEWARE: this will overwrite an existing file
|
||||
if (!File.Exists(AccountsSettingsFile))
|
||||
{
|
||||
//Save the JSON file manually so that AccountsSettingsPersister.Saving and AccountsSettingsPersister.Saved
|
||||
//are not fired. There's no need to fire those events on an empty AccountsSettings file.
|
||||
var accountSerializerSettings = AudibleApi.Authorization.Identity.GetJsonSerializerSettings();
|
||||
File.WriteAllText(AccountsSettingsFile, JsonConvert.SerializeObject(new AccountsSettings(), Formatting.Indented, accountSerializerSettings));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>If you use this, be a good citizen and DISPOSE of it</summary>
|
||||
public static AccountsSettingsPersister GetAccountsSettingsPersister()
|
||||
{
|
||||
try
|
||||
{
|
||||
return new AccountsSettingsPersister(AccountsSettingsFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var args = new AccountSettingsLoadErrorEventArgs(AccountsSettingsFile, ex);
|
||||
LoadError?.Invoke(null, args);
|
||||
if (args.Handled)
|
||||
return GetAccountsSettingsPersister();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetIdentityTokensJsonPath(this Account account)
|
||||
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
|
||||
public static string GetIdentityTokensJsonPath(string username, string? localeName)
|
||||
{
|
||||
var usernameSanitized = trimSurroundingQuotes(JsonConvert.ToString(username));
|
||||
var localeNameSanitized = trimSurroundingQuotes(JsonConvert.ToString(localeName));
|
||||
|
||||
return $"$.Accounts[?(@.AccountId == '{usernameSanitized}' && @.IdentityTokens.LocaleName == '{localeNameSanitized}')].IdentityTokens";
|
||||
}
|
||||
private static string trimSurroundingQuotes(string str)
|
||||
{
|
||||
// SubString algo is better than .Trim("\"")
|
||||
// orig string "
|
||||
// json string "\""
|
||||
// Eg:
|
||||
// => str.Trim("\"")
|
||||
// output \
|
||||
// vs
|
||||
// => str.Substring(1, str.Length - 2)
|
||||
// output \"
|
||||
// also works with surrounding single quotes
|
||||
|
||||
return str.Substring(1, str.Length - 2);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +1,91 @@
|
||||
using System;
|
||||
using AudibleApi.Common;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApi.Common;
|
||||
|
||||
namespace AudibleUtilities
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public interface IValidator
|
||||
{
|
||||
public interface IValidator
|
||||
IEnumerable<Exception> Validate(IEnumerable<Item> items);
|
||||
|
||||
public static IValidator[] GetAllValidators() => [
|
||||
new LibraryValidator(),
|
||||
new BookValidator(),
|
||||
new CategoryValidator(),
|
||||
new SeriesValidator(),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// To be used when no validation is desired
|
||||
/// </summary>
|
||||
public class ClearValidator : IValidator
|
||||
{
|
||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items) => [];
|
||||
}
|
||||
public class LibraryValidator : IValidator
|
||||
{
|
||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
||||
{
|
||||
IEnumerable<Exception> Validate(IEnumerable<Item> items);
|
||||
var exceptions = new List<Exception>();
|
||||
|
||||
public static IValidator[] GetAllValidators()
|
||||
=> new IValidator[]
|
||||
{
|
||||
new LibraryValidator(),
|
||||
new BookValidator(),
|
||||
new CategoryValidator(),
|
||||
new ContributorValidator(),
|
||||
new SeriesValidator(),
|
||||
};
|
||||
}
|
||||
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
|
||||
exceptions.Add(new ArgumentException($"Collection contains item(s) with null or blank {nameof(Item.ProductId)}", nameof(items)));
|
||||
//// unfortunately, an actual user has a title with a beginning-of-time 'purchase_date'
|
||||
//if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1)))
|
||||
// exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items)));
|
||||
|
||||
public class LibraryValidator : IValidator
|
||||
{
|
||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
||||
{
|
||||
var exceptions = new List<Exception>();
|
||||
|
||||
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
|
||||
exceptions.Add(new ArgumentException($"Collection contains item(s) with null or blank {nameof(Item.ProductId)}", nameof(items)));
|
||||
if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1)))
|
||||
exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items)));
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
}
|
||||
public class BookValidator : IValidator
|
||||
{
|
||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
||||
{
|
||||
var exceptions = new List<Exception>();
|
||||
|
||||
// a book having no authors is rare but allowed
|
||||
|
||||
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
|
||||
exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.ProductId)}", nameof(items)));
|
||||
|
||||
// this can happen with podcast episodes
|
||||
foreach (var i in items.Where(i => string.IsNullOrWhiteSpace(i.Title)))
|
||||
i.Title = "[blank title]";
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
}
|
||||
public class CategoryValidator : IValidator
|
||||
{
|
||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
||||
{
|
||||
var exceptions = new List<Exception>();
|
||||
|
||||
var distinct = items.GetCategoriesDistinct();
|
||||
if (distinct.Any(s => s.CategoryId is null))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Categories)} with null {nameof(Ladder.CategoryId)}", nameof(items)));
|
||||
if (distinct.Any(s => s.CategoryName is null))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Categories)} with null {nameof(Ladder.CategoryName)}", nameof(items)));
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
}
|
||||
public class ContributorValidator : IValidator
|
||||
{
|
||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
||||
{
|
||||
var exceptions = new List<Exception>();
|
||||
|
||||
if (items.GetAuthorsDistinct().Any(a => string.IsNullOrWhiteSpace(a.Name)))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Authors)} with null {nameof(Person.Name)}", nameof(items)));
|
||||
if (items.GetNarratorsDistinct().Any(a => string.IsNullOrWhiteSpace(a.Name)))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Narrators)} with null {nameof(Person.Name)}", nameof(items)));
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
}
|
||||
public class SeriesValidator : IValidator
|
||||
{
|
||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
||||
{
|
||||
var exceptions = new List<Exception>();
|
||||
|
||||
var distinct = items.GetSeriesDistinct();
|
||||
if (distinct.Any(s => s.SeriesId is null))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
|
||||
if (distinct.Any(s => s.SeriesName is null))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
return exceptions;
|
||||
}
|
||||
}
|
||||
public class BookValidator : IValidator
|
||||
{
|
||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
||||
{
|
||||
var exceptions = new List<Exception>();
|
||||
|
||||
// a book having no authors is rare but allowed
|
||||
|
||||
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
|
||||
exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.ProductId)}", nameof(items)));
|
||||
|
||||
// this can happen with podcast episodes
|
||||
foreach (var i in items.Where(i => string.IsNullOrWhiteSpace(i.Title)))
|
||||
i.Title = "[blank title]";
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
}
|
||||
public class CategoryValidator : IValidator
|
||||
{
|
||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
||||
{
|
||||
var exceptions = new List<Exception>();
|
||||
|
||||
var distinct = items.GetCategoriesDistinct();
|
||||
if (distinct.Any(s => s.CategoryId is null))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Categories)} with null {nameof(Ladder.CategoryId)}", nameof(items)));
|
||||
if (distinct.Any(s => s.CategoryName is null))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Categories)} with null {nameof(Ladder.CategoryName)}", nameof(items)));
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
}
|
||||
public class SeriesValidator : IValidator
|
||||
{
|
||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
||||
{
|
||||
var exceptions = new List<Exception>();
|
||||
|
||||
var distinct = items.GetSeriesDistinct();
|
||||
if (distinct.Any(s => s.SeriesId is null))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
|
||||
|
||||
//// unfortunately, an actual user has a series with no name
|
||||
//if (distinct.Any(s => s.SeriesName is null))
|
||||
// exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="8.4.1.1" />
|
||||
<PackageReference Include="AudibleApi" Version="10.1.5.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.33.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -20,4 +22,9 @@
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Widevine\Cdm.*.cs">
|
||||
<DependentUpon>Cdm.cs</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,24 @@
|
||||
using AudibleApi.Common;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
extension(Item item)
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines when your audible plus or free book will expire from your library
|
||||
/// plan.IsAyce from underlying AudibleApi project determines the plans to look at, first plan found is used.
|
||||
/// In some cases current date is later than end date so exclude.
|
||||
/// </summary>
|
||||
/// <returns>The DateTime that this title will become unavailable, otherwise null</returns>
|
||||
public DateTime? GetExpirationDate()
|
||||
=> item.Plans
|
||||
?.Where(p => p.IsAyce)
|
||||
.Select(p => p.EndDate)
|
||||
.FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now)
|
||||
?.DateTime;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AudibleUtilities
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public class ImportValidationException : AggregateException
|
||||
{
|
||||
public class ImportValidationException : AggregateException
|
||||
public List<Item> Items { get; }
|
||||
public ImportValidationException(List<Item> items, IEnumerable<Exception> exceptions) : base(exceptions)
|
||||
{
|
||||
public List<Item> Items { get; }
|
||||
public ImportValidationException(List<Item> items, IEnumerable<Exception> exceptions) : base(exceptions)
|
||||
{
|
||||
Items = items;
|
||||
}
|
||||
Items = items;
|
||||
}
|
||||
}
|
||||
@@ -1,205 +1,280 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using AudibleApi.Cryptography;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public partial class Mkb79Auth : IIdentityMaintainer
|
||||
{
|
||||
public partial class Mkb79Auth : IIdentityMaintainer
|
||||
[JsonProperty("website_cookies")]
|
||||
private JObject? _websiteCookies { get; set; }
|
||||
|
||||
[JsonProperty("adp_token")]
|
||||
public string? AdpToken { get; private set; }
|
||||
|
||||
[JsonProperty("access_token")]
|
||||
public string? AccessToken { get; private set; }
|
||||
|
||||
[JsonProperty("refresh_token")]
|
||||
public string? RefreshToken { get; private set; }
|
||||
|
||||
[JsonProperty("device_private_key")]
|
||||
public string? DevicePrivateKey { get; private set; }
|
||||
|
||||
[JsonProperty("store_authentication_cookie")]
|
||||
private JObject? _storeAuthenticationCookie { get; set; }
|
||||
|
||||
[JsonProperty("device_info")]
|
||||
public DeviceInfo? DeviceInfo { get; private set; }
|
||||
|
||||
[JsonProperty("customer_info")]
|
||||
public CustomerInfo? CustomerInfo { get; private set; }
|
||||
|
||||
[JsonProperty("expires")]
|
||||
private double _expires { get; set; }
|
||||
|
||||
[JsonProperty("locale_code")]
|
||||
public string? LocaleCode { get; private set; }
|
||||
|
||||
[JsonProperty("with_username")]
|
||||
public bool WithUsername { get; private set; }
|
||||
|
||||
[JsonProperty("activation_bytes")]
|
||||
public string? ActivationBytes { get; private set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Dictionary<string, string?>? WebsiteCookies
|
||||
{
|
||||
[JsonProperty("website_cookies")]
|
||||
private JObject _websiteCookies { get; set; }
|
||||
|
||||
[JsonProperty("adp_token")]
|
||||
public string AdpToken { get; private set; }
|
||||
|
||||
[JsonProperty("access_token")]
|
||||
public string AccessToken { get; private set; }
|
||||
|
||||
[JsonProperty("refresh_token")]
|
||||
public string RefreshToken { get; private set; }
|
||||
|
||||
[JsonProperty("device_private_key")]
|
||||
public string DevicePrivateKey { get; private set; }
|
||||
|
||||
[JsonProperty("store_authentication_cookie")]
|
||||
private JObject _storeAuthenticationCookie { get; set; }
|
||||
|
||||
[JsonProperty("device_info")]
|
||||
public DeviceInfo DeviceInfo { get; private set; }
|
||||
|
||||
[JsonProperty("customer_info")]
|
||||
public CustomerInfo CustomerInfo { get; private set; }
|
||||
|
||||
[JsonProperty("expires")]
|
||||
private double _expires { get; set; }
|
||||
|
||||
[JsonProperty("locale_code")]
|
||||
public string LocaleCode { get; private set; }
|
||||
|
||||
[JsonProperty("with_username")]
|
||||
public bool WithUsername { get; private set; }
|
||||
|
||||
[JsonProperty("activation_bytes")]
|
||||
public string ActivationBytes { get; private set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Dictionary<string, string> WebsiteCookies
|
||||
{
|
||||
get => _websiteCookies.ToObject<Dictionary<string, string>>();
|
||||
private set => _websiteCookies = JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings));
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string StoreAuthenticationCookie
|
||||
{
|
||||
get => _storeAuthenticationCookie.ToObject<Dictionary<string, string>>()["cookie"];
|
||||
private set => _storeAuthenticationCookie = JObject.Parse(JsonConvert.SerializeObject(new Dictionary<string, string>() { { "cookie", value } }, Converter.Settings));
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public DateTime AccessTokenExpires
|
||||
{
|
||||
get => DateTimeOffset.FromUnixTimeMilliseconds((long)(_expires * 1000)).DateTime;
|
||||
private set => _expires = new DateTimeOffset(value).ToUnixTimeMilliseconds() / 1000d;
|
||||
}
|
||||
|
||||
[JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime();
|
||||
[JsonIgnore]
|
||||
public Locale Locale => Localization.Locales.Where(l => l.WithUsername == WithUsername).Single(l => l.CountryCode == LocaleCode);
|
||||
[JsonIgnore] public string DeviceSerialNumber => DeviceInfo.DeviceSerialNumber;
|
||||
[JsonIgnore] public string DeviceType => DeviceInfo.DeviceType;
|
||||
[JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId;
|
||||
|
||||
public Task<AccessToken> GetAccessTokenAsync()
|
||||
=> Task.FromResult(new AccessToken(AccessToken, AccessTokenExpires));
|
||||
|
||||
public Task<AdpToken> GetAdpTokenAsync()
|
||||
=> Task.FromResult(new AdpToken(AdpToken));
|
||||
|
||||
public Task<PrivateKey> GetPrivateKeyAsync()
|
||||
=> Task.FromResult(new PrivateKey(DevicePrivateKey));
|
||||
get => _websiteCookies?.ToObject<Dictionary<string, string?>>();
|
||||
private set => _websiteCookies = value is null || value.Count == 0
|
||||
? null
|
||||
: JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings));
|
||||
}
|
||||
|
||||
public partial class CustomerInfo
|
||||
[JsonIgnore]
|
||||
public string? StoreAuthenticationCookie
|
||||
{
|
||||
[JsonProperty("account_pool")]
|
||||
public string AccountPool { get; set; }
|
||||
|
||||
[JsonProperty("user_id")]
|
||||
public string UserId { get; set; }
|
||||
|
||||
[JsonProperty("home_region")]
|
||||
public string HomeRegion { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("given_name")]
|
||||
public string GivenName { get; set; }
|
||||
get => _storeAuthenticationCookie?.ToObject<Dictionary<string, string>>()?["cookie"];
|
||||
private set => _storeAuthenticationCookie = JObject.Parse(JsonConvert.SerializeObject(new Dictionary<string, string>() { { "cookie", value ?? "" } }, Converter.Settings));
|
||||
}
|
||||
|
||||
public partial class DeviceInfo
|
||||
[JsonIgnore]
|
||||
public DateTime AccessTokenExpires
|
||||
{
|
||||
[JsonProperty("device_name")]
|
||||
public string DeviceName { get; set; }
|
||||
|
||||
[JsonProperty("device_serial_number")]
|
||||
public string DeviceSerialNumber { get; set; }
|
||||
|
||||
[JsonProperty("device_type")]
|
||||
public string DeviceType { get; set; }
|
||||
get => DateTimeOffset.FromUnixTimeMilliseconds((long)(_expires * 1000)).DateTime;
|
||||
private set => _expires = new DateTimeOffset(value).ToUnixTimeMilliseconds() / 1000d;
|
||||
}
|
||||
|
||||
public partial class Mkb79Auth
|
||||
{
|
||||
public static Mkb79Auth FromJson(string json)
|
||||
=> JsonConvert.DeserializeObject<Mkb79Auth>(json, Converter.Settings);
|
||||
[JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime();
|
||||
[JsonIgnore]
|
||||
public Locale Locale => Localization.Locales.Where(l => l.WithUsername == WithUsername).Single(l => l.CountryCode == LocaleCode);
|
||||
[JsonIgnore] public string? DeviceSerialNumber => DeviceInfo?.DeviceSerialNumber;
|
||||
[JsonIgnore] public string? DeviceType => DeviceInfo?.DeviceType;
|
||||
[JsonIgnore] public string? AmazonAccountId => CustomerInfo?.UserId;
|
||||
|
||||
public string ToJson()
|
||||
=> JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings)).ToString(Formatting.Indented);
|
||||
public Task<AccessToken?> GetAccessTokenAsync()
|
||||
=> AccessToken is null ? Task.FromResult((AccessToken?)null) : Task.FromResult((AccessToken?)new AccessToken(AccessToken, AccessTokenExpires));
|
||||
|
||||
public async Task<Account> ToAccountAsync()
|
||||
{
|
||||
var refreshToken = new RefreshToken(RefreshToken);
|
||||
public Task<AdpToken?> GetAdpTokenAsync()
|
||||
=> AdpToken is null ? Task.FromResult((AdpToken?)null) : Task.FromResult((AdpToken?)new AdpToken(AdpToken));
|
||||
|
||||
var authorize = new Authorize(Locale);
|
||||
var newToken = await authorize.RefreshAccessTokenAsync(refreshToken);
|
||||
AccessToken = newToken.TokenValue;
|
||||
AccessTokenExpires = newToken.Expires;
|
||||
|
||||
var api = new Api(this);
|
||||
var email = await api.GetEmailAsync();
|
||||
var account = new Account(email)
|
||||
{
|
||||
DecryptKey = ActivationBytes,
|
||||
AccountName = $"{email} - {Locale.Name}",
|
||||
IdentityTokens = new Identity(Locale)
|
||||
};
|
||||
|
||||
account.IdentityTokens.Update(
|
||||
await GetPrivateKeyAsync(),
|
||||
await GetAdpTokenAsync(),
|
||||
await GetAccessTokenAsync(),
|
||||
refreshToken,
|
||||
WebsiteCookies.Select(c => new KeyValuePair<string, string>(c.Key, c.Value)),
|
||||
DeviceSerialNumber,
|
||||
DeviceType,
|
||||
AmazonAccountId,
|
||||
DeviceInfo.DeviceName,
|
||||
StoreAuthenticationCookie);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public static Mkb79Auth FromAccount(Account account)
|
||||
=> new()
|
||||
{
|
||||
AccessToken = account.IdentityTokens.ExistingAccessToken.TokenValue,
|
||||
ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey,
|
||||
AdpToken = account.IdentityTokens.AdpToken.Value,
|
||||
CustomerInfo = new CustomerInfo
|
||||
{
|
||||
AccountPool = "Amazon",
|
||||
GivenName = string.Empty,
|
||||
HomeRegion = "NA",
|
||||
Name = string.Empty,
|
||||
UserId = account.IdentityTokens.AmazonAccountId
|
||||
},
|
||||
DeviceInfo = new DeviceInfo
|
||||
{
|
||||
DeviceName = account.IdentityTokens.DeviceName,
|
||||
DeviceSerialNumber = account.IdentityTokens.DeviceSerialNumber,
|
||||
DeviceType = account.IdentityTokens.DeviceType,
|
||||
},
|
||||
DevicePrivateKey = account.IdentityTokens.PrivateKey,
|
||||
AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires,
|
||||
LocaleCode = account.Locale.CountryCode,
|
||||
WithUsername = account.Locale.WithUsername,
|
||||
RefreshToken = account.IdentityTokens.RefreshToken.Value,
|
||||
StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie,
|
||||
WebsiteCookies = new(account.IdentityTokens.Cookies),
|
||||
};
|
||||
}
|
||||
|
||||
public static class Serialize
|
||||
{
|
||||
public static string ToJson(this Mkb79Auth self)
|
||||
=> JObject.Parse(JsonConvert.SerializeObject(self, Converter.Settings)).ToString(Formatting.Indented);
|
||||
}
|
||||
|
||||
internal static class Converter
|
||||
{
|
||||
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
|
||||
{
|
||||
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
|
||||
DateParseHandling = DateParseHandling.None,
|
||||
};
|
||||
}
|
||||
public Task<PrivateKey?> GetPrivateKeyAsync()
|
||||
=> DevicePrivateKey is null ? Task.FromResult((PrivateKey?)null) : Task.FromResult((PrivateKey?)new PrivateKey(DevicePrivateKey));
|
||||
}
|
||||
|
||||
public partial class CustomerInfo
|
||||
{
|
||||
[JsonProperty("account_pool")]
|
||||
public string? AccountPool { get; set; }
|
||||
|
||||
[JsonProperty("user_id")]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
[JsonProperty("home_region")]
|
||||
public string? HomeRegion { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonProperty("given_name")]
|
||||
public string? GivenName { get; set; }
|
||||
}
|
||||
|
||||
public partial class DeviceInfo
|
||||
{
|
||||
[JsonProperty("device_name")]
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
[JsonProperty("device_serial_number")]
|
||||
public string? DeviceSerialNumber { get; set; }
|
||||
|
||||
[JsonProperty("device_type")]
|
||||
public string? DeviceType { get; set; }
|
||||
}
|
||||
|
||||
public partial class Mkb79Auth
|
||||
{
|
||||
public static Mkb79Auth? FromJson(string json)
|
||||
=> JsonConvert.DeserializeObject<Mkb79Auth>(json, Converter.Settings);
|
||||
|
||||
public string ToJson()
|
||||
{
|
||||
var jo = JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings));
|
||||
ApplyAudibleCliExportConventions(jo);
|
||||
return jo.ToString(Formatting.Indented);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// audible-cli expects <c>website_cookies</c> as JSON null when empty (not <c>{}</c>) and a PEM
|
||||
/// <c>device_private_key</c> with standard 64-character base64 lines and newline separators.
|
||||
/// </summary>
|
||||
internal static void ApplyAudibleCliExportConventions(JObject jo)
|
||||
{
|
||||
if (jo["website_cookies"] is JObject wc && !wc.Properties().Any())
|
||||
jo["website_cookies"] = JValue.CreateNull();
|
||||
|
||||
if (jo["device_private_key"]?.Type == JTokenType.String)
|
||||
{
|
||||
var s = jo["device_private_key"]!.Value<string>();
|
||||
var formatted = FormatDevicePrivateKeyForAudibleCliExport(s);
|
||||
if (formatted is not null)
|
||||
jo["device_private_key"] = formatted;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FormatDevicePrivateKeyForAudibleCliExport(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
|
||||
var trimmed = value.Trim();
|
||||
string payload;
|
||||
if (trimmed.StartsWith(PrivateKey.REQUIRED_BEGINNING, StringComparison.Ordinal))
|
||||
{
|
||||
var endIdx = trimmed.LastIndexOf(PrivateKey.REQUIRED_ENDING, StringComparison.Ordinal);
|
||||
if (endIdx < PrivateKey.REQUIRED_BEGINNING.Length)
|
||||
return value;
|
||||
|
||||
payload = trimmed
|
||||
.Substring(PrivateKey.REQUIRED_BEGINNING.Length, endIdx - PrivateKey.REQUIRED_BEGINNING.Length)
|
||||
.Replace("\r", "")
|
||||
.Replace("\n", "")
|
||||
.Replace("\\n", "", StringComparison.Ordinal)
|
||||
.Trim();
|
||||
}
|
||||
else
|
||||
payload = trimmed;
|
||||
|
||||
if (payload.Length == 0)
|
||||
return value;
|
||||
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(payload);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(PrivateKey.REQUIRED_BEGINNING).Append('\n');
|
||||
for (var i = 0; i < payload.Length; i += 64)
|
||||
{
|
||||
var len = Math.Min(64, payload.Length - i);
|
||||
sb.Append(payload, i, len).Append('\n');
|
||||
}
|
||||
sb.Append(PrivateKey.REQUIRED_ENDING).Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public async Task<Account> ToAccountAsync()
|
||||
{
|
||||
if (RefreshToken is null)
|
||||
throw new InvalidOperationException("Cannot create Account from Mkb79Auth without a Refresh Token.");
|
||||
if (await GetAdpTokenAsync() is not { } adpToken)
|
||||
throw new InvalidOperationException("Cannot create Account from Mkb79Auth without an ADP Token.");
|
||||
if (await GetPrivateKeyAsync() is not { } privateKey)
|
||||
throw new InvalidOperationException("Cannot create Account from Mkb79Auth without a Private Key.");
|
||||
var refreshToken = new RefreshToken(RefreshToken);
|
||||
|
||||
var authorize = new Authorize(Locale);
|
||||
var newToken = await authorize.RefreshAccessTokenAsync(refreshToken);
|
||||
AccessToken = newToken.TokenValue;
|
||||
AccessTokenExpires = newToken.Expires;
|
||||
|
||||
var api = new Api(this);
|
||||
var email = await api.GetEmailAsync();
|
||||
var account = new Account(email)
|
||||
{
|
||||
DecryptKey = ActivationBytes,
|
||||
AccountName = $"{email} - {Locale.Name}",
|
||||
IdentityTokens = new Identity(Locale)
|
||||
};
|
||||
|
||||
account.IdentityTokens.Update(
|
||||
privateKey,
|
||||
adpToken,
|
||||
newToken,
|
||||
refreshToken,
|
||||
WebsiteCookies?.Select(c => new KeyValuePair<string, string?>(c.Key, c.Value)),
|
||||
DeviceSerialNumber,
|
||||
DeviceType,
|
||||
AmazonAccountId,
|
||||
DeviceInfo?.DeviceName,
|
||||
StoreAuthenticationCookie);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public static Mkb79Auth FromAccount(Account account)
|
||||
=> new()
|
||||
{
|
||||
AccessToken = account.IdentityTokens?.ExistingAccessToken.TokenValue,
|
||||
ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey,
|
||||
AdpToken = account.IdentityTokens?.AdpToken?.Value,
|
||||
CustomerInfo = new CustomerInfo
|
||||
{
|
||||
AccountPool = "Amazon",
|
||||
GivenName = string.Empty,
|
||||
HomeRegion = "NA",
|
||||
Name = string.Empty,
|
||||
UserId = account.IdentityTokens?.AmazonAccountId
|
||||
},
|
||||
DeviceInfo = new DeviceInfo
|
||||
{
|
||||
DeviceName = account.IdentityTokens?.DeviceName,
|
||||
DeviceSerialNumber = account.IdentityTokens?.DeviceSerialNumber,
|
||||
DeviceType = account.IdentityTokens?.DeviceType,
|
||||
},
|
||||
DevicePrivateKey = account.IdentityTokens?.PrivateKey?.Value,
|
||||
AccessTokenExpires = account.IdentityTokens?.ExistingAccessToken.Expires ?? default,
|
||||
LocaleCode = account.Locale?.CountryCode,
|
||||
WithUsername = account.Locale?.WithUsername ?? false,
|
||||
RefreshToken = account.IdentityTokens?.RefreshToken?.Value,
|
||||
StoreAuthenticationCookie = account.IdentityTokens?.StoreAuthenticationCookie,
|
||||
WebsiteCookies = new(account.IdentityTokens?.Cookies ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
public static class Serialize
|
||||
{
|
||||
public static string ToJson(this Mkb79Auth self) => self.ToJson();
|
||||
}
|
||||
|
||||
internal static class Converter
|
||||
{
|
||||
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
|
||||
{
|
||||
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
|
||||
DateParseHandling = DateParseHandling.None,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using AudibleApi;
|
||||
using AudibleApi.Cryptography;
|
||||
using Dinah.Core.Net.Http;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities.Widevine;
|
||||
|
||||
public partial class Cdm
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a <see cref="Cdm"/> from <see cref="AccountsSettings"/> or from the API.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Cdm"/> if successful, otherwise <see cref="null"/></returns>
|
||||
public static async Task<Cdm?> GetCdmAsync()
|
||||
{
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
//Check if there are any Android accounts. If not, we can't use Widevine.
|
||||
if (!persister.Target.Accounts.Any(a => a.IdentityTokens?.DeviceType == Resources.DeviceType))
|
||||
return null;
|
||||
|
||||
if (!string.IsNullOrEmpty(persister.Target.Cdm))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cdm = Convert.FromBase64String(persister.Target.Cdm);
|
||||
return new Cdm(new Device(cdm));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error loading CDM from account settings.");
|
||||
persister.Target.Cdm = string.Empty;
|
||||
//Clear the stored Cdm and try getting a fresh one from the server.
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(persister.Target.Cdm))
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
if (await GetCdmUris(client) is not Uri[] uris)
|
||||
return null;
|
||||
|
||||
//try to get a CDM file for any account that's registered as an android device.
|
||||
//CDMs are not account-specific, so it doesn't matter which account we're successful with.
|
||||
foreach (var account in persister.Target.Accounts.Where(a => a.IdentityTokens?.DeviceType == Resources.DeviceType))
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestMessage = CreateApiRequest(account);
|
||||
|
||||
await TestApiRequest(client, new JsonObject { { "body", requestMessage.ToString() } });
|
||||
|
||||
//Try all CDM URIs until a CDM has been retrieved successfully
|
||||
foreach (var uri in uris)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await client.PostAsync(uri, ((HttpBody)requestMessage).Content);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var message = await resp.Content.ReadAsStringAsync();
|
||||
throw new ApiErrorException(uri, null, message);
|
||||
}
|
||||
|
||||
var cdmBts = await resp.Content.ReadAsByteArrayAsync();
|
||||
var device = new Device(cdmBts);
|
||||
persister.Target.Cdm = Convert.ToBase64String(cdmBts);
|
||||
return new Cdm(device);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error getting a CDM from URI: " + uri);
|
||||
//try the next URI
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error getting a CDM for account: " + account.MaskedLogEntry);
|
||||
//try the next Account
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a list of CDM API URIs from the main Gitgub repository's .cdmurls.json file.
|
||||
/// </summary>
|
||||
/// <returns>If successful, an array of URIs to try. Otherwise null</returns>
|
||||
private static async Task<Uri[]?> GetCdmUris(HttpClient httpClient)
|
||||
{
|
||||
const string CdmUrlListFile = "https://raw.githubusercontent.com/rmcrackan/Libation/refs/heads/master/.cdmurls.json";
|
||||
|
||||
try
|
||||
{
|
||||
var fileContents = await httpClient.GetStringAsync(CdmUrlListFile);
|
||||
var releaseIndex = JObject.Parse(fileContents);
|
||||
var urlArray = releaseIndex["CdmUrls"] as JArray;
|
||||
if (urlArray is null)
|
||||
throw new System.IO.InvalidDataException("CDM url list not found in JSON: " + fileContents);
|
||||
|
||||
var uris = urlArray.Select(u => u.Value<string>()).OfType<string>().Select(u => new Uri(u)).ToArray();
|
||||
|
||||
if (uris.Length == 0)
|
||||
throw new System.IO.InvalidDataException("No CDM url found in JSON: " + fileContents);
|
||||
|
||||
return uris;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error getting CDM URLs");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static readonly string[] TLDs = ["com", "co.uk", "com.au", "com.br", "ca", "fr", "de", "in", "it", "co.jp", "es"];
|
||||
|
||||
//Ensure that the request can be made successfully before sending it to the API
|
||||
//The API uses System.Text.Json, so perform test with same.
|
||||
private static async Task TestApiRequest(HttpClient client, JsonObject input)
|
||||
{
|
||||
if (input["body"]?.GetValue<string>() is not string body
|
||||
|| JsonNode.Parse(body) is not JsonNode bodyJson)
|
||||
throw new Exception("Api request doesn't contain a body");
|
||||
|
||||
if (bodyJson?["Url"]?.GetValue<string>() is not string url
|
||||
|| !Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
throw new Exception("Api request doesn't contain a url");
|
||||
|
||||
if (!TLDs.Select(tld => "api.audible." + tld).Contains(uri.Host.ToLower()))
|
||||
throw new Exception($"Unknown Audible Api domain: {uri.Host}");
|
||||
|
||||
if (bodyJson?["Headers"] is not JsonObject headers)
|
||||
throw new Exception($"Api request doesn't contain any headers");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
|
||||
Dictionary<string, string>? headersDict = null;
|
||||
try
|
||||
{
|
||||
headersDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(headers);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception("Failed to read Audible Api headers.", ex);
|
||||
}
|
||||
|
||||
if (headersDict is null)
|
||||
throw new Exception("Failed to read Audible Api headers.");
|
||||
|
||||
foreach (var kvp in headersDict)
|
||||
request.Headers.Add(kvp.Key, kvp.Value);
|
||||
|
||||
using var resp = await client.SendAsync(request);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a request body to send to the API
|
||||
/// </summary>
|
||||
/// <param name="account">An authenticated account</param>
|
||||
private static JObject CreateApiRequest(Account account)
|
||||
{
|
||||
const string ACCOUNT_INFO_PATH = "/1.0/account/information";
|
||||
|
||||
if (account?.Locale is null)
|
||||
throw new ArgumentException("Account does not have a valid locale.", nameof(account));
|
||||
if (account.IdentityTokens?.AdpToken is null || account.IdentityTokens.PrivateKey is null)
|
||||
throw new ArgumentException("Account does not have valid identity tokens.", nameof(account));
|
||||
|
||||
var message = new HttpRequestMessage(HttpMethod.Get, ACCOUNT_INFO_PATH);
|
||||
|
||||
message.SignRequest(
|
||||
DateTime.UtcNow,
|
||||
account.IdentityTokens.AdpToken,
|
||||
account.IdentityTokens.PrivateKey);
|
||||
|
||||
return new JObject
|
||||
{
|
||||
{ "Url", new Uri(account.Locale.AudibleApiUri(), ACCOUNT_INFO_PATH) },
|
||||
{ "Headers", JObject.FromObject(message.Headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Single())) }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using Google.Protobuf;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AudibleUtilities.Widevine;
|
||||
|
||||
public enum KeyType
|
||||
{
|
||||
/// <summary>
|
||||
/// Exactly one key of this type must appear.
|
||||
/// </summary>
|
||||
Signing = 1,
|
||||
/// <summary>
|
||||
/// Content key.
|
||||
/// </summary>
|
||||
Content = 2,
|
||||
/// <summary>
|
||||
/// Key control block for license renewals. No key.
|
||||
/// </summary>
|
||||
KeyControl = 3,
|
||||
/// <summary>
|
||||
/// wrapped keys for auxiliary crypto operations.
|
||||
/// </summary>
|
||||
OperatorSession = 4,
|
||||
/// <summary>
|
||||
/// Entitlement keys.
|
||||
/// </summary>
|
||||
Entitlement = 5,
|
||||
/// <summary>
|
||||
/// Partner-specific content key.
|
||||
/// </summary>
|
||||
OemContent = 6,
|
||||
}
|
||||
|
||||
public interface ISession : IDisposable
|
||||
{
|
||||
string GetLicenseChallenge(MpegDash dash);
|
||||
WidevineKey[] ParseLicense(string licenseMessage);
|
||||
}
|
||||
|
||||
public class WidevineKey
|
||||
{
|
||||
public Guid Kid { get; }
|
||||
public KeyType Type { get; }
|
||||
public byte[] Key { get; }
|
||||
internal WidevineKey(Guid kid, License.Types.KeyContainer.Types.KeyType type, byte[] key)
|
||||
{
|
||||
Kid = kid;
|
||||
Type = (KeyType)type;
|
||||
Key = key;
|
||||
}
|
||||
public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray(bigEndian: true)).ToLower()}:{Convert.ToHexString(Key).ToLower()}";
|
||||
}
|
||||
|
||||
public partial class Cdm
|
||||
{
|
||||
public static Guid WidevineContentProtection { get; } = new("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed");
|
||||
private const int MAX_NUM_OF_SESSIONS = 16;
|
||||
internal Device Device { get; }
|
||||
|
||||
private ConcurrentDictionary<Guid, Session> Sessions { get; } = new(-1, MAX_NUM_OF_SESSIONS);
|
||||
|
||||
internal Cdm(Device device)
|
||||
{
|
||||
Device = device;
|
||||
}
|
||||
|
||||
public ISession OpenSession()
|
||||
{
|
||||
if (Sessions.Count == MAX_NUM_OF_SESSIONS)
|
||||
throw new Exception("Too Many Sessions");
|
||||
|
||||
var session = new Session(Sessions.Count + 1, this);
|
||||
|
||||
var ddd = Sessions.TryAdd(session.Id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
#region Session
|
||||
|
||||
internal class Session : ISession
|
||||
{
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
private int SessionNumber { get; }
|
||||
private Cdm Cdm { get; }
|
||||
private byte[]? EncryptionContext { get; set; }
|
||||
private byte[]? AuthenticationContext { get; set; }
|
||||
|
||||
public Session(int number, Cdm cdm)
|
||||
{
|
||||
SessionNumber = number;
|
||||
Cdm = cdm;
|
||||
}
|
||||
|
||||
private string GetRequestId()
|
||||
=> $"{RandomUint():x8}00000000{Convert.ToHexString(BitConverter.GetBytes((long)SessionNumber)).ToLowerInvariant()}";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Cdm.Sessions.ContainsKey(Id))
|
||||
Cdm.Sessions.TryRemove(Id, out var session);
|
||||
}
|
||||
|
||||
public string GetLicenseChallenge(MpegDash dash)
|
||||
{
|
||||
if (!dash.TryGetPssh(Cdm.WidevineContentProtection, out var pssh))
|
||||
throw new InvalidDataException("No Widevine PSSH found in DASH");
|
||||
|
||||
var licRequest = new LicenseRequest
|
||||
{
|
||||
ClientId = Cdm.Device.ClientId,
|
||||
ContentId = new()
|
||||
{
|
||||
WidevinePsshData = new()
|
||||
{
|
||||
LicenseType = LicenseType.Offline,
|
||||
RequestId = ByteString.CopyFrom(GetRequestId(), Encoding.ASCII)
|
||||
}
|
||||
},
|
||||
Type = LicenseRequest.Types.RequestType.New,
|
||||
RequestTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
ProtocolVersion = ProtocolVersion.Version21,
|
||||
KeyControlNonce = RandomUint()
|
||||
};
|
||||
|
||||
licRequest.ContentId.WidevinePsshData.PsshData.Add(ByteString.CopyFrom(pssh.InitData));
|
||||
|
||||
var licRequestBts = licRequest.ToByteArray();
|
||||
EncryptionContext = CreateContext("ENCRYPTION", 128, licRequestBts);
|
||||
AuthenticationContext = CreateContext("AUTHENTICATION", 512, licRequestBts);
|
||||
|
||||
var signedMessage = new SignedMessage
|
||||
{
|
||||
Type = SignedMessage.Types.MessageType.LicenseRequest,
|
||||
Msg = ByteString.CopyFrom(licRequestBts),
|
||||
Signature = ByteString.CopyFrom(Cdm.Device.SignMessage(licRequestBts))
|
||||
};
|
||||
|
||||
return Convert.ToBase64String(signedMessage.ToByteArray());
|
||||
}
|
||||
|
||||
public WidevineKey[] ParseLicense(string licenseMessage)
|
||||
{
|
||||
if (EncryptionContext is null || AuthenticationContext is null)
|
||||
throw new InvalidOperationException($"{nameof(GetLicenseChallenge)}() must be called before calling {nameof(ParseLicense)}()");
|
||||
|
||||
var signedMessage = SignedMessage.Parser.ParseFrom(Convert.FromBase64String(licenseMessage));
|
||||
if (signedMessage.Type != SignedMessage.Types.MessageType.License)
|
||||
throw new InvalidDataException("Invalid license");
|
||||
|
||||
var sessionKey = Cdm.Device.DecryptSessionKey(signedMessage.SessionKey.ToByteArray());
|
||||
|
||||
if (!VerifySignature(signedMessage, AuthenticationContext, sessionKey))
|
||||
throw new InvalidDataException("Message signature is invalid");
|
||||
|
||||
var license = License.Parser.ParseFrom(signedMessage.Msg);
|
||||
var keyToTheKeys = DeriveKey(sessionKey, EncryptionContext, 1);
|
||||
|
||||
return DecryptKeys(keyToTheKeys, license.Key);
|
||||
}
|
||||
|
||||
private static WidevineKey[] DecryptKeys(byte[] keyToTheKeys, IList<License.Types.KeyContainer> licenseKeys)
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = keyToTheKeys;
|
||||
var keys = new WidevineKey[licenseKeys.Count];
|
||||
|
||||
for (int i = 0; i < licenseKeys.Count; i++)
|
||||
{
|
||||
var keyContainer = licenseKeys[i];
|
||||
|
||||
var keyBytes = aes.DecryptCbc(keyContainer.Key.ToByteArray(), keyContainer.Iv.ToByteArray(), PaddingMode.PKCS7);
|
||||
var id = keyContainer.Id.ToByteArray();
|
||||
|
||||
if (id.Length > 16)
|
||||
{
|
||||
var tryB64 = new byte[id.Length * 3 / 4];
|
||||
if (Convert.TryFromBase64String(Encoding.ASCII.GetString(id), tryB64, out int bytesWritten))
|
||||
{
|
||||
id = tryB64;
|
||||
}
|
||||
Array.Resize(ref id, 16);
|
||||
}
|
||||
else if (id.Length < 16)
|
||||
{
|
||||
id = id.Append(new byte[16 - id.Length]);
|
||||
}
|
||||
|
||||
keys[i] = new WidevineKey(new Guid(id, bigEndian: true), keyContainer.Type, keyBytes);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static bool VerifySignature(SignedMessage signedMessage, byte[] authContext, byte[] sessionKey)
|
||||
{
|
||||
var mac_key_server = DeriveKey(sessionKey, authContext, 1).Append(DeriveKey(sessionKey, authContext, 2));
|
||||
|
||||
var hmacData = (signedMessage.OemcryptoCoreMessage?.ToByteArray() ?? []).Append(signedMessage.Msg?.ToByteArray() ?? []);
|
||||
|
||||
var computed_signature = HMACSHA256.HashData(mac_key_server, hmacData);
|
||||
|
||||
return computed_signature.SequenceEqual(signedMessage.Signature);
|
||||
}
|
||||
|
||||
private static byte[] DeriveKey(byte[] session_key, byte[] context, int counter)
|
||||
{
|
||||
var data = new byte[context.Length + 1];
|
||||
Array.Copy(context, 0, data, 1, context.Length);
|
||||
data[0] = (byte)counter;
|
||||
|
||||
return AESCMAC(session_key, data);
|
||||
}
|
||||
|
||||
private static byte[] AESCMAC(byte[] key, byte[] data)
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = key;
|
||||
|
||||
// SubKey generation
|
||||
// step 1, AES-128 with key K is applied to an all-zero input block.
|
||||
byte[] subKey = aes.EncryptCbc(new byte[16], new byte[16], PaddingMode.None);
|
||||
|
||||
nextSubKey();
|
||||
|
||||
// MAC computing
|
||||
if ((data.Length == 0) || (data.Length % 16 != 0))
|
||||
{
|
||||
// If the size of the input message block is not equal to a positive
|
||||
// multiple of the block size (namely, 128 bits), the last block shall
|
||||
// be padded with 10^i
|
||||
nextSubKey();
|
||||
var padLen = 16 - data.Length % 16;
|
||||
Array.Resize(ref data, data.Length + padLen);
|
||||
data[^padLen] = 0x80;
|
||||
}
|
||||
|
||||
// the last block shall be exclusive-OR'ed with K1 before processing
|
||||
for (int j = 0; j < subKey.Length; j++)
|
||||
data[data.Length - 16 + j] ^= subKey[j];
|
||||
|
||||
// The result of the previous process will be the input of the last encryption.
|
||||
byte[] encResult = aes.EncryptCbc(data, new byte[16], PaddingMode.None);
|
||||
|
||||
byte[] HashValue = new byte[16];
|
||||
Array.Copy(encResult, encResult.Length - HashValue.Length, HashValue, 0, HashValue.Length);
|
||||
|
||||
return HashValue;
|
||||
|
||||
void nextSubKey()
|
||||
{
|
||||
const byte const_Rb = 0x87;
|
||||
if (Rol(subKey) != 0)
|
||||
subKey[15] ^= const_Rb;
|
||||
|
||||
static int Rol(byte[] b)
|
||||
{
|
||||
int carry = 0;
|
||||
|
||||
for (int i = b.Length - 1; i >= 0; i--)
|
||||
{
|
||||
ushort u = (ushort)(b[i] << 1);
|
||||
b[i] = (byte)((u & 0xff) + carry);
|
||||
carry = (u & 0xff00) >> 8;
|
||||
}
|
||||
return carry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreateContext(string label, int keySize, byte[] licRequestBts)
|
||||
{
|
||||
var contextSize = label.Length + 1 + licRequestBts.Length + sizeof(int);
|
||||
|
||||
var context = new byte[contextSize];
|
||||
var numChars = Encoding.ASCII.GetBytes(label.AsSpan(), context);
|
||||
Array.Copy(licRequestBts, 0, context, numChars + 1, licRequestBts.Length);
|
||||
|
||||
var numBts = BitConverter.GetBytes(keySize);
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(numBts);
|
||||
Array.Copy(numBts, 0, context, context.Length - sizeof(int), sizeof(int));
|
||||
return context;
|
||||
}
|
||||
|
||||
private static uint RandomUint()
|
||||
{
|
||||
var bts = new byte[4];
|
||||
new Random().NextBytes(bts);
|
||||
return BitConverter.ToUInt32(bts, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace AudibleUtilities.Widevine;
|
||||
|
||||
internal enum DeviceTypes : byte
|
||||
{
|
||||
Unknown = 0,
|
||||
Chrome = 1,
|
||||
Android = 2
|
||||
}
|
||||
|
||||
internal class Device
|
||||
{
|
||||
public DeviceTypes Type { get; }
|
||||
public int FileVersion { get; }
|
||||
public int SecurityLevel { get; }
|
||||
public int Flags { get; }
|
||||
|
||||
public RSA CdmKey { get; }
|
||||
internal ClientIdentification ClientId { get; }
|
||||
|
||||
public Device(Span<byte> fileData)
|
||||
{
|
||||
if (fileData.Length < 7 || fileData[0] != 'W' || fileData[1] != 'V' || fileData[2] != 'D')
|
||||
throw new InvalidDataException();
|
||||
|
||||
FileVersion = fileData[3];
|
||||
Type = (DeviceTypes)fileData[4];
|
||||
SecurityLevel = fileData[5];
|
||||
Flags = fileData[6];
|
||||
|
||||
if (FileVersion != 2)
|
||||
throw new InvalidDataException($"Unknown CDM File Version: '{FileVersion}'");
|
||||
|
||||
var privateKeyLength = (fileData[7] << 8) | fileData[8];
|
||||
|
||||
if (privateKeyLength <= 0 || fileData.Length < 9 + privateKeyLength + 2)
|
||||
throw new InvalidDataException($"Invalid private key length: '{privateKeyLength}'");
|
||||
|
||||
var clientIdLength = (fileData[9 + privateKeyLength] << 8) | fileData[10 + privateKeyLength];
|
||||
|
||||
if (clientIdLength <= 0 || fileData.Length < 11 + privateKeyLength + clientIdLength)
|
||||
throw new InvalidDataException($"Invalid client id length: '{clientIdLength}'");
|
||||
|
||||
ClientId = ClientIdentification.Parser.ParseFrom(fileData.Slice(11 + privateKeyLength));
|
||||
CdmKey = RSA.Create();
|
||||
CdmKey.ImportRSAPrivateKey(fileData.Slice(9, privateKeyLength), out _);
|
||||
}
|
||||
|
||||
public byte[] SignMessage(byte[] message)
|
||||
{
|
||||
var digestion = SHA1.HashData(message);
|
||||
return PssSha1Signer.SignHash(CdmKey, digestion);
|
||||
}
|
||||
|
||||
public bool VerifyMessage(byte[] message, byte[] signature)
|
||||
{
|
||||
var digestion = SHA1.HashData(message);
|
||||
return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
||||
}
|
||||
|
||||
public byte[] DecryptSessionKey(byte[] sessionKey)
|
||||
=> CdmKey.Decrypt(sessionKey, RSAEncryptionPadding.OaepSHA1);
|
||||
|
||||
/// <summary>
|
||||
/// Completely managed implementation of RSASSA-PSS using SHA-1.
|
||||
/// https://github.com/bcgit/bc-csharp/blob/master/crypto/src/crypto/signers/PssSigner.cs
|
||||
///
|
||||
/// Absolutely nobody anywhere should use this RSASSA-PSS implementation in anything where they care about security at all. We completely skipped the random salt part of it because libation doesn't need security; it only needs to satisfy Audible server's challenge-response requirements.
|
||||
/// </summary>
|
||||
private static class PssSha1Signer
|
||||
{
|
||||
private const int Sha1DigestSize = 20;
|
||||
private const int Trailer = 0xBC;
|
||||
|
||||
public static byte[] SignHash(RSA rsa, ReadOnlySpan<byte> hash)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNotEqual(hash.Length, Sha1DigestSize);
|
||||
|
||||
var parameters = rsa.ExportParameters(true);
|
||||
var Modulus = new BigInteger(parameters.Modulus, isUnsigned: true, isBigEndian: true);
|
||||
var Exponent = new BigInteger(parameters.D, isUnsigned: true, isBigEndian: true);
|
||||
var emBits = rsa.KeySize - 1;
|
||||
var block = new byte[(emBits + 7) / 8];
|
||||
var firstByteMask = (byte)(0xFFU >> ((block.Length * 8) - emBits));
|
||||
|
||||
Span<byte> mDash = new byte[8 + 2 * Sha1DigestSize];
|
||||
|
||||
hash.CopyTo(mDash.Slice(8));
|
||||
var h = SHA1.HashData(mDash);
|
||||
|
||||
block[^(2 * (Sha1DigestSize + 1))] = 1;
|
||||
byte[] dbMask = MaskGeneratorFunction1(h, 0, h.Length, block.Length - Sha1DigestSize - 1);
|
||||
for (int i = 0; i != dbMask.Length; i++)
|
||||
block[i] ^= dbMask[i];
|
||||
|
||||
h.CopyTo(block, block.Length - Sha1DigestSize - 1);
|
||||
|
||||
block[0] &= firstByteMask;
|
||||
block[^1] = Trailer;
|
||||
|
||||
var input = new BigInteger(block, isUnsigned: true, isBigEndian: true);
|
||||
var result = BigInteger.ModPow(input, Exponent, Modulus);
|
||||
return result.ToByteArray(isUnsigned: true, isBigEndian: true);
|
||||
}
|
||||
|
||||
private static byte[] MaskGeneratorFunction1(byte[] Z, int zOff, int zLen, int length)
|
||||
{
|
||||
byte[] mask = new byte[length];
|
||||
byte[] hashBuf = new byte[Sha1DigestSize];
|
||||
byte[] C = new byte[4];
|
||||
int counter = 0;
|
||||
|
||||
using var sha = SHA1.Create();
|
||||
|
||||
for (; counter < (length / Sha1DigestSize); counter++)
|
||||
{
|
||||
ItoOSP(counter, C);
|
||||
|
||||
sha.TransformBlock(Z, zOff, zLen, null, 0);
|
||||
sha.TransformFinalBlock(C, 0, C.Length);
|
||||
|
||||
sha.Hash!.CopyTo(mask, counter * Sha1DigestSize);
|
||||
}
|
||||
|
||||
if ((counter * Sha1DigestSize) < length)
|
||||
{
|
||||
ItoOSP(counter, C);
|
||||
|
||||
sha.TransformBlock(Z, zOff, zLen, null, 0);
|
||||
sha.TransformFinalBlock(C, 0, C.Length);
|
||||
|
||||
Array.Copy(sha.Hash!, 0, mask, counter * Sha1DigestSize, mask.Length - (counter * Sha1DigestSize));
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
private static void ItoOSP(int i, byte[] sp)
|
||||
{
|
||||
sp[0] = (byte)((uint)i >> 24);
|
||||
sp[1] = (byte)((uint)i >> 16);
|
||||
sp[2] = (byte)((uint)i >> 8);
|
||||
sp[3] = (byte)((uint)i >> 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace AudibleUtilities.Widevine;
|
||||
|
||||
internal static class Extensions
|
||||
{
|
||||
public static T[] Append<T>(this T[] message, T[] appendData)
|
||||
{
|
||||
var origLength = message.Length;
|
||||
Array.Resize(ref message, origLength + appendData.Length);
|
||||
Array.Copy(appendData, 0, message, origLength, appendData.Length);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,69 @@
|
||||
using Mpeg4Lib.Boxes;
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using System.Xml.XPath;
|
||||
|
||||
namespace AudibleUtilities.Widevine;
|
||||
|
||||
public class MpegDash
|
||||
{
|
||||
private const string MpegDashNamespace = "urn:mpeg:dash:schema:mpd:2011";
|
||||
private const string CencNamespace = "urn:mpeg:cenc:2013";
|
||||
private const string UuidPreamble = "urn:uuid:";
|
||||
private XElement DashMpd { get; }
|
||||
private static XmlNamespaceManager NamespaceManager { get; } = new(new NameTable());
|
||||
static MpegDash()
|
||||
{
|
||||
NamespaceManager.AddNamespace("dash", MpegDashNamespace);
|
||||
NamespaceManager.AddNamespace("cenc", CencNamespace);
|
||||
}
|
||||
|
||||
public MpegDash(Stream contents)
|
||||
{
|
||||
DashMpd = XElement.Load(contents);
|
||||
}
|
||||
|
||||
public bool TryGetUri(Uri baseUri, [NotNullWhen(true)] out Uri? fileUri)
|
||||
{
|
||||
foreach (var baseUrl in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:Representation/dash:BaseURL", NamespaceManager))
|
||||
{
|
||||
try
|
||||
{
|
||||
fileUri = new Uri(baseUri, baseUrl.Value);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
fileUri = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
fileUri = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetPssh(Guid protectionSystemId, [NotNullWhen(true)] out PsshBox? pssh)
|
||||
{
|
||||
foreach (var psshEle in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:ContentProtection/cenc:pssh", NamespaceManager))
|
||||
{
|
||||
if (psshEle?.Value?.Trim() is string psshStr
|
||||
&& psshEle.Parent?.Attribute(XName.Get("schemeIdUri")) is XAttribute scheme
|
||||
&& scheme.Value is string uuid
|
||||
&& uuid.Equals(UuidPreamble + protectionSystemId.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Span<byte> buffer = new byte[psshStr.Length * 3 / 4];
|
||||
if (Convert.TryFromBase64String(psshStr, buffer, out var written))
|
||||
{
|
||||
using var ms = new MemoryStream(buffer.Slice(0, written).ToArray());
|
||||
pssh = BoxFactory.CreateBox(ms, null) as PsshBox;
|
||||
return pssh is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
pssh = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Migrations\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20251027224441_InitialPostgres")]
|
||||
partial class InitialPostgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("_categoriesCategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryCategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("BookId", "CategoryLadderId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("CategoryLadderId");
|
||||
|
||||
b.ToTable("BookCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("smallint");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoriesCategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<bool>("IsFinished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating");
|
||||
});
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("CategoriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("CategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("CategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("CategoriesLink");
|
||||
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 913 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user