mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-08 11:47:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71afb5c9f4 | ||
|
|
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 | ||
|
|
da9cb3371f | ||
|
|
91d0f8020e | ||
|
|
156726ca95 | ||
|
|
3dad4c194b | ||
|
|
6025a7538a | ||
|
|
824f65baae | ||
|
|
9372a7318b | ||
|
|
ddd032c16d | ||
|
|
9aaf523240 | ||
|
|
8cbdeb38fa | ||
|
|
a9258a1811 | ||
|
|
0dbc42c407 | ||
|
|
2c91de1b3b | ||
|
|
607cd07b74 | ||
|
|
64d080336c | ||
|
|
fd510861c6 | ||
|
|
3fdfbb9e26 | ||
|
|
3e74898dac | ||
|
|
d6fe3013ab | ||
|
|
265794bae0 | ||
|
|
7586f7a159 | ||
|
|
5dfddfb549 | ||
|
|
98bb06378a | ||
|
|
429367d21c | ||
|
|
ea9e36fd76 | ||
|
|
fe534b335b | ||
|
|
6db3a8fbf3 | ||
|
|
48c69a1339 | ||
|
|
1ab882f327 | ||
|
|
019b110a8a | ||
|
|
9e14169e15 | ||
|
|
e08a68219d | ||
|
|
af24c6e07b | ||
|
|
e31847e669 | ||
|
|
c4f55d2ad1 | ||
|
|
1439e38cb0 | ||
|
|
4456432116 | ||
|
|
df2936e0b6 | ||
|
|
53b5c1b902 | ||
|
|
82fba7e752 | ||
|
|
1a95f2923b | ||
|
|
1939aae81c | ||
|
|
9a663fda15 | ||
|
|
84b2996102 | ||
|
|
af8e1cd5ef | ||
|
|
8a1b375f0d | ||
|
|
6800986f25 | ||
|
|
6110b08d16 | ||
|
|
666b5d83df | ||
|
|
7db5a34f1b | ||
|
|
e52772826a | ||
|
|
8ea9b2abc6 | ||
|
|
c10bb276f5 | ||
|
|
9dcb3b3a25 | ||
|
|
d857882220 | ||
|
|
d731db4036 | ||
|
|
ca5b40b176 | ||
|
|
b29ec26f63 | ||
|
|
7569b01bd0 | ||
|
|
6465b0a885 | ||
|
|
5e99cb6f02 | ||
|
|
d737cd2199 | ||
|
|
2d2907e076 | ||
|
|
05c454dce4 | ||
|
|
e64a9d2adf | ||
|
|
6252f015b3 | ||
|
|
7ada0082a9 | ||
|
|
826e53c9cb | ||
|
|
2248d7b24e | ||
|
|
69918c2587 | ||
|
|
1991bf5b4d | ||
|
|
756d387238 | ||
|
|
8d73f5cc7e | ||
|
|
4a65d6bbd3 | ||
|
|
10a1b56b3c | ||
|
|
66fb392b7f | ||
|
|
49ef96055c | ||
|
|
cb4a209f69 | ||
|
|
255e18eb5e | ||
|
|
7e1ec47b46 | ||
|
|
40c725b8c2 | ||
|
|
5d0937dc48 | ||
|
|
bff81bfc4b | ||
|
|
aa7c159985 | ||
|
|
012d94a146 | ||
|
|
22bd1ed121 | ||
|
|
c832f26b08 | ||
|
|
efd73d334e | ||
|
|
0db3ee6fd7 | ||
|
|
6aaf4f63d1 | ||
|
|
ab392a9285 | ||
|
|
efc9ff4bd8 | ||
|
|
a52b466c85 | ||
|
|
5611431abf | ||
|
|
a75932d1f4 | ||
|
|
6c8464b650 | ||
|
|
ba4a1c5a51 | ||
|
|
3681c0f18f | ||
|
|
e365ba7296 | ||
|
|
2afb5365dd | ||
|
|
00cf7693d5 | ||
|
|
dac6877a06 | ||
|
|
36005508a1 | ||
|
|
d9e27fd32e | ||
|
|
d86bcbb414 | ||
|
|
00cbab5b58 | ||
|
|
807725f6ff | ||
|
|
ec9356b36e | ||
|
|
add31024da | ||
|
|
27d2ada5a4 | ||
|
|
702219ee69 | ||
|
|
cdf1a01457 | ||
|
|
a71ccbac6e | ||
|
|
f8c6b836c3 | ||
|
|
090871f50d | ||
|
|
e62f01d2a3 | ||
|
|
68af6a5ebb | ||
|
|
8bba8538d5 | ||
|
|
2cd9b86930 | ||
|
|
b876d90964 | ||
|
|
49c91c273b | ||
|
|
c07bc88493 | ||
|
|
397a516dc1 | ||
|
|
1c2b51aa83 | ||
|
|
fc6f494f0d | ||
|
|
7289459170 | ||
|
|
ed6f741a65 | ||
|
|
1783da3e2d | ||
|
|
e7eac7bed3 | ||
|
|
9ae1f0399b | ||
|
|
784ab73a36 | ||
|
|
99687e968e | ||
|
|
565c84c4ab | ||
|
|
18cf20ecad | ||
|
|
2725340994 | ||
|
|
56de1e7659 | ||
|
|
fd16e97632 | ||
|
|
36076242a7 | ||
|
|
718e6c14d0 | ||
|
|
eb61ba3d69 | ||
|
|
defabf7356 | ||
|
|
1149c10cf1 | ||
|
|
ec7dd1b54a | ||
|
|
bb900b31ef | ||
|
|
eed42bd108 | ||
|
|
3f0e6b9ee5 | ||
|
|
5ec01913d5 | ||
|
|
245e55782e | ||
|
|
cc306e0e19 | ||
|
|
26a9bc6bbf | ||
|
|
fb9d062545 | ||
|
|
49c6b391fd | ||
|
|
e1cd8b8f94 | ||
|
|
ef1edf1136 | ||
|
|
0def1b426a | ||
|
|
230e014bb1 | ||
|
|
34f56d2fd7 | ||
|
|
c45ffaf4a6 | ||
|
|
ae43ab103e | ||
|
|
559977ce0b | ||
|
|
ccd4d3e26d | ||
|
|
e76f99ff28 | ||
|
|
d3607583ab | ||
|
|
3ebd4ce243 | ||
|
|
f6dcc0db1d | ||
|
|
bd49db83e4 | ||
|
|
4140722a6d | ||
|
|
da36f9414d | ||
|
|
1510f71ca6 | ||
|
|
cdb27ef712 | ||
|
|
790319ed98 | ||
|
|
1b0fb2b316 | ||
|
|
02371f2221 | ||
|
|
2b672f86be | ||
|
|
36176bff33 | ||
|
|
174b0c26b8 | ||
|
|
26c60e8e79 | ||
|
|
d94759d868 | ||
|
|
bd7e45ca3c | ||
|
|
52a863c62a | ||
|
|
fe55b90ee3 | ||
|
|
df224cc7f3 | ||
|
|
2a59329350 | ||
|
|
abdf0e7261 | ||
|
|
b9c2a1cce3 | ||
|
|
aa86fca08f | ||
|
|
cf9ec9facf | ||
|
|
f6084ef10c | ||
|
|
740b73beb7 | ||
|
|
5c45802391 | ||
|
|
429aa603f5 | ||
|
|
80ea394934 | ||
|
|
bce4437c79 | ||
|
|
b6ad1a289b | ||
|
|
2a22d05f37 | ||
|
|
d787843fd2 | ||
|
|
ded58f687d | ||
|
|
1f1f34b6ce | ||
|
|
ffadf90f4f | ||
|
|
67807efacf | ||
|
|
980f5afa54 | ||
|
|
b2f68760b2 | ||
|
|
faf86711a5 | ||
|
|
4a78b9d28f | ||
|
|
1b0a7f5062 | ||
|
|
49982043e0 | ||
|
|
378cf7057e | ||
|
|
abdc0f018e | ||
|
|
c65f61b92e | ||
|
|
c12805c8ce | ||
|
|
67f9a6db78 | ||
|
|
bb6336ce2a | ||
|
|
af7a4a6acf | ||
|
|
21d18aa565 | ||
|
|
c96875ba5d | ||
|
|
6ebbfb8e59 | ||
|
|
1e6e28cd57 | ||
|
|
defed72862 | ||
|
|
71503b34b5 | ||
|
|
a00849fb6f | ||
|
|
14b63c0883 | ||
|
|
59d556733e | ||
|
|
a99a175683 | ||
|
|
26fedcfb60 | ||
|
|
dde8024506 | ||
|
|
25f7c29380 | ||
|
|
2f347e83e8 | ||
|
|
080a74884d | ||
|
|
2dbeb64c38 | ||
|
|
bb508c0718 | ||
|
|
9a450b0d63 | ||
|
|
c1de0e60d2 | ||
|
|
dc7c03661d | ||
|
|
952eee6d32 | ||
|
|
472a0f30b9 | ||
|
|
73533c58a8 | ||
|
|
65ef018719 | ||
|
|
f0ca349539 | ||
|
|
500b287721 | ||
|
|
21f3ae45d3 | ||
|
|
d496564f0d | ||
|
|
6fdd6293ce | ||
|
|
3bca495521 | ||
|
|
0fb580f1a5 | ||
|
|
a7cd47e0b1 | ||
|
|
30aecedfae | ||
|
|
e72799efe5 | ||
|
|
ee8c0ae27b | ||
|
|
5b4a4341ad | ||
|
|
56823c1105 | ||
|
|
1f4ada604a | ||
|
|
3a4ab80892 | ||
|
|
bba9c2ba7b | ||
|
|
c4acd5d208 | ||
|
|
381440db4c | ||
|
|
00c8be1f7e | ||
|
|
d665122aa2 | ||
|
|
bb40df5fa3 | ||
|
|
e3c9f70dff | ||
|
|
b351033cec | ||
|
|
18f69bc73d | ||
|
|
39fe7b79d2 | ||
|
|
85769d797b | ||
|
|
9a80f18e1c | ||
|
|
aec8305e52 | ||
|
|
a672174a9b | ||
|
|
6f490b4491 | ||
|
|
5917d059e4 | ||
|
|
40602c7626 | ||
|
|
7d5ee2afa8 | ||
|
|
08b6f8fa11 | ||
|
|
5f9699aa3b | ||
|
|
70607aaaf4 | ||
|
|
1d96d39af7 | ||
|
|
5557772957 | ||
|
|
5c7db6cd23 | ||
|
|
c72b64d74c | ||
|
|
20474e0b3c | ||
|
|
867085600c | ||
|
|
74290ec609 | ||
|
|
5ee555e60c | ||
|
|
a36c28d48f | ||
|
|
0877f2c042 | ||
|
|
2baf5243ea | ||
|
|
b7e71f5812 | ||
|
|
2ed1076fab | ||
|
|
0b20aa751f | ||
|
|
05a4ece8d1 | ||
|
|
25b37c6266 | ||
|
|
b668cff0ac | ||
|
|
4d6c742ae9 | ||
|
|
933f663d22 | ||
|
|
0c55f278a4 | ||
|
|
3f567ee82e | ||
|
|
8dc912c11d | ||
|
|
f1b4e2a17d | ||
|
|
630cfdeab3 | ||
|
|
7029409792 | ||
|
|
d0727b5a85 | ||
|
|
9f52ad5e0a | ||
|
|
501ae643f7 | ||
|
|
400074170e | ||
|
|
17103ed066 | ||
|
|
b6b29309c9 | ||
|
|
a04538710f | ||
|
|
01f6f5c137 | ||
|
|
b1a37cbd8c | ||
|
|
8c59e1280b | ||
|
|
00339127aa | ||
|
|
5935b40b60 | ||
|
|
6cfd2dea96 | ||
|
|
7d3a39c693 | ||
|
|
6e7a4ea475 | ||
|
|
3479dbc3f0 | ||
|
|
9309aea6d9 | ||
|
|
f72551fa9a | ||
|
|
e3b237b75f | ||
|
|
b1ddf18f73 | ||
|
|
13f522abb8 | ||
|
|
3c3d956bf3 | ||
|
|
8160547c11 | ||
|
|
ef71d36dee | ||
|
|
b0d8434455 | ||
|
|
9be0d58461 | ||
|
|
1addcc8211 | ||
|
|
38c75dc8c5 | ||
|
|
89c3ea8311 | ||
|
|
18ff799fb1 | ||
|
|
67b6aaed99 | ||
|
|
08bb463560 | ||
|
|
97767dcabb | ||
|
|
fb18940a5c | ||
|
|
b823f5fa00 | ||
|
|
d64fb081a0 | ||
|
|
09118b1ddf | ||
|
|
de20590fd5 | ||
|
|
1050ffdb24 | ||
|
|
2e49c7f697 | ||
|
|
708cdcc24c | ||
|
|
c89eafd568 | ||
|
|
10de241d53 | ||
|
|
e58952035f | ||
|
|
50a8c7508a | ||
|
|
2b243a6934 | ||
|
|
ece93cb4d7 | ||
|
|
5b3ca0ed32 | ||
|
|
7474f1221a | ||
|
|
d023a943c1 | ||
|
|
4e80af5c53 | ||
|
|
eee785377f | ||
|
|
915906e6ed | ||
|
|
358c8b577e | ||
|
|
5c450a01a4 | ||
|
|
36264c6c6e | ||
|
|
fca946bf15 | ||
|
|
452ceef285 | ||
|
|
7fafee804d | ||
|
|
3a48479435 | ||
|
|
cab8555ab5 | ||
|
|
e3b7cbcc2a | ||
|
|
ed15614288 | ||
|
|
acb6d1b335 | ||
|
|
fe804796ab | ||
|
|
4725fe36d1 | ||
|
|
5c73beff4b | ||
|
|
1f7000c2c9 | ||
|
|
f09baa1318 | ||
|
|
7eaa03e43c | ||
|
|
26099303fa | ||
|
|
6417aee780 | ||
|
|
f9deaba4c5 | ||
|
|
ddd6a3b279 | ||
|
|
9359950666 | ||
|
|
d31b2a1b65 | ||
|
|
b89b4e0af4 | ||
|
|
cbcde027b3 | ||
|
|
d306e6bd22 | ||
|
|
9ec877999e | ||
|
|
f4189bf409 | ||
|
|
0ed5062683 | ||
|
|
7ef666dc91 | ||
|
|
1ac825919a | ||
|
|
a7bf30954d | ||
|
|
613cfdd903 | ||
|
|
28802c8279 | ||
|
|
6d7b3bd5f0 | ||
|
|
b97d8e9403 | ||
|
|
b4838d364e | ||
|
|
05ac5c63e1 | ||
|
|
874bf9e7c0 | ||
|
|
c9497ef39e | ||
|
|
496830d01d | ||
|
|
ccebcdd4c7 | ||
|
|
c900fe8461 | ||
|
|
a0158db37e | ||
|
|
b8c26b01ad | ||
|
|
3a44bef0d9 | ||
|
|
57a4ee781b | ||
|
|
e12f475850 | ||
|
|
f822a23daa | ||
|
|
6901b8be35 | ||
|
|
83fb2cd1d0 | ||
|
|
c98664d584 | ||
|
|
d098be8b03 | ||
|
|
3f6689d032 | ||
|
|
b4206fc203 | ||
|
|
cfa4a0c07f | ||
|
|
357b220ace | ||
|
|
47968304c9 | ||
|
|
2024d5e116 | ||
|
|
5ae2a99c14 | ||
|
|
7fd002d2c9 | ||
|
|
b7b7038244 | ||
|
|
b5519c4875 | ||
|
|
44feab9eb2 | ||
|
|
96c45c33e5 | ||
|
|
36efbcb812 | ||
|
|
03f44b4e9c | ||
|
|
19860e9f09 | ||
|
|
0701cb3970 | ||
|
|
7d6000e3b6 | ||
|
|
ef973ac56a | ||
|
|
91a1033c52 | ||
|
|
4197db6af9 | ||
|
|
210ab065c2 | ||
|
|
9cd10eca58 | ||
|
|
ba676be46d | ||
|
|
665a2e1866 | ||
|
|
94469cae3d | ||
|
|
a0dd2ccad6 | ||
|
|
b2cf837de7 | ||
|
|
80bcf60b5b | ||
|
|
7ad0ab566a | ||
|
|
2b16e86c7b | ||
|
|
f2ea02ae0b | ||
|
|
f65cd39040 | ||
|
|
5ca0d2a399 | ||
|
|
d1528a095b | ||
|
|
749173a463 | ||
|
|
6fbd90a6b3 | ||
|
|
f39d272e6a | ||
|
|
bb3854f512 | ||
|
|
e40daecfb8 | ||
|
|
3716ab9cb5 | ||
|
|
0cc6d6337a | ||
|
|
ce711a36ba | ||
|
|
451af7bea9 | ||
|
|
63200592bf | ||
|
|
d165dfbeb5 | ||
|
|
eed3d84517 | ||
|
|
ba7d890966 | ||
|
|
5140fc63d9 | ||
|
|
78509c07e0 | ||
|
|
5084141215 | ||
|
|
3f2ac83474 | ||
|
|
58a0468728 | ||
|
|
8e13aa7513 | ||
|
|
48e2d91fc8 | ||
|
|
a7f119217f | ||
|
|
865f2261fe | ||
|
|
dfedb23efd | ||
|
|
c01e1c3e4b | ||
|
|
ad8dac5fb0 | ||
|
|
84e81b6218 | ||
|
|
86efe631fe | ||
|
|
f5f1dc483b | ||
|
|
8aa4328c6c | ||
|
|
a01a8c4b19 | ||
|
|
4b2387b621 | ||
|
|
74d16d8ef9 | ||
|
|
b1ea8f9fa7 | ||
|
|
c666fdeaff | ||
|
|
7068782975 | ||
|
|
c4cebbebe7 | ||
|
|
53d43d9fa9 | ||
|
|
11d59beeed | ||
|
|
ef71e297f4 | ||
|
|
1e4d1d1973 | ||
|
|
893d99854b | ||
|
|
db93980cd5 | ||
|
|
34fac30b2b | ||
|
|
2fa0bcb765 | ||
|
|
78fd09aa91 | ||
|
|
a54516b4f5 | ||
|
|
f193d6f376 | ||
|
|
8a82c294a1 | ||
|
|
9392cf4bf0 | ||
|
|
ec4deb9099 | ||
|
|
cf0548aab9 | ||
|
|
064801380b | ||
|
|
9dc2a7424a | ||
|
|
4c8a56a5b9 | ||
|
|
9aad263996 | ||
|
|
ce1ab7c20d | ||
|
|
c9217990cd | ||
|
|
90cbf3b7a6 | ||
|
|
c4f1b22ddf | ||
|
|
fb612ea6ab | ||
|
|
bce44b6f6d |
No files matched your search
@@ -6,10 +6,10 @@ labels: bug
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
**Describe the bug**
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
**To Reproduce**
|
**To Reproduce**
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
@@ -17,15 +17,14 @@ Steps to reproduce the behavior:
|
|||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
|
||||||
**Expected behavior**
|
**Expected behavior**
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
**Screenshots**
|
**Screenshots**
|
||||||
If applicable, add screenshots to help explain your problem.
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
**Platform**
|
**Platform**
|
||||||
|
|
||||||
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
||||||
|
|
||||||
**Log Files**
|
**Log Files**
|
||||||
Attach your Libation log file here.
|
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'
|
||||||
@@ -6,14 +6,26 @@ labels: enhancement
|
|||||||
assignees: ''
|
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 [...]
|
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.
|
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.
|
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.
|
Add any other context or screenshots about the feature request here.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Maintain dependencies for GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# build-linux.yml
|
||||||
|
# Reusable workflow that builds the Linux and MacOS (x64 and arm64) versions of Libation.
|
||||||
|
---
|
||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version_override:
|
||||||
|
type: string
|
||||||
|
description: "Version number override"
|
||||||
|
required: false
|
||||||
|
run_unit_tests:
|
||||||
|
type: boolean
|
||||||
|
description: "Skip running unit tests"
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
runs_on:
|
||||||
|
type: string
|
||||||
|
description: "The GitHub hosted runner to use"
|
||||||
|
required: true
|
||||||
|
OS:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
The operating system targeted by the build.
|
||||||
|
|
||||||
|
There must be a corresponding Bundle_$OS.sh script file in ./Scripts
|
||||||
|
required: true
|
||||||
|
architecture:
|
||||||
|
type: string
|
||||||
|
description: "CPU architecture targeted by the build."
|
||||||
|
required: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_CONFIGURATION: "Release"
|
||||||
|
DOTNET_VERSION: "9.0.x"
|
||||||
|
RELEASE_NAME: "chardonnay"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
|
||||||
|
runs-on: ${{ inputs.runs_on }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
env:
|
||||||
|
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
inputVersion="${{ inputs.version_override }}"
|
||||||
|
if [[ "${#inputVersion}" -gt 0 ]]
|
||||||
|
then
|
||||||
|
version="${inputVersion}"
|
||||||
|
else
|
||||||
|
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
|
||||||
|
fi
|
||||||
|
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Unit test
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
chmod +rx ${SCRIPT}
|
||||||
|
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}"
|
||||||
|
artifact=$(ls ./bundle)
|
||||||
|
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Publish bundle
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ steps.bundle.outputs.artifact }}
|
||||||
|
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# build-windows.yml
|
||||||
|
# Reusable workflow that builds the Windows versions of Libation.
|
||||||
|
---
|
||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version_override:
|
||||||
|
type: string
|
||||||
|
description: "Version number override"
|
||||||
|
required: false
|
||||||
|
run_unit_tests:
|
||||||
|
type: boolean
|
||||||
|
description: "Skip running unit tests"
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_CONFIGURATION: "Release"
|
||||||
|
DOTNET_VERSION: "9.0.x"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: "${{ matrix.os }}-${{ matrix.release_name }}"
|
||||||
|
runs-on: windows-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [Windows]
|
||||||
|
ui: [Avalonia]
|
||||||
|
release_name: [chardonnay]
|
||||||
|
include:
|
||||||
|
- os: Windows
|
||||||
|
ui: WinForms
|
||||||
|
release_name: classic
|
||||||
|
prefix: Classic-
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Unit test
|
||||||
|
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:DefineConstants="${{ 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
|
||||||
|
|
||||||
|
- name: Zip artifact
|
||||||
|
id: zip
|
||||||
|
working-directory: ./Source/bin/Publish
|
||||||
|
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 }}"
|
||||||
|
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
||||||
|
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
|
||||||
|
|
||||||
|
- name: Publish artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ steps.zip.outputs.artifact }}.zip
|
||||||
|
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# build.yml
|
||||||
|
# Reusable workflow that builds Libation for all platforms.
|
||||||
|
---
|
||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version_override:
|
||||||
|
type: string
|
||||||
|
description: "Version number override"
|
||||||
|
required: false
|
||||||
|
run_unit_tests:
|
||||||
|
type: boolean
|
||||||
|
description: "Skip running unit tests"
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows:
|
||||||
|
uses: ./.github/workflows/build-windows.yml
|
||||||
|
with:
|
||||||
|
version_override: ${{ inputs.version_override }}
|
||||||
|
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||||
|
|
||||||
|
linux:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
OS: [Redhat, Debian]
|
||||||
|
architecture: [x64, arm64]
|
||||||
|
uses: ./.github/workflows/build-linux.yml
|
||||||
|
with:
|
||||||
|
version_override: ${{ inputs.version_override }}
|
||||||
|
runs_on: ubuntu-latest
|
||||||
|
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,63 @@
|
|||||||
|
# docker.yml
|
||||||
|
# Reusable workflow that builds a docker image for Libation.
|
||||||
|
---
|
||||||
|
name: docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
description: "Version number"
|
||||||
|
required: true
|
||||||
|
release:
|
||||||
|
type: boolean
|
||||||
|
description: "Is this a release build?"
|
||||||
|
required: true
|
||||||
|
secrets:
|
||||||
|
docker_username:
|
||||||
|
required: true
|
||||||
|
docker_token:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_and_push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
if: ${{ inputs.release }}
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.docker_username }}
|
||||||
|
password: ${{ secrets.docker_token }}
|
||||||
|
|
||||||
|
- name: Generate docker image tags
|
||||||
|
id: metadata
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
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@v6
|
||||||
|
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,58 @@
|
|||||||
|
# release.yml
|
||||||
|
# Builds and creates the release on any tags starting with a `v`
|
||||||
|
---
|
||||||
|
name: release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
jobs:
|
||||||
|
prerelease:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Get tag version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
export TAG="${{ github.ref_name }}"
|
||||||
|
echo "version=${TAG#v}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
docker:
|
||||||
|
needs: [prerelease]
|
||||||
|
uses: ./.github/workflows/docker.yml
|
||||||
|
with:
|
||||||
|
version: ${{ needs.prerelease.outputs.version }}
|
||||||
|
release: true
|
||||||
|
secrets:
|
||||||
|
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: [prerelease]
|
||||||
|
uses: ./.github/workflows/build.yml
|
||||||
|
with:
|
||||||
|
version_override: ${{ needs.prerelease.outputs.version }}
|
||||||
|
run_unit_tests: false
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [prerelease, build]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
pattern: "*(Classic-)Libation.*"
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
id: release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: Libation ${{ needs.prerelease.outputs.version }}
|
||||||
|
body: <Put a body here>
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
draft: true
|
||||||
|
prerelease: false
|
||||||
|
files: |
|
||||||
|
artifacts/*/*
|
||||||
@@ -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@v4
|
||||||
|
- 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@v4
|
||||||
|
- run: sudo apt --yes install desktop-file-utils
|
||||||
|
- name: Check the desktop file
|
||||||
|
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# validate.yml
|
||||||
|
# Validates that Libation will build on a pull request or push to master.
|
||||||
|
---
|
||||||
|
name: validate
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
uses: ./.github/workflows/build.yml
|
||||||
|
docker:
|
||||||
|
uses: ./.github/workflows/docker.yml
|
||||||
|
with:
|
||||||
|
version: ${GITHUB_SHA}
|
||||||
|
release: false
|
||||||
|
secrets:
|
||||||
|
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
+8
-4
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"WindowsClassic": "Libation\\.\\d+\\.\\d+\\.\\d+-win-classic\\.zip",
|
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
||||||
"WindowsAvalonia":"Libation\\.\\d+\\.\\d+\\.\\d+-win-chardonnay\\.zip",
|
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
|
||||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay",
|
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
|
||||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macos-chardonnay"
|
"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"
|
||||||
}
|
}
|
||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": ".NET Core Launch (console)",
|
||||||
|
"type": "coreclr",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "build",
|
||||||
|
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"console": "internalConsole"
|
||||||
|
}
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+42
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
// for the documentation about the tasks.json format
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "build",
|
||||||
|
"dependsOn": [
|
||||||
|
"build_libation",
|
||||||
|
"build_linuxconfigapp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "build_libation",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "dotnet",
|
||||||
|
"args": [
|
||||||
|
"build",
|
||||||
|
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj"
|
||||||
|
],
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
//"reveal": "silent"
|
||||||
|
},
|
||||||
|
"problemMatcher": "$msCompile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "build_linuxconfigapp",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "dotnet",
|
||||||
|
"args": [
|
||||||
|
"build",
|
||||||
|
"${workspaceFolder}/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj"
|
||||||
|
],
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
//"reveal": "silent"
|
||||||
|
},
|
||||||
|
"problemMatcher": "$msCompile"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"LibationFiles": "/config-internal"
|
||||||
|
}
|
||||||
Executable
+174
@@ -0,0 +1,174 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
error() {
|
||||||
|
log "ERROR" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
warn() {
|
||||||
|
log "WARNING" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
info() {
|
||||||
|
log "info" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
debug() {
|
||||||
|
if [ "${LOG_LEVEL}" = "debug" ]; then
|
||||||
|
log "debug" "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
DBPATH=$1
|
||||||
|
dbpattern="*.db"
|
||||||
|
|
||||||
|
debug "using database directory ${DBPATH}"
|
||||||
|
|
||||||
|
# Figure out the right databse file
|
||||||
|
if [[ -z "${LIBATION_DB_FILE}" ]];
|
||||||
|
then
|
||||||
|
dbCount=$(find "${DBPATH}" -maxdepth 1 -type f -name "${dbpattern}" | wc -l)
|
||||||
|
if [ "${dbCount}" -gt 1 ];
|
||||||
|
then
|
||||||
|
error "too many database files found, set LIBATION_DB_FILE to the filename you wish to use"
|
||||||
|
exit 1
|
||||||
|
elif [ "${dbCount}" -eq 1 ];
|
||||||
|
then
|
||||||
|
files=( ${DBPATH}/${dbpattern} )
|
||||||
|
FILE=${files[0]}
|
||||||
|
else
|
||||||
|
FILE="${DBPATH}/LibationContext.db"
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
ln -s "${FILE}" "${LIBATION_CONFIG_INTERNAL}/LibationContext.db"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 /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
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep "${SLEEP_TIME}"
|
||||||
|
done
|
||||||
|
|
||||||
|
info "exiting"
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# Dockerfile
|
||||||
|
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
COPY Source /Source
|
||||||
|
RUN dotnet publish \
|
||||||
|
/Source/LibationCli/LibationCli.csproj \
|
||||||
|
--arch ${TARGETARCH} \
|
||||||
|
--configuration Release \
|
||||||
|
--output /Source/bin/Publish/Linux-chardonnay \
|
||||||
|
-p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:9.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
|
||||||
|
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get -y upgrade && \
|
||||||
|
apt-get install -y jq && \
|
||||||
|
mkdir -m777 ${LIBATION_CONFIG_INTERNAL} ${LIBATION_BOOKS_DIR}
|
||||||
|
|
||||||
|
COPY --from=build /Source/bin/Publish/Linux-chardonnay /libation
|
||||||
|
COPY Docker/* /libation
|
||||||
|
|
||||||
|
USER ${USER_UID}:${USER_GID}
|
||||||
|
|
||||||
|
CMD ["/libation/liberate.sh"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
## [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 [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
- [Files and folders](#files-and-folders)
|
- [Files and folders](#files-and-folders)
|
||||||
- [Settings](#settings)
|
- [Settings](#settings)
|
||||||
- [Custom File Naming](#custom-file-naming)
|
- [Custom File Naming](NamingTemplates.md)
|
||||||
- [Command Line Interface](#command-line-interface)
|
- [Command Line Interface](#command-line-interface)
|
||||||
|
|
||||||
|
|
||||||
@@ -28,11 +28,14 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
|
|||||||
|
|
||||||
* 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.
|
* 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.
|
||||||
|
|
||||||
### Custom File Naming
|
In addition to the options that are enabled if you allow Libation to "fix up" the audiobook, it does the following:
|
||||||
|
|
||||||
In Settings, on the Download/Decrypt tab, you can specify the format in which you want your files to be named. As you edit these templates, a live example will be shown. Parameters are listed for folders, files, and files split by chapter including an explanation of what each naming option means. For instance: you can use template `<title short> - <ch# 0> of <ch count> - <ch title>` to create the file `A Study in Scarlet - 04 of 10 - A Flight for Life.m4b`.
|
* Adds the `TCOM` (`@wrt` in M4B files) metadata tag for the narrators.
|
||||||
|
* Sets the `©gen` metadata tag for the genres.
|
||||||
These templates apply to GUI and CLI.
|
* Unescapes the copyright symbol (replace `©` with `©`)
|
||||||
|
* Replaces the recording copyright `(P)` string with `℗`
|
||||||
|
* Replaces the chapter markers embedded in the aax file with the chapter markers retrieved from Audible's API.
|
||||||
|
* Sets the embedded cover art image with the 500x500 px cover art retrieved from Audible
|
||||||
|
|
||||||
### Command Line Interface
|
### Command Line Interface
|
||||||
|
|
||||||
@@ -71,4 +74,15 @@ export library to file
|
|||||||
libationcli export -p "C:\foo\bar\my.csv" -c
|
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" --xlsx
|
||||||
libationcli export -p "C:\foo\bar\my.xlsx" -x
|
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
|
||||||
```
|
```
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||||
|
|
||||||
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> ## Breaking Changes
|
||||||
|
> * The docker image now runs as user 1001 and group 1001. Make sure that the permissions on your volumes allow user 1001 to read and write to them or see the User section below for other options, or if you're not sure.
|
||||||
|
> * `SLEEP_TIME` is now set to `-1` by default. This means the image will run once and exit. If you were relying on the previous default, you'll need to explicitly set the `SLEEP_TIME` environment variable to `30m` to replicate the previous behavior.
|
||||||
|
> * The docker image now ignores the values in `Settings.json` for `Books` and `InProgress`. You can now change the folder that books are saved to by using the `LIBATION_BOOKS_DIR` environment variable.
|
||||||
|
|
||||||
|
# Disclaimer
|
||||||
|
The docker image is provided as-is. We hope it can be useful to you but it is not officially supported.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
Configuration in Libation is handled by two files, `AccountsSettings.json` and `Settings.json`. These files can usually be found in the Libation folder in your user's home directory. The easiest way to configure these is to run the desktop version of Libation and then copy them into a folder, such as `/opt/libation/config`, that you'll volume mount into the image. `Settings.json` is technically optional, and, if not provided, Libation will run using the default settings. Additionally, the `Books` and `InProgress` settings in `Settings.json` will be ignored and the image will instead substitute it's own values.
|
||||||
|
|
||||||
|
### Running
|
||||||
|
Once the configuration files are copied, the docker image can be run with the following command.
|
||||||
|
```
|
||||||
|
sudo docker run -d \
|
||||||
|
-v /opt/libation/config:/config \
|
||||||
|
-v /opt/libation/books:/data \
|
||||||
|
--name libation \
|
||||||
|
--restart=always \
|
||||||
|
rmcrackan/libation:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
By default the container will scan for new books once and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. For example, if you pass in `10m` it will keep running, scan for new books, and download them every 10 minutes.
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo docker run -d \
|
||||||
|
-v /opt/libation/config:/config \
|
||||||
|
-v /opt/libation/books:/data \
|
||||||
|
-e SLEEP_TIME='10m' \
|
||||||
|
--name libation \
|
||||||
|
--restart=always \
|
||||||
|
rmcrackan/libation:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
| Env Var | Default | Description |
|
||||||
|
| -------- | ------- | ----------- |
|
||||||
|
| SLEEP_TIME | -1 | Length of time to sleep before doing another scan/download. Set to -1 to run one. |
|
||||||
|
| LIBATION_BOOKS_DIR | /data | Folder where books will be saved |
|
||||||
|
| LIBATION_CONFIG_DIR | /config | Folder to read configuration from. |
|
||||||
|
| LIBATION_DB_DIR | /db | Optional folder to load database from. If not mounted, will load database from `LIBATION_CONFIG_DIR`. |
|
||||||
|
| LIBATION_DB_FILE | | Name of database file to load. By default it will look for all `.db` files and load one if there is only one present. |
|
||||||
|
| LIBATION_CREATE_DB | true | Whether or not the image should create a database file if none are found. |
|
||||||
|
|
||||||
|
### User
|
||||||
|
This docker image runs as user `1001`. In order for the image to function properly, user `1001` must be able to read and write the volumes that are mounted in. If they are not, you will see errors, including [sqlite error](#1060), [Microsoft.Data.Sqlite.SqliteException](#1110), [unable to open database file](#1113), [Microsoft.EntityFrameworkCore.DbUpdateException](#1049)
|
||||||
|
|
||||||
|
If you're not sure what your user number is, check the output of the `id` command. Docker should normally run with the number of the user who configured and ran it.
|
||||||
|
|
||||||
|
If you want to change the user the image runs as, you can specify `-u <uid>:<gid>`. For example, to run it as user `2000` and group `3000`, you could do the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo docker run -d \
|
||||||
|
-u 2000:3000 \
|
||||||
|
-v /opt/libation/config:/config \
|
||||||
|
-v /opt/libation/books:/data \
|
||||||
|
--name libation \
|
||||||
|
--restart=always \
|
||||||
|
rmcrackan/libation:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
If the user it's running as is correct, and it still cannot write, be sure to check whether the files and/or folders might be owned by the wrong user. You can use the `chown` command to change the owner of the file to the correct user and group number, for example: `chown -R 1001:1001 /mnt/audiobooks /mnt/libation-config`
|
||||||
|
|
||||||
|
### Advanced Database Options
|
||||||
|
The docker image supports an optional database mount location defined by `LIBATION_DB_DIR`. This allows the database to be mounted as read/write, while allowing the rest of the configuration files to be mounted as read only. This is specifically useful if running in Kubernetes where you can use Configmaps and Secrets to define the configuration. If the `LIBATION_DB_DIR` is mounted, it will be used, otherwise it will look for the database in `LIBATION_CONFIG_DIR`. If it does not find the database in the expected location, it will attempt to make an empty database there.
|
||||||
|
|
||||||
|
### Getting help
|
||||||
|
As mentioned above: docker is not officially supported. I'm adding this at the bottom of the page for anyone serious enough to have read this far. If you've tried everything above and would still like help, you can open an [issue](https://github.com/rmcrackan/Libation/issues). Please include `[docker]` in the title. There are also some docker folks who have offered occasional assistance who you can tag within your issue: `@ducamagnifico` , `@wtanksleyjr` , `@CLHatch`.
|
||||||
|
|
||||||
|
**Reminder** that these are just friendly users who are sometimes around. They're *not* our customer support.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||||
|
|
||||||
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Frequently Asked Questions
|
||||||
|
|
||||||
|
## Q: What's the difference between 'Classic' and 'Chardonnay'?
|
||||||
|
|
||||||
|
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
|
||||||
|
|
||||||
|
* **Classic** is Windows only. Its older 'grey boxes' look has a compact design which allows for more information on the screen. Notably, Classic was written using an older, more mature technology which has built-in support for screenreaders.
|
||||||
|
|
||||||
|
* **Chardonnay** is available for Windows, Mac, and Linux. Its modern design has a more open look and feel.
|
||||||
|
|
||||||
|
## Q: Now that I've downloaded my books, how can I listen to them?
|
||||||
|
|
||||||
|
**A:** You can use any app which plays m4b files (or mp3 files if you used that setting). Here are just a few ideas. Disclaimer: I have no affiliation with any of these companies:
|
||||||
|
|
||||||
|
* iOS: [BookPlayer](https://apps.apple.com/us/app/bookplayer/id1138219998)
|
||||||
|
* iOS: [Bound](https://apps.apple.com/us/app/bound-audiobook-player/id1041727137)
|
||||||
|
* Android: [Smart AudioBook Player](https://play.google.com/store/apps/details?id=ak.alizandro.smartaudiobookplayer&hl=en_US&gl=US)
|
||||||
|
* Android: [Listen](https://play.google.com/store/apps/details?id=ru.litres.android.audio&hl=en_US&gl=US)
|
||||||
|
* Desktop: [VLC](https://www.videolan.org/)
|
||||||
|
* Windows Desktop: [Audibly](https://github.com/rstewa/Audibly) -- a desktop player build specifically for audiobooks
|
||||||
|
|
||||||
|
Self-hosting online:
|
||||||
|
|
||||||
|
* [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/)
|
||||||
|
* [plex](https://www.plex.tv/). Listen with [Prologue](https://prologue.audio/) (iOS)
|
||||||
|
|
||||||
|
## Q: I'm having trouble loggin into my Brazil account.
|
||||||
|
|
||||||
|
For reasons known only to Jeff Bezos and God, amazon and audible brazil handle logins slightly differently. The external browser login option is not possible for Brazil. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103)
|
||||||
|
|
||||||
|
## Q: How do I use Libation with a South Africa account?
|
||||||
|
|
||||||
|
**A:** Like many countries, amazon gives South Africa it's own amazon site. [Unlike many other regions](https://www.audible.com/ep/country-selector) there is not South Africa specific audible site. Use `US` for your region -- ie: audible.com.
|
||||||
|
|
||||||
|
(Not exactly a *frequently* asked question but it's come up more than once)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
## [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 [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
@@ -20,14 +20,21 @@
|
|||||||
|
|
||||||
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
|
### [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
|
### Installation
|
||||||
|
|
||||||
* Windows
|
* Windows
|
||||||
|
|
||||||
Extract the zip file to a folder and then run `Libation.exe` from inside of that folder.
|
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.
|
||||||
|
|
||||||
* [Ubuntu Linux (beta)](InstallOnLinux.md)
|
* [Linux](InstallOnLinux.md)
|
||||||
* [MacOS (beta)](InstallOnMac.md)
|
* [MacOS](InstallOnMac.md)
|
||||||
|
|
||||||
### Create Accounts
|
### Create Accounts
|
||||||
|
|
||||||
|
|||||||
@@ -1,100 +1,67 @@
|
|||||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
## [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 [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
## Packaging status
|
||||||
|
|
||||||
# Run Libation on Ubuntu (Beta)
|
[](https://repology.org/project/libation/versions)
|
||||||
This walkthrough should get you up and running with Libation on your Ubuntu machine.
|
|
||||||
|
|
||||||
Some limitations of the linux release are:
|
New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the Libation repository's releases page.
|
||||||
- Cannot customize how illegial filename characters are replaced.
|
|
||||||
- The Auto-update function is unavailable
|
|
||||||
|
|
||||||
## Dependencies
|
Run this command in your terminal to download and install Libation, replacing the url with the latest Libation package url:
|
||||||
|
|
||||||
### FFMpeg (Optional)
|
### Debian
|
||||||
If you want to convert your audiobooks to mp3, install FFMpeg using the following command:
|
```Console
|
||||||
|
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
|
||||||
```console
|
sudo apt install ./libation.deb
|
||||||
sudo apt-get install -y ffmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
## Install Libation
|
|
||||||
|
|
||||||
Download the most recent linux-64 binaries zip file and save it as `libation-linux-bin.zip`. Save the 'install-libation.sh' bash script to a file. From the terminal make the script file executable:
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>install-libation.sh</summary>
|
|
||||||
|
|
||||||
```BASH
|
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
FILE=$1
|
|
||||||
|
|
||||||
if [ -z "$FILE" ]
|
|
||||||
then echo "This script must be called with a the Libation Linux bin zip file as an argument."
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$EUID" -ne 0 ]]
|
|
||||||
then echo "Please run as root"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$FILE" ]
|
|
||||||
then echo "The file \"$FILE\" does not exist."
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Extracting $FILE"
|
|
||||||
|
|
||||||
FOLDER="$(dirname "$FILE")/libation_src"
|
|
||||||
echo "$FOLDER"
|
|
||||||
|
|
||||||
sudo -u $SUDO_USER unzip -q -o ${FILE} -d ${FOLDER}
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]
|
|
||||||
then echo "Error unzipping ${FILE}"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
sudo -u $SUDO_USER chmod +700 ${FOLDER}/Libation
|
|
||||||
sudo -u $SUDO_USER chmod +700 ${FOLDER}/Hangover
|
|
||||||
sudo -u $SUDO_USER chmod +700 ${FOLDER}/LibationCli
|
|
||||||
|
|
||||||
#Remove previous installation program files and sym link
|
|
||||||
rm /usr/bin/Libation
|
|
||||||
rm /usr/bin/Hangover
|
|
||||||
rm /usr/bin/LibationCli
|
|
||||||
rm /usr/bin/libationcli
|
|
||||||
rm /usr/lib/libation -r
|
|
||||||
|
|
||||||
#Copy install files, icon and desktop file
|
|
||||||
cp ${FOLDER}/glass-with-glow_256.svg /usr/share/icons/hicolor/scalable/apps/libation.svg
|
|
||||||
cp ${FOLDER}/Libation.desktop /usr/share/applications/Libation.desktop
|
|
||||||
mv ${FOLDER}/ /usr/lib/libation
|
|
||||||
|
|
||||||
chmod +666 /usr/share/icons/hicolor/scalable/apps/libation.svg
|
|
||||||
gtk-update-icon-cache -f /usr/share/icons/hicolor/
|
|
||||||
ln -s /usr/lib/libation/Libation /usr/bin/Libation
|
|
||||||
ln -s /usr/lib/libation/Hangover /usr/bin/Hangover
|
|
||||||
ln -s /usr/lib/libation/LibationCli /usr/bin/LibationCli
|
|
||||||
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
|
|
||||||
|
|
||||||
echo "Done!"
|
|
||||||
```
|
```
|
||||||
</details>
|
### 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
|
||||||
|
```
|
||||||
|
### Fedora
|
||||||
|
```Console
|
||||||
|
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
|
||||||
|
sudo dnf5 install ./libation.rpm
|
||||||
|
```
|
||||||
|
---
|
||||||
|
### Arch Linux
|
||||||
|
```Console
|
||||||
|
yay -S libation
|
||||||
|
```
|
||||||
|
This package is available on [Arch User Repository](https://aur.archlinux.org/packages/libation), install via your choice of [AUR helpers](https://wiki.archlinux.org/title/AUR_helpers).
|
||||||
|
|
||||||
|
Thanks to [mhdi](https://aur.archlinux.org/account/mhdi) for taking care of AUR package maintenance.
|
||||||
|
### NixOS
|
||||||
|
- Install via `nix-shell`
|
||||||
|
```Console
|
||||||
|
nix-shell -p libation
|
||||||
|
```
|
||||||
|
A `nix-shell` will temporarily modify your $PATH environment variable. This can be used to try a piece of software before deciding to permanently install it.
|
||||||
|
- Install via NixOS configuration
|
||||||
|
```Console
|
||||||
|
environment.systemPackages = [
|
||||||
|
pkgs.libation
|
||||||
|
];
|
||||||
|
```
|
||||||
|
Add the following Nix code to your NixOS Configuration, usually located in `/etc/nixos/configuration.nix`
|
||||||
|
- On NixOS via via `nix-env`
|
||||||
|
```Console
|
||||||
|
nix-env -iA nixos.libation
|
||||||
|
```
|
||||||
|
- On Non NixOS via `nix-env`
|
||||||
|
```Console
|
||||||
|
nix-env -iA nixpkgs.libation
|
||||||
|
```
|
||||||
|
Warning: Using `nix-env` permanently modifies a local profile of installed packages. This must be updated and maintained by the user in the same way as with a traditional package manager.
|
||||||
|
|
||||||
```console
|
Thanks to [TomaSajt](https://github.com/tomasajt) for taking care of Nix package maintenance.
|
||||||
chmod +700 install-libation.sh
|
|
||||||
```
|
|
||||||
Then run the script with the libation binaries zipfile as an argument.
|
|
||||||
```console
|
|
||||||
sudo ./install-libation.sh libation-linux-bin.zip
|
|
||||||
```
|
|
||||||
|
|
||||||
You should now see Libation among your applications.
|
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
|
Report bugs to https://github.com/rmcrackan/Libation/issues
|
||||||
@@ -1,40 +1,82 @@
|
|||||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
## [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 [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Run Libation on MacOS (Beta)
|
# Run Libation on MacOS
|
||||||
This walkthrough should get you up and running with Libation on your Mac.
|
This walkthrough should get you up and running with Libation on your Mac.
|
||||||
|
|
||||||
|
## Supports macOS 10.15 (Catalina) and above
|
||||||
|
|
||||||
## Install Libation
|
## Install Libation
|
||||||
|
|
||||||
- Download latest MacOS zip to downloads folder
|
- Download the file from the latest release and extract it.
|
||||||
- Extract and rename folder to Libation
|
- Apple Silicon (M1, M2, ...): `Libation.x.x.x-macOS-chardonnay-`**arm64**`.tgz`
|
||||||
- in terminal type cd and then drag your folder of libation to terminal so it looks like `cd/users/YourName/Downloads/Libation`
|
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
|
||||||
- Type following commands
|
- Move the extracted Libation app bundle to your applications folder.
|
||||||
|
- Right-click on Libation and then click on open
|
||||||
|
- The first time, it will not immediately show you an option to open it. Just dismiss the dialog and do the same thing again (right-click -> open) then you will get an option to run the unsigned application. This takes about 10 seconds.
|
||||||
|
|
||||||
```console
|
## If this doesn't work
|
||||||
chmod +x ./Libation
|
|
||||||
sudo spctl --add --label "Libation" ./Libation
|
You can add Libation as a safe app without touching Gatekeeper.
|
||||||
./Libation
|
|
||||||
|
- Copy/paste/run the following command. Adjust the file path to the Libation.app on your computer if necessary.
|
||||||
|
|
||||||
|
```Console
|
||||||
|
xattr -r -d com.apple.quarantine ~/Downloads/Libation.app
|
||||||
|
```
|
||||||
|
- Close the terminal and use Libation!
|
||||||
|
|
||||||
|
## If this still doesn't work
|
||||||
|
|
||||||
|
- Copy/paste/run the following command (you'll be prompted to enter your Mac password)
|
||||||
|
|
||||||
|
```Console
|
||||||
|
sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable
|
||||||
|
```
|
||||||
|
|
||||||
|
* Close the terminal and use Libation!
|
||||||
|
|
||||||
|
## "Apple can't check app for malicious software"
|
||||||
|
|
||||||
|
From: [How to Open Anyway](https://support.apple.com/guide/mac-help/apple-cant-check-app-for-malicious-software-mchleab3a043/mac):
|
||||||
|
|
||||||
|
* On your Mac, choose Apple menu > System Settings, then click Privacy & Security in the sidebar. (You may need to scroll down.)
|
||||||
|
* Go to Security, then click Open.
|
||||||
|
* Click Open Anyway. This button is available for about an hour after you try to open the app.
|
||||||
|
* Enter your login password, then click OK.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If Libation fails to start after completing the above steps, try the following:
|
||||||
|
|
||||||
|
1. Right-click the Libation app in your applications folder and select _Show Package Contents_
|
||||||
|
2. Open the `Contents` folder and then the `MacOS` folder.
|
||||||
|
3. Find the file named `Libation`, right-click it, and then select _Open_.
|
||||||
|
|
||||||
|
Libation _should_ launch, and you should now be able to open Libation by just double-clicking the app bundle in your applications folder.
|
||||||
|
|
||||||
|
|
||||||
|
## Running Hangover
|
||||||
|
|
||||||
|
Libation comes with a recovery app called Hangover. You can start it by running this command:
|
||||||
|
```Console
|
||||||
|
open /Applications/Libation.app --args hangover
|
||||||
```
|
```
|
||||||
|
|
||||||
## Trouble with Gatekeeper?
|
## Running LibationCli
|
||||||
|
|
||||||
If Gatekeeper is giving you trouble with Libation:
|
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`
|
||||||
|
|
||||||
Disable the block
|
Then use `./LibationCli` to execute a command.
|
||||||
|
|
||||||
`sudo spctl --master-disable`
|
## Get Libation running on Mac
|
||||||
|
|
||||||
Launch Libation and login, etc. and allow the rules to update then re-enable the block.
|
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/219271379-a922e4e1-48a0-48e4-bd81-48aa1226a4f5.mp4)
|
||||||
|
|
||||||
`sudo spctl --master-enable`
|
|
||||||
|
|
||||||
Once Gatekeeper reenabled, you can open Libation again without it being blocked.
|
|
||||||
|
|
||||||
Thanks [joseph-holland](https://github.com/rmcrackan/Libation/issues/327#issuecomment-1268993349)!
|
|
||||||
|
|
||||||
Report bugs to https://github.com/rmcrackan/Libation/issues
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||||
|
|
||||||
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Naming Templates
|
||||||
|
File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options.
|
||||||
|
|
||||||
|
These templates apply to both GUI and CLI.
|
||||||
|
|
||||||
|
# Table of Contents
|
||||||
|
|
||||||
|
- [Template Tags](#template-tags)
|
||||||
|
- [Property Tags](#property-tags)
|
||||||
|
- [Conditional Tags](#conditional-tags)
|
||||||
|
- [Tag Formatters](#tag-formatters)
|
||||||
|
- [Text Formatters](#text-formatters)
|
||||||
|
- [Name List Formatters](#name-list-formatters)
|
||||||
|
- [Number Formatters](#number-formatters)
|
||||||
|
- [Date Formatters](#date-formatters)
|
||||||
|
|
||||||
|
|
||||||
|
# Template Tags
|
||||||
|
|
||||||
|
These are the naming template tags currently supported by Libation.
|
||||||
|
|
||||||
|
## Property Tags
|
||||||
|
These tags will be replaced in the template with the audiobook's values.
|
||||||
|
|
||||||
|
|Tag|Description|Type|
|
||||||
|
|-|-|-|
|
||||||
|
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
||||||
|
|\<title\>|Full title with subtitle|Text|
|
||||||
|
|\<title short\>|Title. Stop at first colon|Text|
|
||||||
|
|\<audible title\>|Audible's title (does not include subtitle)|Text|
|
||||||
|
|\<audible subtitle\>|Audible's subtitle|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|Number|
|
||||||
|
|\<bitrate\>|File's original bitrate (Kbps)|Number|
|
||||||
|
|\<samplerate\>|File's original audio sample rate|Number|
|
||||||
|
|\<channels\>|Number of audio channels|Number|
|
||||||
|
|\<account\>|Audible account of this book|Text|
|
||||||
|
|\<account nickname\>|Audible account nickname of this book|Text|
|
||||||
|
|\<locale\>|Region/country|Text|
|
||||||
|
|\<year\>|Year published|Number|
|
||||||
|
|\<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|Number|
|
||||||
|
|\<ch title\> **‡**|Chapter title|Text|
|
||||||
|
|\<ch#\> **‡**|Chapter number|Number|
|
||||||
|
|\<ch# 0\> **‡**|Chapter number with leading zeros|Number|
|
||||||
|
|
||||||
|
**†** Does not support custom formatting
|
||||||
|
|
||||||
|
**‡** Only valid for Chapter Filename and Chapter Tile Metadata
|
||||||
|
|
||||||
|
To change how these properties are displayed, [read about custom formatters](#tag-formatters)
|
||||||
|
|
||||||
|
## Conditional Tags
|
||||||
|
Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
|
||||||
|
|
||||||
|
|Tag|Description|Type|
|
||||||
|
|-|-|-|
|
||||||
|
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|
||||||
|
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|
||||||
|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|
||||||
|
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
|
||||||
|
|
||||||
|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
|
||||||
|
|
||||||
|
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
|
||||||
|
|
||||||
|
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
|
||||||
|
|
||||||
|
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**, **Number**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
|
||||||
|
|
||||||
|
## Text Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet꞉ a sherlock holmes novel|
|
||||||
|
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
|
||||||
|
|
||||||
|
## Name List Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|separator()|Speficy the text used to join<br>multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|
||||||
|
|format(\{T \| F \| M \| L \| S\})|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<author[format({L}, {F})`<br>`separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|
||||||
|
|sort(F \| M \| L)|Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|
||||||
|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
|
||||||
|
|
||||||
|
## Number Formatters
|
||||||
|
For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings).
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|\[integer\]|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
|
||||||
|
|0|Replaces the zero with the corresponding digit if one<br>is present; otherwise, zero appears in the result string.|\<series#\[000.0\]\>|001.0|
|
||||||
|
|#|Replaces the "#" symbol with the corresponding digit if one<br> is present; otherwise, no digit appears in the result string|\<series#\[00.##\]\>|01|
|
||||||
|
|
||||||
|
## Date Formatters
|
||||||
|
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
|
||||||
|
### Standard DateTime Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|s|Sortable date/time pattern.|\<file date[s]\>|2023-02-14T13:45:30|
|
||||||
|
|Y|Year month pattern.|\<file date[Y]\>|February 2023|
|
||||||
|
|
||||||
|
### Custom DateTime Formatters
|
||||||
|
You can use custom formatters to construct customized DateTime string. For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|yyyy|4-digit year|\<file date[yyyy]\>|2023|
|
||||||
|
|yy|2-digit year|\<file date[yy]\>|23|
|
||||||
|
|MM|2-digit month|\<file date[MM]\>|02|
|
||||||
|
|dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14|
|
||||||
|
|HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
## [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 [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,32 @@
|
|||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512px" enable-background="new 0 0 512 512">
|
||||||
|
<path id="slosh" transform=
|
||||||
|
"translate(-50 23)
|
||||||
|
scale(0.7, 0.7)
|
||||||
|
rotate(12 256,256)"
|
||||||
|
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
|
||||||
|
M146,147
|
||||||
|
A 168,300 35 0 0 256,270
|
||||||
|
A 168,300 -35 0 0 366,128
|
||||||
|
S 360,50 280,110
|
||||||
|
S 192,128 147,147
|
||||||
|
z" />
|
||||||
|
<use href="#slosh" transform="translate(512 0) scale(-1 1)" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 736 B |
@@ -0,0 +1,39 @@
|
|||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 524 524" enable-background="new 0 0 524 524">
|
||||||
|
<defs>
|
||||||
|
<g id="glass">
|
||||||
|
<path fill-rule="evenodd" d=
|
||||||
|
"M262,8
|
||||||
|
h-117
|
||||||
|
a 192,200 0 0 0 -36,82
|
||||||
|
a 222,334 41 0 0 138,236
|
||||||
|
v158
|
||||||
|
h-81
|
||||||
|
a 16,16 0 0 0 0,32
|
||||||
|
h192
|
||||||
|
a 16 16 0 0 0 0,-32
|
||||||
|
h-81
|
||||||
|
v-158
|
||||||
|
a 222,334 -41 0 0 138,-236
|
||||||
|
a 192,200 0 0 0 -36,-82
|
||||||
|
h-117
|
||||||
|
m-99,30
|
||||||
|
a 192,200 0 0 0 -26,95
|
||||||
|
a 187.5,334 35 0 0 125,159
|
||||||
|
a 187.5,334 -35 0 0 125,-159
|
||||||
|
a 192,200 0 0 0 -26,-95
|
||||||
|
h-198
|
||||||
|
z"/>
|
||||||
|
</g>
|
||||||
|
<g id="wine-level">
|
||||||
|
<path d=
|
||||||
|
"M158,136
|
||||||
|
a 168,305 35 0 0 104,136
|
||||||
|
a 168,305 -35 0 0 104,-136
|
||||||
|
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>
|
||||||
|
After Width: | Height: | Size: 968 B |
@@ -0,0 +1,30 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 638 B |
@@ -0,0 +1,33 @@
|
|||||||
|
<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
|
||||||
|
transform=
|
||||||
|
"rotate(15 256,256)
|
||||||
|
translate(0 25)
|
||||||
|
scale(0.93, 0.93)"
|
||||||
|
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
|
||||||
|
M146,147
|
||||||
|
A 168,300 35 0 0 256,270
|
||||||
|
A 168,300 -35 0 0 366,128
|
||||||
|
S 360,50 280,110
|
||||||
|
S 192,128 147,147
|
||||||
|
z" />
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 649 B |
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
## [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 [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Table of Contents
|
# Table of Contents
|
||||||
|
|
||||||
- [Audible audiobook manager](#audible-audiobook-manager)
|
- [Audible audiobook manager](#audible-audiobook-manager)
|
||||||
@@ -28,8 +30,10 @@
|
|||||||
- [Advanced](Documentation/Advanced.md)
|
- [Advanced](Documentation/Advanced.md)
|
||||||
- [Files and folders](Documentation/Advanced.md#files-and-folders)
|
- [Files and folders](Documentation/Advanced.md#files-and-folders)
|
||||||
- [Settings](Documentation/Advanced.md#settings)
|
- [Settings](Documentation/Advanced.md#settings)
|
||||||
- [Custom File Naming](Documentation/Advanced.md#custom-file-naming)
|
- [Custom File Naming](Documentation/NamingTemplates.md)
|
||||||
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
||||||
|
- [Docker](Documentation/Docker.md)
|
||||||
|
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@@ -48,12 +52,12 @@
|
|||||||
* Customizable saved filters for common searches
|
* Customizable saved filters for common searches
|
||||||
* Open source
|
* Open source
|
||||||
* Supports most regions: US, UK, Canada, Germany, France, Australia, Japan, India, and Spain
|
* Supports most regions: US, UK, Canada, Germany, France, Australia, Japan, India, and Spain
|
||||||
|
* Fully supported in Windows, Mac, and Linux
|
||||||
|
|
||||||
<a name="theBad"/>
|
<a name="theBad"/>
|
||||||
|
|
||||||
### The bad
|
### The bad
|
||||||
|
|
||||||
* Only fully supported in Windows. (Mac and Linux are in beta)
|
|
||||||
* Large file size
|
* Large file size
|
||||||
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows
|
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
BIN_DIR=$1; shift
|
||||||
|
VERSION=$1; shift
|
||||||
|
ARCH=$1; shift
|
||||||
|
|
||||||
|
if [ -z "$BIN_DIR" ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with a the Libation Linux bins directory as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$BIN_DIR" ]
|
||||||
|
then
|
||||||
|
echo "The directory \"$BIN_DIR\" does not exist."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with the Libation version number as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$ARCH" ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with the Libation cpu architecture as an argument."
|
||||||
|
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
|
||||||
|
|
||||||
|
FOLDER_EXEC=$DEB_DIR/usr/lib/libation
|
||||||
|
echo "Exec dir: $FOLDER_EXEC"
|
||||||
|
mkdir -p $FOLDER_EXEC
|
||||||
|
|
||||||
|
echo "Moving bins from $BIN_DIR to $FOLDER_EXEC"
|
||||||
|
mv "${BIN_DIR}/"* $FOLDER_EXEC
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]
|
||||||
|
then echo "Error moving ${BIN_DIR} files"
|
||||||
|
exit
|
||||||
|
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
|
||||||
|
|
||||||
|
for n in "${delfiles[@]}"
|
||||||
|
do
|
||||||
|
echo "Deleting $n"
|
||||||
|
rm $FOLDER_EXEC/$n
|
||||||
|
done
|
||||||
|
|
||||||
|
FOLDER_ICON=$DEB_DIR/usr/share/icons/hicolor/scalable/apps/
|
||||||
|
echo "Icon dir: $FOLDER_ICON"
|
||||||
|
|
||||||
|
FOLDER_DESKTOP=$DEB_DIR/usr/share/applications
|
||||||
|
echo "Desktop dir: $FOLDER_DESKTOP"
|
||||||
|
|
||||||
|
FOLDER_DEBIAN=$DEB_DIR/DEBIAN
|
||||||
|
echo "Debian dir: $FOLDER_DEBIAN"
|
||||||
|
|
||||||
|
mkdir -p $FOLDER_ICON
|
||||||
|
mkdir -p $FOLDER_DESKTOP
|
||||||
|
mkdir -p $FOLDER_DEBIAN
|
||||||
|
|
||||||
|
echo "Copying icon..."
|
||||||
|
cp $FOLDER_EXEC/libation_glass.svg $FOLDER_ICON/libation.svg
|
||||||
|
|
||||||
|
echo "Copying desktop file..."
|
||||||
|
cp $FOLDER_EXEC/Libation.desktop $FOLDER_DESKTOP/Libation.desktop
|
||||||
|
|
||||||
|
echo "Creating pre-install file..."
|
||||||
|
echo "#!/bin/bash
|
||||||
|
# Pre-install script, removes previous installation program files and sym links
|
||||||
|
echo \"Removing previously created symlinks...\"
|
||||||
|
rm /usr/bin/libation
|
||||||
|
rm /usr/bin/hangover
|
||||||
|
rm /usr/bin/libationcli
|
||||||
|
echo \"Removing previously installed Libation files...\"
|
||||||
|
rm -r /usr/lib/libation
|
||||||
|
# making sure it won't stop installation
|
||||||
|
exit 0
|
||||||
|
" >> $FOLDER_DEBIAN/preinst
|
||||||
|
|
||||||
|
echo "Creating post-install file..."
|
||||||
|
echo "#!/bin/bash
|
||||||
|
gtk-update-icon-cache -f /usr/share/icons/hicolor/
|
||||||
|
ln -s /usr/lib/libation/Libation /usr/bin/libation
|
||||||
|
ln -s /usr/lib/libation/Hangover /usr/bin/hangover
|
||||||
|
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
|
||||||
|
# Increase the maximum number of inotify instances
|
||||||
|
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
|
||||||
|
" >> $FOLDER_DEBIAN/postinst
|
||||||
|
|
||||||
|
echo "Creating control file..."
|
||||||
|
echo "Package: Libation
|
||||||
|
Version: $VERSION
|
||||||
|
Architecture: $ARCH
|
||||||
|
Essential: no
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: github.com/rmcrackan
|
||||||
|
Description: liberate your audiobooks
|
||||||
|
" >> $FOLDER_DEBIAN/control
|
||||||
|
|
||||||
|
echo "Changing permissions for pre- and post-install files..."
|
||||||
|
chmod +x "$FOLDER_DEBIAN/preinst"
|
||||||
|
chmod +x "$FOLDER_DEBIAN/postinst"
|
||||||
|
|
||||||
|
if [ "$(uname -s)" == "Darwin" ]; then
|
||||||
|
echo "macOS detected, installing dpkg"
|
||||||
|
brew install dpkg
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEB_FILE=Libation.${VERSION}-linux-chardonnay-${ARCH}.deb
|
||||||
|
echo "Creating $DEB_FILE"
|
||||||
|
dpkg-deb -Zxz --build $DEB_DIR ./$DEB_FILE
|
||||||
|
|
||||||
|
echo "moving to ./bundle/$DEB_FILE"
|
||||||
|
mkdir bundle
|
||||||
|
mv $DEB_FILE ./bundle/$DEB_FILE
|
||||||
|
|
||||||
|
rm -r "$BIN_DIR"
|
||||||
|
|
||||||
|
echo "Done!"
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
BIN_DIR=$1; shift
|
||||||
|
VERSION=$1; shift
|
||||||
|
ARCH=$1; shift
|
||||||
|
|
||||||
|
if [ -z "$BIN_DIR" ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with a the Libation macos bins directory as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$BIN_DIR" ]
|
||||||
|
then
|
||||||
|
echo "The directory \"$BIN_DIR\" does not exist."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z $VERSION ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with the Libation version number as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z $ARCH ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with the Libation cpu architecture as an argument."
|
||||||
|
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
|
||||||
|
|
||||||
|
BUNDLE=./Libation.app
|
||||||
|
echo "Bundle dir: $BUNDLE"
|
||||||
|
|
||||||
|
if [[ -d $BUNDLE ]]
|
||||||
|
then
|
||||||
|
echo "$BUNDLE directory already exists, aborting."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUNDLE_CONTENTS=$BUNDLE/Contents
|
||||||
|
echo "Bundle Contents dir: $BUNDLE_CONTENTS"
|
||||||
|
|
||||||
|
BUNDLE_RESOURCES=$BUNDLE_CONTENTS/Resources
|
||||||
|
echo "Resources dir: $BUNDLE_RESOURCES"
|
||||||
|
|
||||||
|
BUNDLE_MACOS=$BUNDLE_CONTENTS/MacOS
|
||||||
|
echo "MacOS dir: $BUNDLE_MACOS"
|
||||||
|
|
||||||
|
mkdir -p $BUNDLE_CONTENTS
|
||||||
|
mkdir -p $BUNDLE_RESOURCES
|
||||||
|
mkdir -p $BUNDLE_MACOS
|
||||||
|
|
||||||
|
mv "${BIN_DIR}/"* $BUNDLE_MACOS
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]
|
||||||
|
then echo "Error moving ${BIN_DIR} files"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Make fileicon executable..."
|
||||||
|
chmod +x $BUNDLE_MACOS/fileicon
|
||||||
|
|
||||||
|
echo "Moving icon..."
|
||||||
|
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
||||||
|
|
||||||
|
echo "Moving Info.plist file..."
|
||||||
|
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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')
|
||||||
|
mv $BUNDLE_MACOS/ffmpegaac.arm64.dylib $BUNDLE_MACOS/ffmpegaac.dylib
|
||||||
|
mv $BUNDLE_MACOS/libmp3lame.arm64.dylib $BUNDLE_MACOS/libmp3lame.dylib
|
||||||
|
else
|
||||||
|
delfiles+=('libmp3lame.arm64.dylib' 'ffmpegaac.arm64.dylib')
|
||||||
|
mv $BUNDLE_MACOS/ffmpegaac.x64.dylib $BUNDLE_MACOS/ffmpegaac.dylib
|
||||||
|
mv $BUNDLE_MACOS/libmp3lame.x64.dylib $BUNDLE_MACOS/libmp3lame.dylib
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
for n in "${delfiles[@]}"
|
||||||
|
do
|
||||||
|
echo "Deleting $n"
|
||||||
|
rm $BUNDLE_MACOS/$n
|
||||||
|
done
|
||||||
|
|
||||||
|
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz
|
||||||
|
|
||||||
|
echo "Signing executables in: $BUNDLE"
|
||||||
|
codesign --force --deep -s - $BUNDLE
|
||||||
|
|
||||||
|
echo "Creating app bundle: $APP_FILE"
|
||||||
|
tar -czvf $APP_FILE $BUNDLE
|
||||||
|
|
||||||
|
mkdir bundle
|
||||||
|
echo "moving to ./bundle/$APP_FILE"
|
||||||
|
mv $APP_FILE ./bundle/$APP_FILE
|
||||||
|
|
||||||
|
rm -r $BUNDLE
|
||||||
|
|
||||||
|
echo "Done!"
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
BIN_DIR=$1; shift
|
||||||
|
VERSION=$1; shift
|
||||||
|
ARCH=$1; shift
|
||||||
|
|
||||||
|
if [ -z "$BIN_DIR" ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with a the Libation Linux bins directory as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$BIN_DIR" ]
|
||||||
|
then
|
||||||
|
echo "The directory \"$BIN_DIR\" does not exist."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with the Libation version number as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$ARCH" ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with the Libation cpu architecture as an argument."
|
||||||
|
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')
|
||||||
|
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
|
||||||
|
|
||||||
|
notinstalled=('libcoreclrtraceptprovider.so' 'libation_glass.svg' 'Libation.desktop')
|
||||||
|
|
||||||
|
mkdir -p ~/rpmbuild/SPECS
|
||||||
|
mkdir ~/rpmbuild/BUILD
|
||||||
|
mkdir ~/rpmbuild/RPMS
|
||||||
|
|
||||||
|
echo "Name: libation
|
||||||
|
Version: ${VERSION}
|
||||||
|
Release: 1
|
||||||
|
Summary: Liberate your Audible Library
|
||||||
|
|
||||||
|
License: GPLv3+
|
||||||
|
URL: https://github.com/rmcrackan/Libation
|
||||||
|
Source0: https://github.com/rmcrackan/Libation
|
||||||
|
|
||||||
|
Requires: bash
|
||||||
|
|
||||||
|
|
||||||
|
%define __os_install_post %{nil}
|
||||||
|
|
||||||
|
%description
|
||||||
|
Liberate your Audible Library
|
||||||
|
|
||||||
|
%install
|
||||||
|
mkdir -p %{buildroot}%{_libdir}/%{name}
|
||||||
|
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/scalable/apps
|
||||||
|
mkdir -p %{buildroot}%{_datadir}/applications
|
||||||
|
|
||||||
|
if test -f 'libcoreclrtraceptprovider.so'; then
|
||||||
|
rm 'libcoreclrtraceptprovider.so'
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
install -m 666 libation_glass.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/libation.svg
|
||||||
|
install -m 666 Libation.desktop %{buildroot}%{_datadir}/applications/Libation.desktop
|
||||||
|
|
||||||
|
rm libation_glass.svg
|
||||||
|
rm Libation.desktop
|
||||||
|
|
||||||
|
install * %{buildroot}%{_libdir}/%{name}/
|
||||||
|
|
||||||
|
%post
|
||||||
|
|
||||||
|
if [ \$1 -eq 1 ] ; then
|
||||||
|
# Initial installation
|
||||||
|
|
||||||
|
ln -s %{_libdir}/%{name}/Libation %{_bindir}/libation
|
||||||
|
ln -s %{_libdir}/%{name}/Hangover %{_bindir}/hangover
|
||||||
|
ln -s %{_libdir}/%{name}/LibationCli %{_bindir}/libationcli
|
||||||
|
|
||||||
|
gtk-update-icon-cache -f %{_datadir}/icons/hicolor/
|
||||||
|
|
||||||
|
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
|
||||||
|
fi
|
||||||
|
|
||||||
|
%postun
|
||||||
|
if [ \$1 -eq 0 ] ; then
|
||||||
|
# Uninstall
|
||||||
|
rm %{_bindir}/libation
|
||||||
|
rm %{_bindir}/hangover
|
||||||
|
rm %{_bindir}/libationcli
|
||||||
|
fi
|
||||||
|
|
||||||
|
%files
|
||||||
|
%{_datadir}/icons/hicolor/scalable/apps/libation.svg
|
||||||
|
%{_datadir}/applications/Libation.desktop" >> ~/rpmbuild/SPECS/libation.spec
|
||||||
|
|
||||||
|
|
||||||
|
cd "$BIN_DIR"
|
||||||
|
|
||||||
|
for f in *; do
|
||||||
|
if [[ " ${delfiles[*]} " =~ " ${f} " ]]; then
|
||||||
|
echo "Deleting $f"
|
||||||
|
elif [[ ! " ${notinstalled[*]} " =~ " ${f} " ]]; then
|
||||||
|
echo "%{_libdir}/%{name}/${f}" >> ~/rpmbuild/SPECS/libation.spec
|
||||||
|
cp $f ~/rpmbuild/BUILD/
|
||||||
|
else
|
||||||
|
cp $f ~/rpmbuild/BUILD/
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cd ~/rpmbuild/SPECS/
|
||||||
|
rpmbuild -bb --target $ARCH_RPM libation.spec
|
||||||
|
|
||||||
|
cd $BASEDIR
|
||||||
|
RPM_FILE=$(ls ~/rpmbuild/RPMS/${ARCH_RPM})
|
||||||
|
|
||||||
|
mkdir bundle
|
||||||
|
|
||||||
|
mv ~/rpmbuild/RPMS/${ARCH_RPM}/$RPM_FILE "./bundle/Libation.${VERSION}-linux-chardonnay-${ARCH}.rpm"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AAXClean.Codecs" Version="0.2.14" />
|
<PackageReference Include="AAXClean.Codecs" Version="1.1.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
using System;
|
using AAXClean;
|
||||||
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AAXClean;
|
|
||||||
using Dinah.Core.Net.Http;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
||||||
{
|
{
|
||||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
public event EventHandler<AppleTags> RetrievedMetadata;
|
||||||
|
|
||||||
protected AaxFile AaxFile;
|
protected AaxFile AaxFile { get; private set; }
|
||||||
|
protected Mp4Operation AaxConversion { get; set; }
|
||||||
|
|
||||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||||
@@ -22,25 +22,66 @@ namespace AaxDecrypter
|
|||||||
AaxFile.AppleTags.Cover = coverArt;
|
AaxFile.AppleTags.Cover = coverArt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async Task CancelAsync()
|
||||||
|
{
|
||||||
|
IsCanceled = true;
|
||||||
|
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
||||||
|
FinalizeDownload();
|
||||||
|
}
|
||||||
|
|
||||||
protected bool Step_GetMetadata()
|
protected bool Step_GetMetadata()
|
||||||
{
|
{
|
||||||
AaxFile = new AaxFile(InputFileStream);
|
AaxFile = new AaxFile(InputFileStream);
|
||||||
|
|
||||||
|
if (DownloadOptions.AudibleKey?.Length == 8 && DownloadOptions.AudibleIV is null)
|
||||||
|
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey);
|
||||||
|
else
|
||||||
|
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||||
|
|
||||||
if (DownloadOptions.StripUnabridged)
|
if (DownloadOptions.StripUnabridged)
|
||||||
{
|
{
|
||||||
AaxFile.AppleTags.Title = AaxFile.AppleTags.TitleSansUnabridged;
|
AaxFile.AppleTags.Title = AaxFile.AppleTags.TitleSansUnabridged;
|
||||||
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
|
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
//Finishing configuring lame encoder.
|
if (DownloadOptions.FixupFile)
|
||||||
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
{
|
||||||
MpegUtil.ConfigureLameOptions(
|
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
|
||||||
AaxFile,
|
AaxFile.AppleTags.AppleListBox.EditOrAddTag("©wrt", AaxFile.AppleTags.Narrator);
|
||||||
DownloadOptions.LameConfig,
|
|
||||||
DownloadOptions.Downsample,
|
|
||||||
DownloadOptions.MatchSourceBitrate);
|
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
|
||||||
|
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("©", "©");
|
||||||
|
|
||||||
|
//Add audiobook shelf tags
|
||||||
|
//https://github.com/advplyr/audiobookshelf/issues/1794#issuecomment-1565050213
|
||||||
|
const string tagDomain = "com.pilabor.tone";
|
||||||
|
|
||||||
|
AaxFile.AppleTags.Title = DownloadOptions.Title;
|
||||||
|
|
||||||
|
if (DownloadOptions.Subtitle is string subtitle)
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SUBTITLE", subtitle);
|
||||||
|
|
||||||
|
if (DownloadOptions.Publisher is string publisher)
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PUBLISHER", publisher);
|
||||||
|
|
||||||
|
if (DownloadOptions.Language is string language)
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "LANGUAGE", language);
|
||||||
|
|
||||||
|
if (DownloadOptions.AudibleProductId is string asin)
|
||||||
|
{
|
||||||
|
AaxFile.AppleTags.Asin = asin;
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddTag("asin", asin);
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ASIN", asin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DownloadOptions.SeriesName is string series)
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
|
||||||
|
|
||||||
|
if (DownloadOptions.SeriesNumber is float part)
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
OnInitialized();
|
||||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
||||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
||||||
@@ -51,57 +92,6 @@ namespace AaxDecrypter
|
|||||||
return !IsCanceled;
|
return !IsCanceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected DownloadProgress Step_DownloadAudiobook_Start()
|
protected virtual void OnInitialized() { }
|
||||||
{
|
|
||||||
var zeroProgress = new DownloadProgress
|
|
||||||
{
|
|
||||||
BytesReceived = 0,
|
|
||||||
ProgressPercentage = 0,
|
|
||||||
TotalBytesToReceive = InputFileStream.Length
|
|
||||||
};
|
|
||||||
|
|
||||||
OnDecryptProgressUpdate(zeroProgress);
|
|
||||||
|
|
||||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
|
||||||
return zeroProgress;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void Step_DownloadAudiobook_End(DownloadProgress zeroProgress)
|
|
||||||
{
|
|
||||||
AaxFile.Close();
|
|
||||||
|
|
||||||
CloseInputFileStream();
|
|
||||||
|
|
||||||
OnDecryptProgressUpdate(zeroProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
|
||||||
{
|
|
||||||
var duration = AaxFile.Duration;
|
|
||||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
|
||||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
|
||||||
|
|
||||||
if (double.IsNormal(estTimeRemaining))
|
|
||||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
|
||||||
|
|
||||||
var progressPercent = (e.ProcessPosition / e.TotalDuration);
|
|
||||||
|
|
||||||
OnDecryptProgressUpdate(
|
|
||||||
new DownloadProgress
|
|
||||||
{
|
|
||||||
ProgressPercentage = 100 * progressPercent,
|
|
||||||
BytesReceived = (long)(InputFileStream.Length * progressPercent),
|
|
||||||
TotalBytesToReceive = InputFileStream.Length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task CancelAsync()
|
|
||||||
{
|
|
||||||
IsCanceled = true;
|
|
||||||
if (AaxFile != null)
|
|
||||||
await AaxFile.CancelAsync();
|
|
||||||
AaxFile?.Dispose();
|
|
||||||
CloseInputFileStream();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,66 +1,36 @@
|
|||||||
using System;
|
using AAXClean;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using AAXClean;
|
|
||||||
using AAXClean.Codecs;
|
using AAXClean.Codecs;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||||
{
|
{
|
||||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
|
||||||
private List<string> multiPartFilePaths { get; } = new List<string>();
|
private FileStream workingFileStream;
|
||||||
|
|
||||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
: base(outFileName, cacheDirectory, dlOptions)
|
||||||
|
|
||||||
public override async Task<bool> RunAsync()
|
|
||||||
{
|
{
|
||||||
try
|
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
|
||||||
{
|
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
|
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||||
|
}
|
||||||
|
|
||||||
//Step 1
|
protected override void OnInitialized()
|
||||||
Serilog.Log.Information("Begin Get Aaxc Metadata");
|
{
|
||||||
if (await Task.Run(Step_GetMetadata))
|
//Finishing configuring lame encoder.
|
||||||
Serilog.Log.Information("Completed Get Aaxc Metadata");
|
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||||
else
|
MpegUtil.ConfigureLameOptions(
|
||||||
{
|
AaxFile,
|
||||||
Serilog.Log.Information("Failed to Complete Get Aaxc Metadata");
|
DownloadOptions.LameConfig,
|
||||||
return false;
|
DownloadOptions.Downsample,
|
||||||
}
|
DownloadOptions.MatchSourceBitrate,
|
||||||
|
chapters: null);
|
||||||
//Step 2
|
|
||||||
Serilog.Log.Information("Begin Download Decrypted Audiobook");
|
|
||||||
if (await Step_DownloadAudiobookAsMultipleFilesPerChapter())
|
|
||||||
Serilog.Log.Information("Completed Download Decrypted Audiobook");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Download Decrypted Audiobook");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 3
|
|
||||||
Serilog.Log.Information("Begin Cleanup");
|
|
||||||
if (await Task.Run(Step_Cleanup))
|
|
||||||
Serilog.Log.Information("Completed Cleanup");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Cleanup");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -87,10 +57,8 @@ The book will be split into the following files:
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
*/
|
*/
|
||||||
private async Task<bool> Step_DownloadAudiobookAsMultipleFilesPerChapter()
|
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
|
||||||
|
|
||||||
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
||||||
|
|
||||||
// Ensure split files are at least minChapterLength in duration.
|
// Ensure split files are at least minChapterLength in duration.
|
||||||
@@ -113,77 +81,77 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset, just in case
|
try
|
||||||
multiPartFilePaths.Clear();
|
|
||||||
|
|
||||||
ConversionResult result;
|
|
||||||
|
|
||||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
|
||||||
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
|
|
||||||
result = await ConvertToMultiMp4a(splitChapters);
|
|
||||||
else
|
|
||||||
result = await ConvertToMultiMp3(splitChapters);
|
|
||||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
|
||||||
|
|
||||||
Step_DownloadAudiobook_End(zeroProgress);
|
|
||||||
|
|
||||||
return result == ConversionResult.NoErrorsDetected;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task<ConversionResult> ConvertToMultiMp4a(ChapterInfo splitChapters)
|
|
||||||
{
|
|
||||||
var chapterCount = 0;
|
|
||||||
return AaxFile.ConvertToMultiMp4aAsync
|
|
||||||
(
|
|
||||||
splitChapters,
|
|
||||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
|
||||||
DownloadOptions.TrimOutputToChapterLength
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task<ConversionResult> ConvertToMultiMp3(ChapterInfo splitChapters)
|
|
||||||
{
|
|
||||||
var chapterCount = 0;
|
|
||||||
return AaxFile.ConvertToMultiMp3Async
|
|
||||||
(
|
|
||||||
splitChapters,
|
|
||||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
|
||||||
DownloadOptions.LameConfig,
|
|
||||||
DownloadOptions.TrimOutputToChapterLength
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void Callback(int currentChapter, ChapterInfo splitChapters, NewMP3SplitCallback newSplitCallback)
|
|
||||||
=> Callback(currentChapter, splitChapters, newSplitCallback as NewSplitCallback);
|
|
||||||
|
|
||||||
private void Callback(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
|
||||||
{
|
|
||||||
MultiConvertFileProperties props = new()
|
|
||||||
{
|
{
|
||||||
OutputFileName = OutputFileName,
|
await (AaxConversion = decryptMultiAsync(splitChapters));
|
||||||
PartsPosition = currentChapter,
|
|
||||||
PartsTotal = splitChapters.Count,
|
if (AaxConversion.IsCompletedSuccessfully)
|
||||||
Title = newSplitCallback?.Chapter?.Title,
|
await moveMoovToBeginning(workingFileStream?.Name);
|
||||||
};
|
|
||||||
newSplitCallback.OutputFile = createOutputFileStream(props);
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitleName(props);
|
}
|
||||||
newSplitCallback.TrackNumber = currentChapter;
|
finally
|
||||||
newSplitCallback.TrackCount = splitChapters.Count;
|
{
|
||||||
|
workingFileStream?.Dispose();
|
||||||
|
FinalizeDownload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
private Mp4Operation decryptMultiAsync(ChapterInfo splitChapters)
|
||||||
{
|
{
|
||||||
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
var chapterCount = 0;
|
||||||
fileName = FileUtility.GetValidFilename(fileName, DownloadOptions.ReplacementCharacters);
|
return
|
||||||
|
DownloadOptions.OutputFormat == OutputFormat.M4b
|
||||||
|
? AaxFile.ConvertToMultiMp4aAsync
|
||||||
|
(
|
||||||
|
splitChapters,
|
||||||
|
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
|
||||||
|
)
|
||||||
|
: AaxFile.ConvertToMultiMp3Async
|
||||||
|
(
|
||||||
|
splitChapters,
|
||||||
|
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
|
||||||
|
DownloadOptions.LameConfig
|
||||||
|
);
|
||||||
|
|
||||||
multiPartFilePaths.Add(fileName);
|
void newSplit(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||||
|
{
|
||||||
|
MultiConvertFileProperties props = new()
|
||||||
|
{
|
||||||
|
OutputFileName = OutputFileName,
|
||||||
|
PartsPosition = currentChapter,
|
||||||
|
PartsTotal = splitChapters.Count,
|
||||||
|
Title = newSplitCallback?.Chapter?.Title,
|
||||||
|
};
|
||||||
|
|
||||||
FileUtility.SaferDelete(fileName);
|
moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult();
|
||||||
|
|
||||||
var file = File.Open(fileName, FileMode.OpenOrCreate);
|
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
|
||||||
OnFileCreated(fileName);
|
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
|
||||||
return file;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,113 +1,103 @@
|
|||||||
using System;
|
using AAXClean;
|
||||||
|
using AAXClean.Codecs;
|
||||||
|
using Dinah.Core.Net.Http;
|
||||||
|
using FileManager;
|
||||||
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AAXClean;
|
|
||||||
using AAXClean.Codecs;
|
|
||||||
using FileManager;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||||
{
|
{
|
||||||
|
private readonly AverageSpeed averageSpeed = new();
|
||||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
: base(outFileName, cacheDirectory, dlOptions)
|
||||||
|
|
||||||
public override async Task<bool> RunAsync()
|
|
||||||
{
|
{
|
||||||
try
|
var step = 1;
|
||||||
{
|
|
||||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
|
|
||||||
//Step 1
|
AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}";
|
||||||
Serilog.Log.Information("Begin Step 1: Get Aaxc Metadata");
|
AsyncSteps[$"Step {step++}: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||||
if (await Task.Run(Step_GetMetadata))
|
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
Serilog.Log.Information("Completed Step 1: Get Aaxc Metadata");
|
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
|
||||||
else
|
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
|
||||||
{
|
AsyncSteps[$"Step {step++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||||
Serilog.Log.Information("Failed to Complete Step 1: Get Aaxc Metadata");
|
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 2
|
|
||||||
Serilog.Log.Information("Begin Step 2: Download Decrypted Audiobook");
|
|
||||||
if (await Step_DownloadAudiobookAsSingleFile())
|
|
||||||
Serilog.Log.Information("Completed Step 2: Download Decrypted Audiobook");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 2: Download Decrypted Audiobook");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 3
|
|
||||||
Serilog.Log.Information("Begin Step 3: Create Cue");
|
|
||||||
if (await Task.Run(Step_CreateCue))
|
|
||||||
Serilog.Log.Information("Completed Step 3: Create Cue");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 3: Create Cue");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 4
|
|
||||||
Serilog.Log.Information("Begin Step 4: Cleanup");
|
|
||||||
if (await Task.Run(Step_Cleanup))
|
|
||||||
Serilog.Log.Information("Completed Step 4: Cleanup");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 4: Cleanup");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> Step_DownloadAudiobookAsSingleFile()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
//Finishing configuring lame encoder.
|
||||||
|
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||||
|
MpegUtil.ConfigureLameOptions(
|
||||||
|
AaxFile,
|
||||||
|
DownloadOptions.LameConfig,
|
||||||
|
DownloadOptions.Downsample,
|
||||||
|
DownloadOptions.MatchSourceBitrate,
|
||||||
|
DownloadOptions.ChapterInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
|
{
|
||||||
FileUtility.SaferDelete(OutputFileName);
|
FileUtility.SaferDelete(OutputFileName);
|
||||||
|
|
||||||
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||||
OnFileCreated(OutputFileName);
|
OnFileCreated(OutputFileName);
|
||||||
|
|
||||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
try
|
||||||
|
{
|
||||||
|
await (AaxConversion = decryptAsync(outputFile));
|
||||||
|
|
||||||
ConversionResult decryptionResult = await decryptAsync(outputFile);
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
|
}
|
||||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
finally
|
||||||
|
{
|
||||||
Step_DownloadAudiobook_End(zeroProgress);
|
FinalizeDownload();
|
||||||
|
}
|
||||||
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
|
|
||||||
if (success)
|
|
||||||
base.OnFileCreated(OutputFileName);
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<ConversionResult> decryptAsync(Stream outputFile)
|
private async Task<bool> Step_MoveMoov()
|
||||||
=> DownloadOptions.OutputFormat == OutputFormat.Mp3 ?
|
{
|
||||||
AaxFile.ConvertToMp3Async
|
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)
|
||||||
|
{
|
||||||
|
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(Stream outputFile)
|
||||||
|
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
|
||||||
|
? AaxFile.ConvertToMp3Async
|
||||||
(
|
(
|
||||||
outputFile,
|
outputFile,
|
||||||
DownloadOptions.LameConfig,
|
DownloadOptions.LameConfig,
|
||||||
DownloadOptions.ChapterInfo,
|
DownloadOptions.ChapterInfo
|
||||||
DownloadOptions.TrimOutputToChapterLength
|
|
||||||
)
|
)
|
||||||
: DownloadOptions.FixupFile ?
|
: DownloadOptions.FixupFile
|
||||||
AaxFile.ConvertToMp4aAsync
|
? AaxFile.ConvertToMp4aAsync
|
||||||
(
|
(
|
||||||
outputFile,
|
outputFile,
|
||||||
DownloadOptions.ChapterInfo,
|
DownloadOptions.ChapterInfo
|
||||||
DownloadOptions.TrimOutputToChapterLength
|
)
|
||||||
)
|
: AaxFile.ConvertToMp4aAsync(outputFile);
|
||||||
: AaxFile.ConvertToMp4aAsync(outputFile);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System;
|
using Dinah.Core;
|
||||||
using System.Collections.Generic;
|
using Dinah.Core.Net.Http;
|
||||||
|
using Dinah.Core.StepRunner;
|
||||||
|
using FileManager;
|
||||||
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Dinah.Core;
|
|
||||||
using Dinah.Core.Net.Http;
|
|
||||||
using FileManager;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
@@ -20,19 +20,18 @@ namespace AaxDecrypter
|
|||||||
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
||||||
public event EventHandler<string> FileCreated;
|
public event EventHandler<string> FileCreated;
|
||||||
|
|
||||||
public bool IsCanceled { get; set; }
|
public bool IsCanceled { get; protected set; }
|
||||||
public string TempFilePath { get; }
|
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||||
|
protected string OutputFileName { get; }
|
||||||
protected string OutputFileName { get; private set; }
|
|
||||||
protected IDownloadOptions DownloadOptions { get; }
|
protected IDownloadOptions DownloadOptions { get; }
|
||||||
protected NetworkFileStream InputFileStream => (nfsPersister ??= OpenNetworkFileStream()).NetworkFileStream;
|
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
|
||||||
|
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||||
|
private bool downloadFinished;
|
||||||
|
|
||||||
// Don't give the property a 'set'. This should have to be an obvious choice; not accidental
|
private readonly NetworkFileStreamPersister nfsPersister;
|
||||||
protected void SetOutputFileName(string newOutputFileName) => OutputFileName = newOutputFileName;
|
private readonly DownloadProgress zeroProgress;
|
||||||
|
private readonly string jsonDownloadState;
|
||||||
private NetworkFileStreamPersister nfsPersister;
|
private readonly string tempFilePath;
|
||||||
|
|
||||||
private string jsonDownloadState { get; }
|
|
||||||
|
|
||||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
{
|
{
|
||||||
@@ -46,23 +45,80 @@ namespace AaxDecrypter
|
|||||||
Directory.CreateDirectory(cacheDirectory);
|
Directory.CreateDirectory(cacheDirectory);
|
||||||
|
|
||||||
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
|
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
|
||||||
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
||||||
|
|
||||||
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
||||||
|
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
|
||||||
|
|
||||||
// delete file after validation is complete
|
// delete file after validation is complete
|
||||||
FileUtility.SaferDelete(OutputFileName);
|
FileUtility.SaferDelete(OutputFileName);
|
||||||
}
|
|
||||||
|
|
||||||
public abstract Task CancelAsync();
|
nfsPersister = OpenNetworkFileStream();
|
||||||
|
|
||||||
public virtual void SetCoverArt(byte[] coverArt)
|
zeroProgress = new DownloadProgress
|
||||||
{
|
{
|
||||||
if (coverArt is not null)
|
BytesReceived = 0,
|
||||||
OnRetrievedCoverArt(coverArt);
|
ProgressPercentage = 0,
|
||||||
|
TotalBytesToReceive = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
OnDecryptProgressUpdate(zeroProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract Task<bool> RunAsync();
|
public async Task<bool> RunAsync()
|
||||||
|
{
|
||||||
|
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) { }
|
||||||
|
|
||||||
protected void OnRetrievedTitle(string title)
|
protected void OnRetrievedTitle(string title)
|
||||||
=> RetrievedTitle?.Invoke(this, title);
|
=> RetrievedTitle?.Invoke(this, title);
|
||||||
@@ -72,97 +128,110 @@ namespace AaxDecrypter
|
|||||||
=> RetrievedNarrators?.Invoke(this, narrators);
|
=> RetrievedNarrators?.Invoke(this, narrators);
|
||||||
protected void OnRetrievedCoverArt(byte[] coverArt)
|
protected void OnRetrievedCoverArt(byte[] coverArt)
|
||||||
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
||||||
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
||||||
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
||||||
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
||||||
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
||||||
protected void OnFileCreated(string path)
|
protected void OnFileCreated(string path)
|
||||||
=> FileCreated?.Invoke(this, path);
|
=> FileCreated?.Invoke(this, path);
|
||||||
|
|
||||||
protected void CloseInputFileStream()
|
protected virtual void FinalizeDownload()
|
||||||
{
|
{
|
||||||
nfsPersister?.NetworkFileStream?.Close();
|
|
||||||
nfsPersister?.Dispose();
|
nfsPersister?.Dispose();
|
||||||
|
downloadFinished = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected bool Step_CreateCue()
|
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
|
||||||
{
|
{
|
||||||
if (!DownloadOptions.CreateCueSheet) return true;
|
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
|
||||||
|
{
|
||||||
|
var recordsFile = await DownloadOptions.SaveClipsAndBookmarksAsync(OutputFileName);
|
||||||
|
|
||||||
|
if (File.Exists(recordsFile))
|
||||||
|
OnFileCreated(recordsFile);
|
||||||
|
}
|
||||||
|
return !IsCanceled;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<bool> Step_CreateCueAsync()
|
||||||
|
{
|
||||||
|
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
|
||||||
|
|
||||||
// not a critical step. its failure should not prevent future steps from running
|
// not a critical step. its failure should not prevent future steps from running
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
||||||
path = FileUtility.GetValidFilename(path, DownloadOptions.ReplacementCharacters);
|
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
||||||
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
|
||||||
OnFileCreated(path);
|
OnFileCreated(path);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCue)}. FAILED");
|
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed");
|
||||||
}
|
}
|
||||||
return !IsCanceled;
|
return !IsCanceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected bool Step_Cleanup()
|
private async Task<bool> CleanupAsync()
|
||||||
{
|
{
|
||||||
bool success = !IsCanceled;
|
if (IsCanceled) return false;
|
||||||
if (success)
|
|
||||||
|
FileUtility.SaferDelete(jsonDownloadState);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
|
||||||
|
DownloadOptions.RetainEncryptedFile)
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(jsonDownloadState);
|
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||||
|
FileUtility.SaferMove(tempFilePath, aaxPath);
|
||||||
|
|
||||||
if (DownloadOptions.AudibleKey is not null &&
|
//Write aax decryption key
|
||||||
DownloadOptions.AudibleIV is not null &&
|
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
||||||
DownloadOptions.RetainEncryptedFile)
|
FileUtility.SaferDelete(keyPath);
|
||||||
{
|
|
||||||
string aaxPath = Path.ChangeExtension(TempFilePath, ".aax");
|
|
||||||
FileUtility.SaferMove(TempFilePath, aaxPath);
|
|
||||||
|
|
||||||
//Write aax decryption key
|
if (string.IsNullOrEmpty(DownloadOptions.AudibleIV))
|
||||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}");
|
||||||
FileUtility.SaferDelete(keyPath);
|
|
||||||
File.WriteAllText(keyPath, $"Key={DownloadOptions.AudibleKey}\r\nIV={DownloadOptions.AudibleIV}");
|
|
||||||
|
|
||||||
OnFileCreated(aaxPath);
|
|
||||||
OnFileCreated(keyPath);
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
FileUtility.SaferDelete(TempFilePath);
|
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
||||||
}
|
|
||||||
|
|
||||||
return success;
|
OnFileCreated(aaxPath);
|
||||||
|
OnFileCreated(keyPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
FileUtility.SaferDelete(tempFilePath);
|
||||||
|
|
||||||
|
return !IsCanceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private NetworkFileStreamPersister OpenNetworkFileStream()
|
private NetworkFileStreamPersister OpenNetworkFileStream()
|
||||||
{
|
{
|
||||||
if (!File.Exists(jsonDownloadState))
|
NetworkFileStreamPersister nfsp = default;
|
||||||
return NewNetworkFilePersister();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var nfsp = new NetworkFileStreamPersister(jsonDownloadState);
|
if (!File.Exists(jsonDownloadState))
|
||||||
// If More than ~1 hour has elapsed since getting the download url, it will expire.
|
return nfsp = newNetworkFilePersister();
|
||||||
// The new url will be to the same file.
|
|
||||||
|
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));
|
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
|
||||||
return nfsp;
|
return nfsp;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(jsonDownloadState);
|
FileUtility.SaferDelete(jsonDownloadState);
|
||||||
FileUtility.SaferDelete(TempFilePath);
|
FileUtility.SaferDelete(tempFilePath);
|
||||||
return NewNetworkFilePersister();
|
return nfsp = newNetworkFilePersister();
|
||||||
}
|
}
|
||||||
}
|
finally
|
||||||
|
|
||||||
private NetworkFileStreamPersister NewNetworkFilePersister()
|
|
||||||
{
|
|
||||||
var headers = new System.Net.WebHeaderCollection
|
|
||||||
{
|
{
|
||||||
{ "User-Agent", DownloadOptions.UserAgent }
|
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
|
||||||
};
|
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
||||||
|
}
|
||||||
|
|
||||||
var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, headers);
|
NetworkFileStreamPersister newNetworkFilePersister()
|
||||||
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
{
|
||||||
|
var networkFileStream = new NetworkFileStream(tempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
|
||||||
|
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using Dinah.Core;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace AaxDecrypter;
|
||||||
|
|
||||||
|
public static class LinqStats
|
||||||
|
{
|
||||||
|
public static (double mean, double stdDev) BasicStatisticsBy<T>(this IEnumerable<T> values, Func<T, double> selector)
|
||||||
|
{
|
||||||
|
var count = values.Count();
|
||||||
|
var mean = values.Average(selector);
|
||||||
|
|
||||||
|
return (mean, Math.Sqrt(values.Sum(s => Math.Pow(selector(s) - mean, 2)) / (count - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool T_Test_2By<T>(this IEnumerable<T> values, Func<T, double> selector, IEnumerable<T> secondGroup, Significance confidence)
|
||||||
|
{
|
||||||
|
var n1 = values.Count();
|
||||||
|
var n2 = secondGroup.Count();
|
||||||
|
var n = n1 + n2;
|
||||||
|
|
||||||
|
if (n1 < 3 || n2 < 3) return false;
|
||||||
|
|
||||||
|
(var mean1, var stdDev1) = values.BasicStatisticsBy(selector);
|
||||||
|
(var mean2, var stdDev2) = secondGroup.BasicStatisticsBy(selector);
|
||||||
|
|
||||||
|
var pooledStdDev = Math.Sqrt((((n1 - 1) * (stdDev1 * stdDev1)) + ((n2 - 1) * (stdDev2 * stdDev2))) / (n1 + n2 - 2));
|
||||||
|
|
||||||
|
var testStat = Math.Abs(mean1 - mean2) / (pooledStdDev * Math.Sqrt(1d / n1 + 1d / n2));
|
||||||
|
var crit = T_Stat(Math.Min(n - 2, MAX_DEGREES_FREEDOM), confidence);
|
||||||
|
|
||||||
|
return testStat > crit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool T_Test_1By<T>(this IEnumerable<T> values, Func<T, double> selector, double testMean, Significance confidence)
|
||||||
|
{
|
||||||
|
var n = values.Count();
|
||||||
|
|
||||||
|
if (n < 2) return false;
|
||||||
|
|
||||||
|
(var sampleMean, var sampleStdDev) = values.BasicStatisticsBy(selector);
|
||||||
|
|
||||||
|
var testStat = Math.Abs(sampleMean - testMean) / (sampleStdDev / Math.Sqrt(n));
|
||||||
|
var crit = T_Stat(Math.Min(n - 1, MAX_DEGREES_FREEDOM), confidence);
|
||||||
|
|
||||||
|
return testStat > crit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double T_Stat(int degreesFreedom, Significance confidence)
|
||||||
|
{
|
||||||
|
ArgumentValidator.EnsureBetweenInclusive(degreesFreedom, nameof(degreesFreedom), MIN_DEGREES_FREEDOM, MAX_DEGREES_FREEDOM);
|
||||||
|
|
||||||
|
return T_TABLE[(int)confidence][degreesFreedom - MIN_DEGREES_FREEDOM];
|
||||||
|
}
|
||||||
|
|
||||||
|
static LinqStats()
|
||||||
|
{
|
||||||
|
T_TABLE = new double[][] { T_Table_01, T_Table_05, T_Table_10, T_Table_15, T_Table_20, T_Table_25 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int MIN_DEGREES_FREEDOM = 1;
|
||||||
|
private const int MAX_DEGREES_FREEDOM = 201;
|
||||||
|
/// <summary>
|
||||||
|
/// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Significance
|
||||||
|
{
|
||||||
|
P01,
|
||||||
|
P05,
|
||||||
|
P10,
|
||||||
|
P15,
|
||||||
|
P20,
|
||||||
|
P25
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AverageSpeed
|
||||||
|
{
|
||||||
|
/// <summary>Average speed in units per second</summary>
|
||||||
|
public double Average { get; private set; }
|
||||||
|
public TimeSpan SlowWindow { get; }
|
||||||
|
public TimeSpan FastWindow { get; }
|
||||||
|
public Significance SlowSignificance { get; }
|
||||||
|
public Significance FastSignificance { get; }
|
||||||
|
|
||||||
|
private DateTime start;
|
||||||
|
private TimeSpan lastTime;
|
||||||
|
private double lastPosition = double.NaN;
|
||||||
|
|
||||||
|
private readonly record struct Point(TimeSpan Time, double Velocity);
|
||||||
|
private readonly LinkedList<Point> speeds = new();
|
||||||
|
private const int MAX_SPEEDS = 200;
|
||||||
|
|
||||||
|
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="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)
|
||||||
|
{
|
||||||
|
SlowWindow = ArgumentValidator.EnsureGreaterThan(slowWindow, nameof(slowWindow), fastWindow);
|
||||||
|
FastWindow = ArgumentValidator.EnsureGreaterThan(fastWindow, nameof(fastWindow), TimeSpan.Zero);
|
||||||
|
SlowSignificance = slowSignificance;
|
||||||
|
FastSignificance = fastSignificance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Add a new position to the moving average</summary>
|
||||||
|
public void AddPosition(double position)
|
||||||
|
{
|
||||||
|
var now = DateTime.Now;
|
||||||
|
if (start == default)
|
||||||
|
start = now;
|
||||||
|
|
||||||
|
var time = now - start;
|
||||||
|
|
||||||
|
while (speeds.Count > MAX_SPEEDS || (speeds.Count > 2 && time - speeds.First.Value.Time > SlowWindow))
|
||||||
|
speeds.RemoveFirst();
|
||||||
|
|
||||||
|
if (!double.IsNaN(lastPosition))
|
||||||
|
{
|
||||||
|
var newSpeed = (position - lastPosition) / (time - lastTime).TotalSeconds;
|
||||||
|
speeds.AddLast(new Point(time, newSpeed));
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTime = time;
|
||||||
|
lastPosition = position;
|
||||||
|
|
||||||
|
Average = ComputeNextAverage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ComputeNextAverage()
|
||||||
|
{
|
||||||
|
if (speeds.Count == 0)
|
||||||
|
return 0;
|
||||||
|
else if (speeds.Count == 1)
|
||||||
|
return speeds.Last.Value.Velocity;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var n_newest = speeds.Count(s => s.Time > lastTime.Subtract(FastWindow));
|
||||||
|
|
||||||
|
var n_oldest = speeds.Count - n_newest;
|
||||||
|
|
||||||
|
if (speeds.Take(n_oldest).T_Test_2By(s => s.Velocity, speeds.TakeLast(n_newest), FastSignificance))
|
||||||
|
{
|
||||||
|
//Speeds in FastWindow are significantly different from reset of speeds in SlowWindow.
|
||||||
|
//Discard older speeds and keep only speeds in FastWindow
|
||||||
|
for (; n_oldest > 0; n_oldest--)
|
||||||
|
speeds.RemoveFirst();
|
||||||
|
|
||||||
|
return speeds.Average(s => s.Velocity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return
|
||||||
|
speeds.T_Test_1By(s => s.Velocity, Average, SlowSignificance)
|
||||||
|
? speeds.Average(s => s.Velocity)
|
||||||
|
: Average;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
using System;
|
using AAXClean;
|
||||||
|
using Dinah.Core;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using AAXClean;
|
|
||||||
using Dinah.Core;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
@@ -16,15 +15,14 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
var startOffset = chapters.StartOffset;
|
var startOffset = chapters.StartOffset;
|
||||||
|
|
||||||
var trackCount = 0;
|
var trackCount = 1;
|
||||||
foreach (var c in chapters.Chapters)
|
foreach (var c in chapters.Chapters)
|
||||||
{
|
{
|
||||||
var startTime = c.StartOffset - startOffset;
|
var startTime = c.StartOffset - startOffset;
|
||||||
trackCount++;
|
|
||||||
|
|
||||||
stringBuilder.AppendLine($"TRACK {trackCount} AUDIO");
|
stringBuilder.AppendLine($"TRACK {trackCount++} AUDIO");
|
||||||
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
|
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
|
||||||
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds / 1000d * 75)}");
|
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds * 75d / 1000):D2}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return stringBuilder.ToString();
|
return stringBuilder.ToString();
|
||||||
@@ -46,7 +44,7 @@ namespace AaxDecrypter
|
|||||||
for (var i = 0; i < cueContents.Length; i++)
|
for (var i = 0; i < cueContents.Length; i++)
|
||||||
{
|
{
|
||||||
var line = cueContents[i];
|
var line = cueContents[i];
|
||||||
if (!line.Trim().StartsWith("FILE") || !line.Contains(" "))
|
if (!line.Trim().StartsWith("FILE") || !line.Contains(' '))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var fileTypeBegins = line.LastIndexOf(" ") + 1;
|
var fileTypeBegins = line.LastIndexOf(" ") + 1;
|
||||||
|
|||||||
@@ -1,25 +1,39 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public interface IDownloadOptions
|
public interface IDownloadOptions
|
||||||
{
|
{
|
||||||
FileManager.ReplacementCharacters ReplacementCharacters { get; }
|
event EventHandler<long> DownloadSpeedChanged;
|
||||||
string DownloadUrl { get; }
|
string DownloadUrl { get; }
|
||||||
string UserAgent { get; }
|
string UserAgent { get; }
|
||||||
string AudibleKey { get; }
|
string AudibleKey { get; }
|
||||||
string AudibleIV { get; }
|
string AudibleIV { get; }
|
||||||
|
TimeSpan RuntimeLength { get; }
|
||||||
OutputFormat OutputFormat { get; }
|
OutputFormat OutputFormat { get; }
|
||||||
bool TrimOutputToChapterLength { get; }
|
bool TrimOutputToChapterLength { get; }
|
||||||
bool RetainEncryptedFile { get; }
|
bool RetainEncryptedFile { get; }
|
||||||
bool StripUnabridged { get; }
|
bool StripUnabridged { get; }
|
||||||
bool CreateCueSheet { get; }
|
bool CreateCueSheet { get; }
|
||||||
|
bool DownloadClipsBookmarks { get; }
|
||||||
|
long DownloadSpeedBps { get; }
|
||||||
ChapterInfo ChapterInfo { get; }
|
ChapterInfo ChapterInfo { get; }
|
||||||
bool FixupFile { get; }
|
bool FixupFile { get; }
|
||||||
NAudio.Lame.LameConfig LameConfig { get; }
|
string AudibleProductId { get; }
|
||||||
|
string Title { get; }
|
||||||
|
string Subtitle { get; }
|
||||||
|
string Publisher { get; }
|
||||||
|
string Language { get; }
|
||||||
|
string SeriesName { get; }
|
||||||
|
float? SeriesNumber { get; }
|
||||||
|
NAudio.Lame.LameConfig LameConfig { get; }
|
||||||
bool Downsample { get; }
|
bool Downsample { get; }
|
||||||
bool MatchSourceBitrate { get; }
|
bool MatchSourceBitrate { get; }
|
||||||
|
bool MoveMoovToBeginning { get; }
|
||||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
string GetMultipartFileName(MultiConvertFileProperties props);
|
||||||
string GetMultipartTitleName(MultiConvertFileProperties props);
|
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||||
}
|
Task<string> SaveClipsAndBookmarksAsync(string fileName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,33 +1,69 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
|
using AAXClean.Codecs;
|
||||||
using NAudio.Lame;
|
using NAudio.Lame;
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public static class MpegUtil
|
public static class MpegUtil
|
||||||
{
|
{
|
||||||
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
|
private const string TagDomain = "com.pilabor.tone";
|
||||||
|
public static void ConfigureLameOptions(
|
||||||
|
Mp4File mp4File,
|
||||||
|
LameConfig lameConfig,
|
||||||
|
bool downsample,
|
||||||
|
bool matchSourceBitrate,
|
||||||
|
ChapterInfo chapters)
|
||||||
{
|
{
|
||||||
double bitrateMultiple = 1;
|
double bitrateMultiple = 1;
|
||||||
|
|
||||||
|
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 (mp4File.AudioChannels == 2)
|
||||||
{
|
{
|
||||||
if (downsample)
|
if (downsample)
|
||||||
bitrateMultiple = 0.5;
|
bitrateMultiple /= 2;
|
||||||
else
|
else
|
||||||
lameConfig.Mode = MPEGMode.Stereo;
|
lameConfig.Mode = MPEGMode.Stereo;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchSourceBitrate)
|
if (matchSourceBitrate)
|
||||||
{
|
{
|
||||||
int kbps = (int)(mp4File.AverageBitrate * bitrateMultiple / 1024);
|
int kbps = (int)Math.Round(mp4File.AverageBitrate * bitrateMultiple / 1024);
|
||||||
|
|
||||||
if (lameConfig.VBR is null)
|
if (lameConfig.VBR is null)
|
||||||
lameConfig.BitRate = kbps;
|
lameConfig.BitRate = kbps;
|
||||||
else if (lameConfig.VBR == VBRMode.ABR)
|
else if (lameConfig.VBR == VBRMode.ABR)
|
||||||
lameConfig.ABRRateKbps = kbps;
|
lameConfig.ABRRateKbps = kbps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Setup metadata tags
|
||||||
|
lameConfig.ID3 = mp4File.AppleTags.ToIDTags();
|
||||||
|
|
||||||
|
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SUBTITLE") is string subtitle)
|
||||||
|
lameConfig.ID3.Subtitle = subtitle;
|
||||||
|
|
||||||
|
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "LANGUAGE") is string lang)
|
||||||
|
lameConfig.ID3.UserDefinedText.Add("LANGUAGE", lang);
|
||||||
|
|
||||||
|
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SERIES") is string series)
|
||||||
|
lameConfig.ID3.UserDefinedText.Add("SERIES", series);
|
||||||
|
|
||||||
|
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "PART") is string part)
|
||||||
|
lameConfig.ID3.UserDefinedText.Add("PART", part);
|
||||||
|
|
||||||
|
if (chapters?.Count > 0)
|
||||||
|
{
|
||||||
|
var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters);
|
||||||
|
lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
|
||||||
using FileManager;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
@@ -10,6 +8,6 @@ namespace AaxDecrypter
|
|||||||
public int PartsPosition { get; set; }
|
public int PartsPosition { get; set; }
|
||||||
public int PartsTotal { get; set; }
|
public int PartsTotal { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
public DateTime FileDate { get; } = DateTime.Now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,91 +1,59 @@
|
|||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>A resumable, simultaneous file downloader and reader. </summary>
|
||||||
/// A <see cref="CookieContainer"/> for a single Uri.
|
|
||||||
/// </summary>
|
|
||||||
public class SingleUriCookieContainer : CookieContainer
|
|
||||||
{
|
|
||||||
private Uri baseAddress;
|
|
||||||
public Uri Uri
|
|
||||||
{
|
|
||||||
get => baseAddress;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
baseAddress = new UriBuilder(value.Scheme, value.Host).Uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public CookieCollection GetCookies()
|
|
||||||
{
|
|
||||||
return GetCookies(Uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A resumable, simultaneous file downloader and reader.
|
|
||||||
/// </summary>
|
|
||||||
public class NetworkFileStream : Stream, IUpdatable
|
public class NetworkFileStream : Stream, IUpdatable
|
||||||
{
|
{
|
||||||
public event EventHandler Updated;
|
public event EventHandler Updated;
|
||||||
|
|
||||||
#region Public Properties
|
#region Public Properties
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Location to save the downloaded data. </summary>
|
||||||
/// Location to save the downloaded data.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
[JsonProperty(Required = Required.Always)]
|
||||||
public string SaveFilePath { get; }
|
public string SaveFilePath { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Http(s) address of the file to download. </summary>
|
||||||
/// Http(s) address of the file to download.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
[JsonProperty(Required = Required.Always)]
|
||||||
public Uri Uri { get; private set; }
|
public Uri Uri { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Http headers to be sent to the server with the request. </summary>
|
||||||
/// All cookies set by caller or by the remote server.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
[JsonProperty(Required = Required.Always)]
|
||||||
public SingleUriCookieContainer CookieContainer { get; }
|
public Dictionary<string, string> RequestHeaders { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> The position in <see cref="SaveFilePath"/> that has been written and flushed to disk. </summary>
|
||||||
/// Http headers to be sent to the server with the request.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
|
||||||
public WebHeaderCollection RequestHeaders { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The position in <see cref="SaveFilePath"/> that has been written and flushed to disk.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
[JsonProperty(Required = Required.Always)]
|
||||||
public long WritePosition { get; private set; }
|
public long WritePosition { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> The total length of the <see cref="Uri"/> file to download. </summary>
|
||||||
/// The total length of the <see cref="Uri"/> file to download.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
[JsonProperty(Required = Required.Always)]
|
||||||
public long ContentLength { get; private set; }
|
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
|
#endregion
|
||||||
|
|
||||||
#region Private Properties
|
#region Private Properties
|
||||||
private HttpWebRequest HttpRequest { get; set; }
|
|
||||||
private FileStream _writeFile { get; }
|
private FileStream _writeFile { get; }
|
||||||
private FileStream _readFile { get; }
|
private FileStream _readFile { get; }
|
||||||
private Stream _networkStream { get; set; }
|
private CancellationTokenSource _cancellationSource { get; } = new();
|
||||||
private bool hasBegunDownloading { get; set; }
|
private EventWaitHandle _downloadedPiece { get; set; }
|
||||||
public bool IsCancelled { get; private set; }
|
|
||||||
private EventWaitHandle downloadEnded { get; set; }
|
|
||||||
private EventWaitHandle downloadedPiece { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -98,32 +66,32 @@ namespace AaxDecrypter
|
|||||||
//DATA_FLUSH_SZ bytes are written to the file stream.
|
//DATA_FLUSH_SZ bytes are written to the file stream.
|
||||||
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
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
|
#endregion
|
||||||
|
|
||||||
#region Constructor
|
#region Constructor
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> A resumable, simultaneous file downloader and reader. </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="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="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="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>
|
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
|
||||||
/// <param name="cookies">A <see cref="SingleUriCookieContainer"/> with cookies to send with the <see cref="HttpWebRequest"/>. It will also be populated with any cookies set by the server. </param>
|
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary<string, string> requestHeaders = null)
|
||||||
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, WebHeaderCollection requestHeaders = null, SingleUriCookieContainer cookies = null)
|
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
|
SaveFilePath = ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri));
|
Uri = ArgumentValidator.EnsureNotNull(uri, nameof(uri));
|
||||||
ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
|
WritePosition = ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
|
||||||
|
|
||||||
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
|
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
|
||||||
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
|
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
|
||||||
|
|
||||||
SaveFilePath = saveFilePath;
|
RequestHeaders = requestHeaders ?? new();
|
||||||
Uri = uri;
|
|
||||||
WritePosition = writePosition;
|
|
||||||
RequestHeaders = requestHeaders ?? new WebHeaderCollection();
|
|
||||||
CookieContainer = cookies ?? new SingleUriCookieContainer { Uri = uri };
|
|
||||||
|
|
||||||
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
|
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
|
||||||
{
|
{
|
||||||
@@ -139,12 +107,10 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
#region Downloader
|
#region Downloader
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
||||||
/// Update the <see cref="JsonFilePersister"/>.
|
private void OnUpdate()
|
||||||
/// </summary>
|
|
||||||
private void Update()
|
|
||||||
{
|
{
|
||||||
RequestHeaders = HttpRequest.Headers;
|
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Updated?.Invoke(this, EventArgs.Empty);
|
Updated?.Invoke(this, EventArgs.Empty);
|
||||||
@@ -155,47 +121,42 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/> </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>
|
/// <param name="uriToSameFile">New <see cref="System.Uri"/> host must match existing host.</param>
|
||||||
public void SetUriForSameFile(Uri uriToSameFile)
|
public void SetUriForSameFile(Uri uriToSameFile)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(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)
|
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}");
|
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 (hasBegunDownloading)
|
if (DownloadTask is not null)
|
||||||
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
||||||
|
|
||||||
Uri = uriToSameFile;
|
Uri = uriToSameFile;
|
||||||
HttpRequest = WebRequest.CreateHttp(Uri);
|
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||||
|
|
||||||
HttpRequest.CookieContainer = CookieContainer;
|
|
||||||
HttpRequest.Headers = RequestHeaders;
|
|
||||||
//If NetworkFileStream is resuming, Header will already contain a range.
|
|
||||||
HttpRequest.Headers.Remove("Range");
|
|
||||||
HttpRequest.AddRange(WritePosition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
|
||||||
/// Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread.
|
/// <returns>The downloader <see cref="Task"/></returns>
|
||||||
/// </summary>
|
public async Task BeginDownloadingAsync()
|
||||||
private void BeginDownloading()
|
|
||||||
{
|
{
|
||||||
downloadEnded = new EventWaitHandle(false, EventResetMode.ManualReset);
|
|
||||||
|
|
||||||
if (ContentLength != 0 && WritePosition == ContentLength)
|
if (ContentLength != 0 && WritePosition == ContentLength)
|
||||||
{
|
{
|
||||||
hasBegunDownloading = true;
|
DownloadTask = Task.CompletedTask;
|
||||||
downloadEnded.Set();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ContentLength != 0 && WritePosition > ContentLength)
|
if (ContentLength != 0 && WritePosition > ContentLength)
|
||||||
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
|
|
||||||
var response = HttpRequest.GetResponse() as HttpWebResponse;
|
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)
|
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||||
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
||||||
@@ -203,24 +164,18 @@ namespace AaxDecrypter
|
|||||||
//Content length is the length of the range request, and it is only equal
|
//Content length is the length of the range request, and it is only equal
|
||||||
//to the complete file length if requesting Range: bytes=0-
|
//to the complete file length if requesting Range: bytes=0-
|
||||||
if (WritePosition == 0)
|
if (WritePosition == 0)
|
||||||
ContentLength = response.ContentLength;
|
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
|
||||||
|
|
||||||
_networkStream = response.GetResponseStream();
|
var networkStream = await response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
||||||
downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||||
|
|
||||||
//Download the file in the background.
|
//Download the file in the background.
|
||||||
new Thread(() => DownloadFile())
|
|
||||||
{ IsBackground = true }
|
|
||||||
.Start();
|
|
||||||
|
|
||||||
hasBegunDownloading = true;
|
DownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
||||||
/// Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.
|
private async Task DownloadFile(Stream networkStream)
|
||||||
/// </summary>
|
|
||||||
private void DownloadFile()
|
|
||||||
{
|
{
|
||||||
var downloadPosition = WritePosition;
|
var downloadPosition = WritePosition;
|
||||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||||
@@ -228,29 +183,44 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
DateTime startTime = DateTime.Now;
|
||||||
|
long bytesReadSinceThrottle = 0;
|
||||||
int bytesRead;
|
int bytesRead;
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
|
bytesRead = await networkStream.ReadAsync(buff, _cancellationSource.Token);
|
||||||
_writeFile.Write(buff, 0, bytesRead);
|
await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token);
|
||||||
|
|
||||||
downloadPosition += bytesRead;
|
downloadPosition += bytesRead;
|
||||||
|
|
||||||
if (downloadPosition > nextFlush)
|
if (downloadPosition > nextFlush)
|
||||||
{
|
{
|
||||||
_writeFile.Flush();
|
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||||
WritePosition = downloadPosition;
|
WritePosition = downloadPosition;
|
||||||
Update();
|
OnUpdate();
|
||||||
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||||
downloadedPiece.Set();
|
_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);
|
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
|
||||||
|
|
||||||
_writeFile.Close();
|
|
||||||
_networkStream.Close();
|
|
||||||
WritePosition = downloadPosition;
|
WritePosition = downloadPosition;
|
||||||
Update();
|
|
||||||
|
|
||||||
if (!IsCancelled && WritePosition < ContentLength)
|
if (!IsCancelled && WritePosition < ContentLength)
|
||||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
@@ -258,110 +228,16 @@ namespace AaxDecrypter
|
|||||||
if (WritePosition > ContentLength)
|
if (WritePosition > ContentLength)
|
||||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (TaskCanceledException)
|
||||||
{
|
{
|
||||||
Serilog.Log.Error(ex, "An error was encountered while downloading {Uri}", Uri);
|
Serilog.Log.Information("Download was cancelled");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
downloadedPiece.Set();
|
networkStream.Close();
|
||||||
downloadEnded.Set();
|
_writeFile.Close();
|
||||||
}
|
_downloadedPiece.Set();
|
||||||
}
|
OnUpdate();
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Json Connverters
|
|
||||||
|
|
||||||
public static JsonSerializerSettings GetJsonSerializerSettings()
|
|
||||||
{
|
|
||||||
var settings = new JsonSerializerSettings();
|
|
||||||
settings.Converters.Add(new CookieContainerConverter());
|
|
||||||
settings.Converters.Add(new WebHeaderCollectionConverter());
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class CookieContainerConverter : JsonConverter
|
|
||||||
{
|
|
||||||
public override bool CanConvert(Type objectType)
|
|
||||||
=> objectType == typeof(SingleUriCookieContainer);
|
|
||||||
|
|
||||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
|
||||||
{
|
|
||||||
var jObj = JObject.Load(reader);
|
|
||||||
|
|
||||||
var result = new SingleUriCookieContainer()
|
|
||||||
{
|
|
||||||
Uri = new Uri(jObj["Uri"].Value<string>()),
|
|
||||||
Capacity = jObj["Capacity"].Value<int>(),
|
|
||||||
MaxCookieSize = jObj["MaxCookieSize"].Value<int>(),
|
|
||||||
PerDomainCapacity = jObj["PerDomainCapacity"].Value<int>()
|
|
||||||
};
|
|
||||||
|
|
||||||
var cookieList = jObj["Cookies"].ToList();
|
|
||||||
|
|
||||||
foreach (var cookie in cookieList)
|
|
||||||
{
|
|
||||||
result.Add(
|
|
||||||
new Cookie
|
|
||||||
{
|
|
||||||
Comment = cookie["Comment"].Value<string>(),
|
|
||||||
HttpOnly = cookie["HttpOnly"].Value<bool>(),
|
|
||||||
Discard = cookie["Discard"].Value<bool>(),
|
|
||||||
Domain = cookie["Domain"].Value<string>(),
|
|
||||||
Expired = cookie["Expired"].Value<bool>(),
|
|
||||||
Expires = cookie["Expires"].Value<DateTime>(),
|
|
||||||
Name = cookie["Name"].Value<string>(),
|
|
||||||
Path = cookie["Path"].Value<string>(),
|
|
||||||
Port = cookie["Port"].Value<string>(),
|
|
||||||
Secure = cookie["Secure"].Value<bool>(),
|
|
||||||
Value = cookie["Value"].Value<string>(),
|
|
||||||
Version = cookie["Version"].Value<int>(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool CanWrite => true;
|
|
||||||
|
|
||||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
|
||||||
{
|
|
||||||
var cookies = value as SingleUriCookieContainer;
|
|
||||||
var obj = (JObject)JToken.FromObject(value);
|
|
||||||
var container = cookies.GetCookies();
|
|
||||||
var propertyNames = container.Select(c => JToken.FromObject(c));
|
|
||||||
obj.AddFirst(new JProperty("Cookies", new JArray(propertyNames)));
|
|
||||||
obj.WriteTo(writer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class WebHeaderCollectionConverter : JsonConverter
|
|
||||||
{
|
|
||||||
public override bool CanConvert(Type objectType)
|
|
||||||
=> objectType == typeof(WebHeaderCollection);
|
|
||||||
|
|
||||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
|
||||||
{
|
|
||||||
var jObj = JObject.Load(reader);
|
|
||||||
var result = new WebHeaderCollection();
|
|
||||||
|
|
||||||
foreach (var kvp in jObj)
|
|
||||||
result.Add(kvp.Key, kvp.Value.Value<string>());
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool CanWrite => true;
|
|
||||||
|
|
||||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
|
||||||
{
|
|
||||||
var jObj = new JObject();
|
|
||||||
var type = value.GetType();
|
|
||||||
var headers = value as WebHeaderCollection;
|
|
||||||
var jHeaders = headers.AllKeys.Select(k => new JProperty(k, headers[k]));
|
|
||||||
jObj.Add(jHeaders);
|
|
||||||
jObj.WriteTo(writer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,10 +246,10 @@ namespace AaxDecrypter
|
|||||||
#region Download Stream Reader
|
#region Download Stream Reader
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public override bool CanRead => true;
|
public override bool CanRead => _readFile.CanRead;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public override bool CanSeek => true;
|
public override bool CanSeek => _readFile.CanSeek;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public override bool CanWrite => false;
|
public override bool CanWrite => false;
|
||||||
@@ -383,8 +259,8 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (!hasBegunDownloading)
|
if (DownloadTask is null)
|
||||||
BeginDownloading();
|
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
||||||
return ContentLength;
|
return ContentLength;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,18 +277,18 @@ namespace AaxDecrypter
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
|
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
|
||||||
|
|
||||||
public override void Flush() => throw new NotImplementedException();
|
public override void Flush() => throw new InvalidOperationException();
|
||||||
public override void SetLength(long value) => throw new NotImplementedException();
|
public override void SetLength(long value) => throw new InvalidOperationException();
|
||||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException();
|
public override void Write(byte[] buffer, int offset, int count) => throw new InvalidOperationException();
|
||||||
|
|
||||||
public override int Read(byte[] buffer, int offset, int count)
|
public override int Read(byte[] buffer, int offset, int count)
|
||||||
{
|
{
|
||||||
if (!hasBegunDownloading)
|
if (DownloadTask is null)
|
||||||
BeginDownloading();
|
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
||||||
|
|
||||||
var toRead = Math.Min(count, Length - Position);
|
var toRead = Math.Min(count, Length - Position);
|
||||||
WaitToPosition(Position + toRead);
|
WaitToPosition(Position + toRead);
|
||||||
return _readFile.Read(buffer, offset, count);
|
return IsCancelled ? 0 : _readFile.Read(buffer, offset, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override long Seek(long offset, SeekOrigin origin)
|
public override long Seek(long offset, SeekOrigin origin)
|
||||||
@@ -428,38 +304,43 @@ namespace AaxDecrypter
|
|||||||
return _readFile.Position = newPosition;
|
return _readFile.Position = newPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns. </summary>
|
||||||
/// Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns.
|
/// <param name="requiredPosition">The minimum required flushed data length in <see cref="SaveFilePath"/>.</param>
|
||||||
/// </summary>
|
|
||||||
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
|
|
||||||
private void WaitToPosition(long requiredPosition)
|
private void WaitToPosition(long requiredPosition)
|
||||||
{
|
{
|
||||||
while (WritePosition < requiredPosition
|
while (WritePosition < requiredPosition
|
||||||
&& hasBegunDownloading
|
&& DownloadTask?.IsCompleted is false
|
||||||
&& !IsCancelled
|
&& !IsCancelled)
|
||||||
&& !downloadEnded.WaitOne(0))
|
|
||||||
{
|
{
|
||||||
downloadedPiece.WaitOne(100);
|
_downloadedPiece.WaitOne(50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Close()
|
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)
|
||||||
{
|
{
|
||||||
IsCancelled = true;
|
if (disposing && !disposed)
|
||||||
|
{
|
||||||
|
_cancellationSource.Cancel();
|
||||||
|
DownloadTask?.GetAwaiter().GetResult();
|
||||||
|
_downloadedPiece?.Dispose();
|
||||||
|
_cancellationSource?.Dispose();
|
||||||
|
_readFile.Dispose();
|
||||||
|
_writeFile.Dispose();
|
||||||
|
OnUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
while (downloadEnded is not null && !downloadEnded.WaitOne(100)) ;
|
disposed = true;
|
||||||
|
base.Dispose(disposing);
|
||||||
_readFile.Close();
|
|
||||||
_writeFile.Close();
|
|
||||||
_networkStream?.Close();
|
|
||||||
Update();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
~NetworkFileStream()
|
|
||||||
{
|
|
||||||
downloadEnded?.Close();
|
|
||||||
downloadedPiece?.Close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
using Dinah.Core.IO;
|
using Dinah.Core.IO;
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
|
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
|
||||||
{
|
{
|
||||||
|
|
||||||
/// <summary>Alias for Target </summary>
|
/// <summary>Alias for Target </summary>
|
||||||
public NetworkFileStream NetworkFileStream => Target;
|
public NetworkFileStream NetworkFileStream => Target;
|
||||||
|
|
||||||
@@ -17,7 +15,11 @@ namespace AaxDecrypter
|
|||||||
public NetworkFileStreamPersister(string path, string jsonPath = null)
|
public NetworkFileStreamPersister(string path, string jsonPath = null)
|
||||||
: base(path, jsonPath) { }
|
: base(path, jsonPath) { }
|
||||||
|
|
||||||
protected override JsonSerializerSettings GetSerializerSettings() => NetworkFileStream.GetJsonSerializerSettings();
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
}
|
if (disposing)
|
||||||
|
NetworkFileStream?.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,112 +1,42 @@
|
|||||||
using System;
|
using FileManager;
|
||||||
using System.Threading;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Dinah.Core.Net.Http;
|
|
||||||
using FileManager;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
||||||
{
|
{
|
||||||
|
protected override long InputFilePosition => InputFileStream.WritePosition;
|
||||||
|
|
||||||
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
|
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
|
||||||
: base(outFileName, cacheDirectory, dlLic) { }
|
: base(outFileName, cacheDirectory, dlLic)
|
||||||
|
|
||||||
public override async Task<bool> RunAsync()
|
|
||||||
{
|
{
|
||||||
try
|
AsyncSteps.Name = "Download Unencrypted Audiobook";
|
||||||
{
|
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||||
|
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
|
||||||
//Step 1
|
|
||||||
Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata");
|
|
||||||
if (await Task.Run(Step_GetMetadata))
|
|
||||||
Serilog.Log.Information("Completed Step 1: Get Mp3 Metadata");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 1: Get Mp3 Metadata");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 2
|
|
||||||
Serilog.Log.Information("Begin Step 2: Download Audiobook");
|
|
||||||
if (await Task.Run(Step_DownloadAudiobookAsSingleFile))
|
|
||||||
Serilog.Log.Information("Completed Step 2: Download Audiobook");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 2: Download Audiobook");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 3
|
|
||||||
Serilog.Log.Information("Begin Step 3: Cleanup");
|
|
||||||
if (await Task.Run(Step_Cleanup))
|
|
||||||
Serilog.Log.Information("Completed Step 3: Cleanup");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 3: Cleanup");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public override Task CancelAsync()
|
public override Task CancelAsync()
|
||||||
{
|
{
|
||||||
IsCanceled = true;
|
IsCanceled = true;
|
||||||
CloseInputFileStream();
|
FinalizeDownload();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected bool Step_GetMetadata()
|
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
OnRetrievedCoverArt(null);
|
await InputFileStream.DownloadTask;
|
||||||
|
|
||||||
return !IsCanceled;
|
if (IsCanceled)
|
||||||
}
|
return false;
|
||||||
|
else
|
||||||
private bool Step_DownloadAudiobookAsSingleFile()
|
|
||||||
{
|
|
||||||
DateTime startTime = DateTime.Now;
|
|
||||||
|
|
||||||
// MUST put InputFileStream.Length first, because it starts background downloader.
|
|
||||||
|
|
||||||
while (InputFileStream.Length > InputFileStream.WritePosition && !InputFileStream.IsCancelled)
|
|
||||||
{
|
{
|
||||||
var rate = InputFileStream.WritePosition / (DateTime.Now - startTime).TotalSeconds;
|
FinalizeDownload();
|
||||||
|
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName);
|
||||||
var estTimeRemaining = (InputFileStream.Length - InputFileStream.WritePosition) / rate;
|
OnFileCreated(OutputFileName);
|
||||||
|
return true;
|
||||||
if (double.IsNormal(estTimeRemaining))
|
|
||||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
|
||||||
|
|
||||||
var progressPercent = (double)InputFileStream.WritePosition / InputFileStream.Length;
|
|
||||||
|
|
||||||
OnDecryptProgressUpdate(
|
|
||||||
new DownloadProgress
|
|
||||||
{
|
|
||||||
ProgressPercentage = 100 * progressPercent,
|
|
||||||
BytesReceived = (long)(InputFileStream.Length * progressPercent),
|
|
||||||
TotalBytesToReceive = InputFileStream.Length
|
|
||||||
});
|
|
||||||
Thread.Sleep(200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CloseInputFileStream();
|
|
||||||
|
|
||||||
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName, DownloadOptions.ReplacementCharacters);
|
|
||||||
SetOutputFileName(realOutputFileName);
|
|
||||||
OnFileCreated(realOutputFileName);
|
|
||||||
|
|
||||||
return !IsCanceled;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Version>8.5.1.1</Version>
|
<Version>12.0.4.2</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Octokit" Version="4.0.1" />
|
<PackageReference Include="Octokit" Version="14.0.0" />
|
||||||
|
<!-- Do not remove unused Serilog.Sinks -->
|
||||||
|
<!-- Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.ZipFile" Version="3.1.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||||
@@ -18,4 +23,4 @@
|
|||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,42 +1,48 @@
|
|||||||
using System;
|
using ApplicationServices;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Reflection;
|
|
||||||
using ApplicationServices;
|
|
||||||
using AudibleUtilities;
|
using AudibleUtilities;
|
||||||
using Dinah.Core.Collections.Generic;
|
|
||||||
using Dinah.Core.IO;
|
using Dinah.Core.IO;
|
||||||
using Dinah.Core.Logging;
|
using Dinah.Core.Logging;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Serilog;
|
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,
|
None,
|
||||||
WindowsClassic,
|
WindowsClassic = OS.Windows | Variety.Classic | Architecture.X64,
|
||||||
WindowsAvalonia,
|
WindowsAvalonia = OS.Windows | Variety.Chardonnay | Architecture.X64,
|
||||||
LinuxAvalonia,
|
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
|
||||||
MacOSAvalonia
|
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
|
||||||
|
LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64,
|
||||||
|
MacOSAvalonia_Arm64 = OS.MacOS | 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
|
// 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
|
||||||
public enum VarietyType { None, Classic, Chardonnay }
|
[Flags]
|
||||||
|
public enum Variety
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Classic = 0x10000,
|
||||||
|
Chardonnay = 0x20000,
|
||||||
|
}
|
||||||
|
|
||||||
public static class LibationScaffolding
|
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 ReleaseIdentifier ReleaseIdentifier { get; private set; }
|
||||||
public static VarietyType Variety
|
public static Variety Variety { get; private set; }
|
||||||
=> ReleaseIdentifier == ReleaseIdentifier.WindowsClassic ? VarietyType.Classic
|
|
||||||
: ReleaseIdentifier.In(ReleaseIdentifier.WindowsAvalonia, ReleaseIdentifier.LinuxAvalonia, ReleaseIdentifier.MacOSAvalonia) ? VarietyType.Chardonnay
|
|
||||||
: VarietyType.None;
|
|
||||||
|
|
||||||
public static void SetReleaseIdentifier(ReleaseIdentifier releaseID)
|
|
||||||
=> ReleaseIdentifier = releaseID;
|
|
||||||
|
|
||||||
// AppScaffolding
|
// AppScaffolding
|
||||||
private static Assembly _executingAssembly;
|
private static Assembly _executingAssembly;
|
||||||
@@ -55,13 +61,15 @@ namespace AppScaffolding
|
|||||||
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
||||||
.Max(a => a.Version);
|
.Max(a => a.Version);
|
||||||
|
|
||||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||||
public static Configuration RunPreConfigMigrations()
|
public static Configuration RunPreConfigMigrations()
|
||||||
{
|
{
|
||||||
// must occur before access to Configuration instance
|
// must occur before access to Configuration instance
|
||||||
// // outdated. kept here as an example of what belongs in this area
|
// // outdated. kept here as an example of what belongs in this area
|
||||||
// // Migrations.migrate_to_v5_2_0__pre_config();
|
// // Migrations.migrate_to_v5_2_0__pre_config();
|
||||||
|
|
||||||
|
Configuration.SetLibationVersion(BuildVersion);
|
||||||
|
|
||||||
//***********************************************//
|
//***********************************************//
|
||||||
// //
|
// //
|
||||||
// do not use Configuration before this line //
|
// do not use Configuration before this line //
|
||||||
@@ -74,114 +82,26 @@ namespace AppScaffolding
|
|||||||
public static void RunPostConfigMigrations(Configuration config)
|
public static void RunPostConfigMigrations(Configuration config)
|
||||||
{
|
{
|
||||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||||
PopulateMissingConfigValues(config);
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// migrations go below here
|
// migrations go below here
|
||||||
//
|
//
|
||||||
|
|
||||||
Migrations.migrate_to_v6_6_9(config);
|
Migrations.migrate_to_v6_6_9(config);
|
||||||
Migrations.migrate_from_7_10_1(config);
|
Migrations.migrate_to_v11_5_0(config);
|
||||||
}
|
Migrations.migrate_to_v11_6_5(config);
|
||||||
|
Migrations.migrate_to_v12_0_1(config);
|
||||||
public static void PopulateMissingConfigValues(Configuration config)
|
|
||||||
{
|
|
||||||
config.InProgress ??= Configuration.WinTemp;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.UseCoverAsFolderIcon)))
|
|
||||||
config.UseCoverAsFolderIcon = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.BetaOptIn)))
|
|
||||||
config.BetaOptIn = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
|
||||||
config.AllowLibationFixup = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.CreateCueSheet)))
|
|
||||||
config.CreateCueSheet = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.RetainAaxFile)))
|
|
||||||
config.RetainAaxFile = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.SplitFilesByChapter)))
|
|
||||||
config.SplitFilesByChapter = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.StripUnabridged)))
|
|
||||||
config.StripUnabridged = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.StripAudibleBrandAudio)))
|
|
||||||
config.StripAudibleBrandAudio = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.DecryptToLossy)))
|
|
||||||
config.DecryptToLossy = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameTargetBitrate)))
|
|
||||||
config.LameTargetBitrate = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameDownsampleMono)))
|
|
||||||
config.LameDownsampleMono = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameBitrate)))
|
|
||||||
config.LameBitrate = 64;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameConstantBitrate)))
|
|
||||||
config.LameConstantBitrate = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameMatchSourceBR)))
|
|
||||||
config.LameMatchSourceBR = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameVBRQuality)))
|
|
||||||
config.LameVBRQuality = 2;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.BadBook)))
|
|
||||||
config.BadBook = Configuration.BadBookAction.Ask;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ShowImportedStats)))
|
|
||||||
config.ShowImportedStats = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ImportEpisodes)))
|
|
||||||
config.ImportEpisodes = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.DownloadEpisodes)))
|
|
||||||
config.DownloadEpisodes = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ReplacementCharacters)))
|
|
||||||
config.ReplacementCharacters = FileManager.ReplacementCharacters.Default;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.FolderTemplate)))
|
|
||||||
config.FolderTemplate = Templates.Folder.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.FileTemplate)))
|
|
||||||
config.FileTemplate = Templates.File.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ChapterFileTemplate)))
|
|
||||||
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ChapterTitleTemplate)))
|
|
||||||
config.ChapterTitleTemplate = Templates.ChapterTitle.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.AutoScan)))
|
|
||||||
config.AutoScan = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.GridColumnsVisibilities)))
|
|
||||||
config.GridColumnsVisibilities = new Dictionary<string, bool>();
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.GridColumnsDisplayIndices)))
|
|
||||||
config.GridColumnsDisplayIndices = new Dictionary<string, int>();
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.GridColumnsWidths)))
|
|
||||||
config.GridColumnsWidths = new Dictionary<string, int>();
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.DownloadCoverArt)))
|
|
||||||
config.DownloadCoverArt = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.AutoDownloadEpisodes)))
|
|
||||||
config.AutoDownloadEpisodes = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
||||||
public static void RunPostMigrationScaffolding(Configuration config)
|
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
|
||||||
{
|
{
|
||||||
|
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);
|
ensureSerilogConfig(config);
|
||||||
configureLogging(config);
|
configureLogging(config);
|
||||||
logStartupState(config);
|
logStartupState(config);
|
||||||
@@ -193,18 +113,28 @@ namespace AppScaffolding
|
|||||||
|
|
||||||
private static void ensureSerilogConfig(Configuration config)
|
private static void ensureSerilogConfig(Configuration config)
|
||||||
{
|
{
|
||||||
if (config.GetObject("Serilog") is not null)
|
if (config.GetObject("Serilog") is JObject serilog)
|
||||||
|
{
|
||||||
|
if (serilog["WriteTo"] is JArray sinks && sinks.FirstOrDefault(s => s["Name"].Value<string>() is "File") is JToken fileSink)
|
||||||
|
{
|
||||||
|
fileSink["Name"] = "ZipFile";
|
||||||
|
config.SetNonString(serilog.DeepClone(), "Serilog");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var serilogObj = new JObject
|
var serilogObj = new JObject
|
||||||
{
|
{
|
||||||
{ "MinimumLevel", "Information" },
|
{ "MinimumLevel", "Information" },
|
||||||
{ "WriteTo", new JArray
|
{ "WriteTo", new JArray
|
||||||
{
|
{
|
||||||
|
// ABOUT SINKS
|
||||||
|
// Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use.
|
||||||
|
|
||||||
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||||
new JObject
|
new JObject
|
||||||
{
|
{
|
||||||
{ "Name", "File" },
|
{ "Name", "ZipFile" },
|
||||||
{ "Args",
|
{ "Args",
|
||||||
new JObject
|
new JObject
|
||||||
{
|
{
|
||||||
@@ -227,7 +157,7 @@ namespace AppScaffolding
|
|||||||
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
||||||
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
||||||
};
|
};
|
||||||
config.SetObject("Serilog", serilogObj);
|
config.SetNonString(serilogObj, "Serilog");
|
||||||
}
|
}
|
||||||
|
|
||||||
// to restore original: Console.SetOut(origOut);
|
// to restore original: Console.SetOut(origOut);
|
||||||
@@ -304,12 +234,20 @@ namespace AppScaffolding
|
|||||||
|
|
||||||
// begin logging session with a form feed
|
// begin logging session with a form feed
|
||||||
Log.Logger.Information("\r\n\f");
|
Log.Logger.Information("\r\n\f");
|
||||||
Log.Logger.Information("Begin. {@DebugInfo}", new
|
|
||||||
|
static int fileCount(FileManager.LongPath longPath)
|
||||||
|
{
|
||||||
|
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
|
||||||
|
catch { return -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||||
{
|
{
|
||||||
AppName = EntryAssembly.GetName().Name,
|
AppName = EntryAssembly.GetName().Name,
|
||||||
Version = BuildVersion.ToString(),
|
Version = BuildVersion.ToString(),
|
||||||
ReleaseIdentifier,
|
ReleaseIdentifier,
|
||||||
Configuration.OS,
|
Configuration.OS,
|
||||||
|
Environment.OSVersion,
|
||||||
InteropFactory.InteropFunctionsType,
|
InteropFactory.InteropFunctionsType,
|
||||||
Mode = mode,
|
Mode = mode,
|
||||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||||
@@ -319,6 +257,7 @@ namespace AppScaffolding
|
|||||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
||||||
|
|
||||||
|
config.AutoScan,
|
||||||
config.BetaOptIn,
|
config.BetaOptIn,
|
||||||
config.UseCoverAsFolderIcon,
|
config.UseCoverAsFolderIcon,
|
||||||
config.LibationFiles,
|
config.LibationFiles,
|
||||||
@@ -327,10 +266,12 @@ namespace AppScaffolding
|
|||||||
config.InProgress,
|
config.InProgress,
|
||||||
|
|
||||||
AudibleFileStorage.DownloadsInProgressDirectory,
|
AudibleFileStorage.DownloadsInProgressDirectory,
|
||||||
DownloadsInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DownloadsInProgressDirectory).Count(),
|
DownloadsInProgressFiles = fileCount(AudibleFileStorage.DownloadsInProgressDirectory),
|
||||||
|
|
||||||
AudibleFileStorage.DecryptInProgressDirectory,
|
AudibleFileStorage.DecryptInProgressDirectory,
|
||||||
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
DecryptInProgressFiles = fileCount(AudibleFileStorage.DecryptInProgressDirectory),
|
||||||
|
|
||||||
|
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (InteropFactory.InteropFunctionsType is null)
|
if (InteropFactory.InteropFunctionsType is null)
|
||||||
@@ -339,8 +280,11 @@ namespace AppScaffolding
|
|||||||
|
|
||||||
private static void wireUpSystemEvents(Configuration configuration)
|
private static void wireUpSystemEvents(Configuration configuration)
|
||||||
{
|
{
|
||||||
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
|
LibraryCommands.LibrarySizeChanged += (object _, List<DataLayer.LibraryBook> libraryBooks)
|
||||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
|
=> SearchEngineCommands.FullReIndex(libraryBooks);
|
||||||
|
|
||||||
|
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
|
||||||
|
=> SearchEngineCommands.UpdateBooks(books);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static UpgradeProperties GetLatestRelease()
|
public static UpgradeProperties GetLatestRelease()
|
||||||
@@ -370,7 +314,7 @@ namespace AppScaffolding
|
|||||||
zipUrl
|
zipUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease);
|
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body);
|
||||||
}
|
}
|
||||||
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
||||||
{
|
{
|
||||||
@@ -390,21 +334,31 @@ namespace AppScaffolding
|
|||||||
}
|
}
|
||||||
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
||||||
{
|
{
|
||||||
var ownerAccount = "rmcrackan";
|
const string ownerAccount = "rmcrackan";
|
||||||
var repoName = "Libation";
|
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
|
//Download the release index
|
||||||
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
||||||
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||||
var regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
|
||||||
|
|
||||||
// https://octokitnet.readthedocs.io/en/latest/releases/
|
string regexPattern;
|
||||||
var releases = await gitHubClient.Repository.Release.GetAll(ownerAccount, repoName);
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
regexPattern = releaseIndex.Value<string>(InteropFactory.Create().ReleaseIdString);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
var latestRelease = releases.FirstOrDefault(r => !r.Draft && !r.Prerelease && r.Assets.Any(a => regex.IsMatch(a.Name)));
|
|
||||||
|
//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)));
|
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,73 +412,139 @@ namespace AppScaffolding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void migrate_from_7_10_1(Configuration config)
|
class FilterState_6_6_9
|
||||||
|
{
|
||||||
|
public bool UseDefault { get; set; }
|
||||||
|
public List<string> Filters { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static void migrate_to_v12_0_1(Configuration config)
|
||||||
{
|
{
|
||||||
var lastMigrationThrew = config.GetNonString<bool>($"{nameof(migrate_from_7_10_1)}_ThrewError");
|
#nullable enable
|
||||||
|
//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";
|
||||||
|
|
||||||
if (lastMigrationThrew) return;
|
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1);
|
||||||
|
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2);
|
||||||
|
|
||||||
try
|
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
|
||||||
{
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
//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;
|
||||||
|
|
||||||
//https://github.com/rmcrackan/Libation/issues/270#issuecomment-1152863629
|
Dictionary<string, JArray> cache = new();
|
||||||
//This migration helps fix databases contaminated with the 7.10.1 hack workaround
|
|
||||||
//and those with improperly identified or missing series. This does not solve cases
|
|
||||||
//where individual episodes are in the db with a valid series link, but said series'
|
|
||||||
//parents have not been imported into the database. For those cases, Libation will
|
|
||||||
//attempt fixup by retrieving parents from the catalog endpoint
|
|
||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
//Convert to c# objects to speed up searching by ID inside the iterator
|
||||||
|
var allItems
|
||||||
|
= v1Cache
|
||||||
|
.Select(i => new
|
||||||
|
{
|
||||||
|
Id = i["Id"]?.Value<string>(),
|
||||||
|
Path = i["Path"]?["Path"]?.Value<string>()
|
||||||
|
}).Where(i => i.Id != null)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
//This migration removes books and series with SERIES_ prefix that were created
|
foreach (var id in allItems.Select(i => i.Id).OfType<string>().Distinct())
|
||||||
//as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2
|
{
|
||||||
string removeHackSeries = "delete " +
|
//Use this opportunity to purge non-existent files and re-classify file types
|
||||||
"from series " +
|
//(due to *.aax files previously not being classified as FileType.AAXC)
|
||||||
"where AudibleSeriesId like 'SERIES%'";
|
var items = allItems
|
||||||
|
.Where(i => i.Id == id && File.Exists(i.Path))
|
||||||
|
.Select(i => new JObject
|
||||||
|
{
|
||||||
|
{ "Id", i.Id },
|
||||||
|
{ "FileType", (int)FileTypes.GetFileTypeFromPath(i.Path) },
|
||||||
|
{ "Path", new JObject{ { "Path", i.Path } } }
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
string removeHackBooks = "delete " +
|
if (items.Length == 0)
|
||||||
"from books " +
|
continue;
|
||||||
"where AudibleProductId like 'SERIES%'";
|
|
||||||
|
|
||||||
//Detect series parents that were added to the database as books with ContentType.Episode,
|
cache[id] = new JArray(items);
|
||||||
//and change them to ContentType.Parent
|
}
|
||||||
string updateContentType =
|
|
||||||
"UPDATE books " +
|
|
||||||
"SET contenttype = 4 " +
|
|
||||||
"WHERE audibleproductid IN (SELECT books.audibleproductid " +
|
|
||||||
"FROM books " +
|
|
||||||
"INNER JOIN series " +
|
|
||||||
"ON ( books.audibleproductid = " +
|
|
||||||
"series.audibleseriesid) " +
|
|
||||||
"WHERE books.contenttype = 2)";
|
|
||||||
|
|
||||||
//Then detect series parents that were added to the database as books with ContentType.Parent
|
var cacheJson = new JObject { { "Dictionary", JObject.FromObject(cache) } };
|
||||||
//but are missing a series link, and add the link (don't know how this happened)
|
var cacheFileText = cacheJson.ToString(Formatting.Indented);
|
||||||
string addMissingSeriesLink =
|
|
||||||
"INSERT INTO seriesbook " +
|
|
||||||
"SELECT series.seriesid, " +
|
|
||||||
"books.bookid, " +
|
|
||||||
"'- 1' " +
|
|
||||||
"FROM books " +
|
|
||||||
"LEFT OUTER JOIN seriesbook " +
|
|
||||||
"ON books.bookid = seriesbook.bookid " +
|
|
||||||
"INNER JOIN series " +
|
|
||||||
"ON books.audibleproductid = series.audibleseriesid " +
|
|
||||||
"WHERE books.contenttype = 4 " +
|
|
||||||
"AND seriesbook.seriesid IS NULL";
|
|
||||||
|
|
||||||
context.Database.ExecuteSqlRaw(removeHackSeries);
|
void migrate()
|
||||||
context.Database.ExecuteSqlRaw(removeHackBooks);
|
{
|
||||||
context.Database.ExecuteSqlRaw(updateContentType);
|
File.WriteAllText(jsonFileV2, cacheFileText);
|
||||||
context.Database.ExecuteSqlRaw(addMissingSeriesLink);
|
File.Delete(jsonFileV1);
|
||||||
|
}
|
||||||
|
|
||||||
LibraryCommands.SaveContext(context);
|
try { migrate(); }
|
||||||
}
|
catch (IOException)
|
||||||
catch (Exception ex)
|
{
|
||||||
{
|
try { migrate(); }
|
||||||
Serilog.Log.Logger.Error(ex, "An error occurred while running database migrations in {0}", nameof(migrate_from_7_10_1));
|
catch (IOException)
|
||||||
config.SetObject($"{nameof(migrate_from_7_10_1)}_ThrewError", true);
|
{
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* eat */ }
|
||||||
}
|
}
|
||||||
|
#nullable restore
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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,29 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace AppScaffolding
|
|
||||||
{
|
|
||||||
public abstract class OSConfigBase
|
|
||||||
{
|
|
||||||
public abstract Type InteropFunctionsType { get; }
|
|
||||||
public virtual Type[] ReferencedTypes { get; } = new Type[0];
|
|
||||||
|
|
||||||
public void Run()
|
|
||||||
{
|
|
||||||
//Each of these types belongs to a different windows-only assembly that's needed by
|
|
||||||
//the WinInterop methods. By referencing these types in main we force the runtime to
|
|
||||||
//load their assemblies before execution reaches inside main. This allows the calling
|
|
||||||
//process to find these assemblies in its module list.
|
|
||||||
_ = ReferencedTypes;
|
|
||||||
_ = InteropFunctionsType;
|
|
||||||
|
|
||||||
//Wait for the calling process to be ready to read the WriteLine()
|
|
||||||
Console.ReadLine();
|
|
||||||
|
|
||||||
// Signal the calling process that execution has reached inside main, and that all referenced assemblies have been loaded.
|
|
||||||
Console.WriteLine();
|
|
||||||
|
|
||||||
// Wait for the calling process to finish reading the process module list, then exit.
|
|
||||||
Console.ReadLine();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
|
using LibationFileManager;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
@@ -25,9 +26,6 @@ namespace AppScaffolding
|
|||||||
: value;
|
: value;
|
||||||
|
|
||||||
#region appsettings.json
|
#region appsettings.json
|
||||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "appsettings.json");
|
|
||||||
|
|
||||||
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);
|
|
||||||
|
|
||||||
public static bool APPSETTINGS_TryGet(string key, out string value)
|
public static bool APPSETTINGS_TryGet(string key, out string value)
|
||||||
{
|
{
|
||||||
@@ -61,11 +59,7 @@ namespace AppScaffolding
|
|||||||
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
|
/// <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)
|
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
|
||||||
{
|
{
|
||||||
// only insert if not exists
|
var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile);
|
||||||
if (!APPSETTINGS_Json_Exists)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
|
|
||||||
|
|
||||||
JObject jObj;
|
JObject jObj;
|
||||||
try
|
try
|
||||||
@@ -88,7 +82,7 @@ namespace AppScaffolding
|
|||||||
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
|
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
File.WriteAllText(APPSETTINGS_JSON, endingContents_indented);
|
File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented);
|
||||||
System.Threading.Thread.Sleep(100);
|
System.Threading.Thread.Sleep(100);
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace AppScaffolding
|
namespace AppScaffolding
|
||||||
{
|
{
|
||||||
public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="30.0.0" />
|
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||||
<PackageReference Include="NPOI" Version="2.5.6" />
|
<PackageReference Include="NPOI" Version="2.7.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DataLayer;
|
||||||
|
using Dinah.Core;
|
||||||
|
using LibationFileManager;
|
||||||
|
|
||||||
|
namespace ApplicationServices
|
||||||
|
{
|
||||||
|
public class BulkSetDownloadStatus
|
||||||
|
{
|
||||||
|
private List<(string message, LiberatedStatus newStatus, IEnumerable<LibraryBook> LibraryBooks)> actionSets { get; } = new();
|
||||||
|
|
||||||
|
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}")}?";
|
||||||
|
|
||||||
|
private List<LibraryBook> _libraryBooks;
|
||||||
|
private bool _setDownloaded;
|
||||||
|
private bool _setNotDownloaded;
|
||||||
|
|
||||||
|
public BulkSetDownloadStatus(List<LibraryBook> libraryBooks, bool setDownloaded, bool setNotDownloaded)
|
||||||
|
{
|
||||||
|
_libraryBooks = libraryBooks;
|
||||||
|
_setDownloaded = setDownloaded;
|
||||||
|
_setNotDownloaded = setNotDownloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Discover()
|
||||||
|
{
|
||||||
|
var bookExistsList = _libraryBooks
|
||||||
|
.Select(libraryBook => new
|
||||||
|
{
|
||||||
|
LibraryBook = libraryBook,
|
||||||
|
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
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 (_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));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Execute()
|
||||||
|
{
|
||||||
|
foreach (var a in actionSets)
|
||||||
|
a.LibraryBooks.UpdateBookStatus(a.newStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -35,6 +35,9 @@ namespace ApplicationServices
|
|||||||
[Name("Title")]
|
[Name("Title")]
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
|
||||||
|
[Name("Subtitle")]
|
||||||
|
public string Subtitle { get; set; }
|
||||||
|
|
||||||
[Name("Authors")]
|
[Name("Authors")]
|
||||||
public string AuthorNames { get; set; }
|
public string AuthorNames { get; set; }
|
||||||
|
|
||||||
@@ -103,7 +106,19 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
[Name("Audio Format")]
|
[Name("Audio Format")]
|
||||||
public string AudioFormat { get; set; }
|
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; }
|
||||||
|
|
||||||
|
[Name("IsFinished")]
|
||||||
|
public bool IsFinished { get; set; }
|
||||||
|
}
|
||||||
public static class LibToDtos
|
public static class LibToDtos
|
||||||
{
|
{
|
||||||
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
||||||
@@ -114,6 +129,7 @@ namespace ApplicationServices
|
|||||||
AudibleProductId = a.Book.AudibleProductId,
|
AudibleProductId = a.Book.AudibleProductId,
|
||||||
Locale = a.Book.Locale,
|
Locale = a.Book.Locale,
|
||||||
Title = a.Book.Title,
|
Title = a.Book.Title,
|
||||||
|
Subtitle = a.Book.Subtitle,
|
||||||
AuthorNames = a.Book.AuthorNames(),
|
AuthorNames = a.Book.AuthorNames(),
|
||||||
NarratorNames = a.Book.NarratorNames(),
|
NarratorNames = a.Book.NarratorNames(),
|
||||||
LengthInMinutes = a.Book.LengthInMinutes,
|
LengthInMinutes = a.Book.LengthInMinutes,
|
||||||
@@ -128,7 +144,7 @@ namespace ApplicationServices
|
|||||||
PictureId = a.Book.PictureId,
|
PictureId = a.Book.PictureId,
|
||||||
IsAbridged = a.Book.IsAbridged,
|
IsAbridged = a.Book.IsAbridged,
|
||||||
DatePublished = a.Book.DatePublished,
|
DatePublished = a.Book.DatePublished,
|
||||||
CategoriesNames = a.Book.CategoriesNames().Any() ? a.Book.CategoriesNames().Aggregate((a, b) => $"{a}, {b}") : "",
|
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
|
||||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
||||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
||||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
||||||
@@ -136,8 +152,12 @@ namespace ApplicationServices
|
|||||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
||||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||||
ContentType = a.Book.ContentType.ToString(),
|
ContentType = a.Book.ContentType.ToString(),
|
||||||
AudioFormat = a.Book.AudioFormat.ToString()
|
AudioFormat = a.Book.AudioFormat.ToString(),
|
||||||
}).ToList();
|
Language = a.Book.Language,
|
||||||
|
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
||||||
|
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
||||||
|
IsFinished = a.Book.UserDefinedItem.IsFinished
|
||||||
|
}).ToList();
|
||||||
}
|
}
|
||||||
public static class LibraryExporter
|
public static class LibraryExporter
|
||||||
{
|
{
|
||||||
@@ -185,6 +205,7 @@ namespace ApplicationServices
|
|||||||
nameof(ExportDto.AudibleProductId),
|
nameof(ExportDto.AudibleProductId),
|
||||||
nameof(ExportDto.Locale),
|
nameof(ExportDto.Locale),
|
||||||
nameof(ExportDto.Title),
|
nameof(ExportDto.Title),
|
||||||
|
nameof(ExportDto.Subtitle),
|
||||||
nameof(ExportDto.AuthorNames),
|
nameof(ExportDto.AuthorNames),
|
||||||
nameof(ExportDto.NarratorNames),
|
nameof(ExportDto.NarratorNames),
|
||||||
nameof(ExportDto.LengthInMinutes),
|
nameof(ExportDto.LengthInMinutes),
|
||||||
@@ -207,8 +228,12 @@ namespace ApplicationServices
|
|||||||
nameof(ExportDto.BookStatus),
|
nameof(ExportDto.BookStatus),
|
||||||
nameof(ExportDto.PdfStatus),
|
nameof(ExportDto.PdfStatus),
|
||||||
nameof(ExportDto.ContentType),
|
nameof(ExportDto.ContentType),
|
||||||
nameof(ExportDto.AudioFormat)
|
nameof(ExportDto.AudioFormat),
|
||||||
};
|
nameof(ExportDto.Language),
|
||||||
|
nameof(ExportDto.LastDownloaded),
|
||||||
|
nameof(ExportDto.LastDownloadedVersion),
|
||||||
|
nameof(ExportDto.IsFinished)
|
||||||
|
};
|
||||||
var col = 0;
|
var col = 0;
|
||||||
foreach (var c in columns)
|
foreach (var c in columns)
|
||||||
{
|
{
|
||||||
@@ -233,13 +258,14 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.Account);
|
row.CreateCell(col++).SetCellValue(dto.Account);
|
||||||
|
|
||||||
var dateAddedCell = row.CreateCell(col++);
|
var dateCell = row.CreateCell(col++);
|
||||||
dateAddedCell.CellStyle = dateStyle;
|
dateCell.CellStyle = dateStyle;
|
||||||
dateAddedCell.SetCellValue(dto.DateAdded);
|
dateCell.SetCellValue(dto.DateAdded);
|
||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Locale);
|
row.CreateCell(col++).SetCellValue(dto.Locale);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Title);
|
row.CreateCell(col++).SetCellValue(dto.Title);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.Subtitle);
|
||||||
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
||||||
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
||||||
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
||||||
@@ -273,9 +299,20 @@ namespace ApplicationServices
|
|||||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||||
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||||
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||||
|
|
||||||
rowIndex++;
|
if (dto.LastDownloaded.HasValue)
|
||||||
|
{
|
||||||
|
dateCell = row.CreateCell(col);
|
||||||
|
dateCell.CellStyle = dateStyle;
|
||||||
|
dateCell.SetCellValue(dto.LastDownloaded.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
|
||||||
|
row.CreateCell(++col).SetCellValue(dto.IsFinished);
|
||||||
|
|
||||||
|
rowIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using AudibleApi.Common;
|
||||||
|
using CsvHelper;
|
||||||
|
using DataLayer;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using NPOI.XSSF.UserModel;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace ApplicationServices
|
||||||
|
{
|
||||||
|
public static class RecordExporter
|
||||||
|
{
|
||||||
|
public static void ToXlsx(string saveFilePath, IEnumerable<IRecord> records)
|
||||||
|
{
|
||||||
|
if (!records.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var workbook = new XSSFWorkbook();
|
||||||
|
var sheet = workbook.CreateSheet("Records");
|
||||||
|
|
||||||
|
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 List<string>
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
row.CreateCell(col++).SetCellValue(annotation.AnnotationId);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (!records.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var recordsEx = extendRecords(records);
|
||||||
|
|
||||||
|
var recordsObj = new JObject
|
||||||
|
{
|
||||||
|
{ "title", libraryBook.Book.TitleWithSubtitle},
|
||||||
|
{ "asin", libraryBook.Book.AudibleProductId},
|
||||||
|
{ "exportTime", DateTime.Now},
|
||||||
|
{ "records", JArray.FromObject(recordsEx) }
|
||||||
|
};
|
||||||
|
|
||||||
|
System.IO.File.WriteAllText(saveFilePath, recordsObj.ToString(Newtonsoft.Json.Formatting.Indented));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ToCsv(string saveFilePath, IEnumerable<IRecord> records)
|
||||||
|
{
|
||||||
|
if (!records.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var writer = new System.IO.StreamWriter(saveFilePath);
|
||||||
|
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
|
//Write headers for the present record type that has the most properties
|
||||||
|
if (records.OfType<Clip>().Any())
|
||||||
|
csv.WriteHeader(typeof(ClipEx));
|
||||||
|
else if (records.OfType<Note>().Any())
|
||||||
|
csv.WriteHeader(typeof(NoteEx));
|
||||||
|
else if (records.OfType<Bookmark>().Any())
|
||||||
|
csv.WriteHeader(typeof(BookmarkEx));
|
||||||
|
else
|
||||||
|
csv.WriteHeader(typeof(LastHeardEx));
|
||||||
|
|
||||||
|
var recordsEx = extendRecords(records);
|
||||||
|
|
||||||
|
csv.NextRecord();
|
||||||
|
csv.WriteRecords(recordsEx.OfType<ClipEx>());
|
||||||
|
csv.WriteRecords(recordsEx.OfType<NoteEx>());
|
||||||
|
csv.WriteRecords(recordsEx.OfType<BookmarkEx>());
|
||||||
|
csv.WriteRecords(recordsEx.OfType<LastHeardEx>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<IRecordEx> extendRecords(IEnumerable<IRecord> records)
|
||||||
|
=> records
|
||||||
|
.Select<IRecord, IRecordEx>(
|
||||||
|
r => r switch
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
public string Type { get; }
|
||||||
|
public LastHeardEx(string type, LastHeard original) : base(original)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record BookmarkEx : Bookmark, IRecordEx
|
||||||
|
{
|
||||||
|
public string Type { get; }
|
||||||
|
public BookmarkEx(string type, Bookmark original) : base(original)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record NoteEx : Note, IRecordEx
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ namespace ApplicationServices
|
|||||||
#region Update
|
#region Update
|
||||||
private static bool isUpdating;
|
private static bool isUpdating;
|
||||||
|
|
||||||
public static void UpdateBooks(IEnumerable<Book> books)
|
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.
|
// 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
|
// I did not benchmark before choosing the number here
|
||||||
@@ -43,23 +43,20 @@ namespace ApplicationServices
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
foreach (var book in books)
|
foreach (var book in books)
|
||||||
{
|
UpdateUserDefinedItems(book);
|
||||||
UpdateLiberatedStatus(book);
|
|
||||||
UpdateBookTags(book);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void FullReIndex() => performSafeCommand(e =>
|
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
||||||
fullReIndex(e)
|
public static void FullReIndex(List<LibraryBook> libraryBooks)
|
||||||
);
|
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
|
||||||
|
|
||||||
internal static void UpdateLiberatedStatus(Book book) => performSafeCommand(e =>
|
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
|
||||||
e.UpdateLiberatedStatus(book)
|
{
|
||||||
);
|
e.UpdateLiberatedStatus(book);
|
||||||
|
e.UpdateTags(book.Book.AudibleProductId, book.Book.UserDefinedItem.Tags);
|
||||||
internal static void UpdateBookTags(Book book) => performSafeCommand(e =>
|
e.UpdateUserRatings(book);
|
||||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
private static void performSafeCommand(Action<SearchEngine> action)
|
private static void performSafeCommand(Action<SearchEngine> action)
|
||||||
@@ -87,7 +84,6 @@ namespace ApplicationServices
|
|||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|
||||||
action(new SearchEngine());
|
action(new SearchEngine());
|
||||||
|
|
||||||
if (!prevIsUpdating)
|
if (!prevIsUpdating)
|
||||||
SearchEngineUpdated?.Invoke(null, null);
|
SearchEngineUpdated?.Invoke(null, null);
|
||||||
}
|
}
|
||||||
@@ -100,8 +96,11 @@ namespace ApplicationServices
|
|||||||
private static void fullReIndex(SearchEngine engine)
|
private static void fullReIndex(SearchEngine engine)
|
||||||
{
|
{
|
||||||
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
||||||
engine.CreateNewIndex(library);
|
fullReIndex(engine, library);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
|
||||||
|
=> engine.CreateNewIndex(libraryBooks);
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading.Channels;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System.Diagnostics;
|
||||||
using AudibleApi;
|
using AudibleApi;
|
||||||
using AudibleApi.Common;
|
using AudibleApi.Common;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using Newtonsoft.Json.Converters;
|
|
||||||
using Polly;
|
using Polly;
|
||||||
using Polly.Retry;
|
using Polly.Retry;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
namespace AudibleUtilities
|
namespace AudibleUtilities
|
||||||
{
|
{
|
||||||
@@ -19,6 +18,9 @@ namespace AudibleUtilities
|
|||||||
{
|
{
|
||||||
public Api Api { get; private set; }
|
public Api Api { get; private set; }
|
||||||
|
|
||||||
|
private const int MaxConcurrency = 10;
|
||||||
|
private const int BatchSize = 50;
|
||||||
|
|
||||||
private ApiExtended(Api api) => Api = api;
|
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>
|
/// <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>
|
||||||
@@ -39,42 +41,6 @@ namespace AudibleUtilities
|
|||||||
return new ApiExtended(api);
|
return new ApiExtended(api);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get api from existing tokens else login with native api callbacks.</summary>
|
|
||||||
public static async Task<ApiExtended> CreateAsync(Account account, ILoginCallback loginCallback)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
|
||||||
{
|
|
||||||
LoginType = nameof(ILoginCallback),
|
|
||||||
Account = account?.MaskedLogEntry ?? "[null]",
|
|
||||||
LocaleName = account?.Locale?.Name
|
|
||||||
});
|
|
||||||
|
|
||||||
var api = await EzApiCreator.GetApiAsync(
|
|
||||||
loginCallback,
|
|
||||||
account.Locale,
|
|
||||||
AudibleApiStorage.AccountsSettingsFile,
|
|
||||||
account.GetIdentityTokensJsonPath());
|
|
||||||
return new ApiExtended(api);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Get api from existing tokens else login with external browser</summary>
|
|
||||||
public static async Task<ApiExtended> CreateAsync(Account account, ILoginExternal loginExternal)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
|
||||||
{
|
|
||||||
LoginType = nameof(ILoginExternal),
|
|
||||||
Account = account?.MaskedLogEntry ?? "[null]",
|
|
||||||
LocaleName = account?.Locale?.Name
|
|
||||||
});
|
|
||||||
|
|
||||||
var api = await EzApiCreator.GetApiAsync(
|
|
||||||
loginExternal,
|
|
||||||
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>
|
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
||||||
public static async Task<ApiExtended> CreateAsync(Account account)
|
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||||
{
|
{
|
||||||
@@ -121,226 +87,205 @@ namespace AudibleUtilities
|
|||||||
|
|
||||||
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
|
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
|
||||||
{
|
{
|
||||||
var items = new List<Item>();
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Beginning library scan.");
|
Serilog.Log.Logger.Debug("Beginning library scan.");
|
||||||
|
|
||||||
List<Task<List<Item>>> getChildEpisodesTasks = new();
|
List<Item> items = new();
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var totalTime = TimeSpan.Zero;
|
||||||
|
using var semaphore = new SemaphoreSlim(MaxConcurrency);
|
||||||
|
|
||||||
int count = 0, maxConcurrentEpisodeScans = 5;
|
var episodeChannel = Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
||||||
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
|
var batchReaderTask = readAllAsinsAsync(episodeChannel.Reader, semaphore);
|
||||||
|
|
||||||
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
|
//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 ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
|
if (importEpisodes)
|
||||||
{
|
{
|
||||||
//Get child episodes asynchronously and await all at the end
|
var episodes = item.Where(i => i.IsEpisodes).ToList();
|
||||||
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
|
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);
|
||||||
}
|
}
|
||||||
else if (!item.IsEpisodes && !item.IsSeriesParent)
|
|
||||||
items.Add(item);
|
|
||||||
|
|
||||||
count++;
|
items.AddRange(item.Where(i => !i.IsSeriesParent && !i.IsEpisodes));
|
||||||
}
|
}
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
|
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();
|
||||||
|
|
||||||
//await and add all episodes from all parents
|
//Signal that we're done adding asins
|
||||||
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
|
episodeChannel.Writer.Complete();
|
||||||
items.AddRange(epList);
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Completed library scan.");
|
//Wait for all episodes/parents to be retrived
|
||||||
|
var allEps = await batchReaderTask;
|
||||||
|
|
||||||
#if DEBUG
|
sw.Stop();
|
||||||
//// this will not work for multi accounts
|
totalTime += sw.Elapsed;
|
||||||
//var library_json = "library.json";
|
Serilog.Log.Logger.Debug("Episode scan complete after {elappsed_ms} ms. Found {count} episodes and series .", sw.ElapsedMilliseconds, allEps.Count);
|
||||||
//library_json = System.IO.Path.GetFullPath(library_json);
|
sw.Restart();
|
||||||
//if (System.IO.File.Exists(library_json))
|
|
||||||
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
|
Serilog.Log.Logger.Debug("Begin indexing series episodes");
|
||||||
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
|
items.AddRange(allEps);
|
||||||
#endif
|
|
||||||
var validators = new List<IValidator>();
|
//Set the Item.Series info for episodes and parents.
|
||||||
validators.AddRange(getValidators());
|
foreach (var parent in items.Where(i => i.IsSeriesParent))
|
||||||
foreach (var v in validators)
|
|
||||||
{
|
{
|
||||||
var exceptions = v.Validate(items);
|
var children = items.Where(i => i.IsEpisodes && i.Relationships.Any(r => r.Asin == parent.Asin));
|
||||||
if (exceptions is not null && exceptions.Any())
|
SetSeries(parent, children);
|
||||||
throw new AggregateException(exceptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<IValidator> getValidators()
|
|
||||||
{
|
|
||||||
var type = typeof(IValidator);
|
|
||||||
var types = AppDomain.CurrentDomain.GetAssemblies()
|
|
||||||
.SelectMany(s => s.GetTypes())
|
|
||||||
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
|
|
||||||
|
|
||||||
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
#region episodes and podcasts
|
#region episodes and podcasts
|
||||||
|
|
||||||
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
|
/// <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)
|
||||||
{
|
{
|
||||||
await concurrencySemaphore.WaitAsync();
|
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. Explude 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
|
try
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
|
var sw = Stopwatch.StartNew();
|
||||||
|
var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media
|
||||||
|
| CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc
|
||||||
|
| CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview
|
||||||
|
| CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series
|
||||||
|
| CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
List<Item> children;
|
Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms");
|
||||||
|
|
||||||
if (parent.IsEpisodes)
|
return items;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new { asins });
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
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[]
|
||||||
|
{
|
||||||
|
new Series
|
||||||
{
|
{
|
||||||
//The 'parent' is a single episode that was added to the library.
|
Asin = parent.Asin,
|
||||||
//Get the episode's parent and add it to the database.
|
Sequence = "-1",
|
||||||
|
Title = parent.TitleWithSubtitle
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
|
if (parent.PurchaseDate == default)
|
||||||
|
{
|
||||||
|
parent.PurchaseDate = children.Select(c => c.PurchaseDate).Order().FirstOrDefault(d => d != default);
|
||||||
|
|
||||||
children = new() { parent };
|
if (parent.PurchaseDate == default)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Warning("{series} doesn't have a purchase date. Using UtcNow", parent);
|
||||||
|
parent.PurchaseDate = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var parentAsins = parent.Relationships
|
int lastEpNum = -1, dupeCount = 0;
|
||||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime))
|
||||||
.Select(p => p.Asin);
|
{
|
||||||
|
string sequence;
|
||||||
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
if (child.EpisodeNumber is null)
|
||||||
|
{
|
||||||
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
|
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||||
if (numSeriesParents != 1)
|
sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0";
|
||||||
{
|
|
||||||
//There should only ever be 1 top-level parent per episode. If not, log
|
|
||||||
//so we can figure out what to do about those special cases, and don't
|
|
||||||
//import the episode.
|
|
||||||
JsonSerializerSettings Settings = new()
|
|
||||||
{
|
|
||||||
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
|
|
||||||
DateParseHandling = DateParseHandling.None,
|
|
||||||
Converters =
|
|
||||||
{
|
|
||||||
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Serilog.Log.Logger.Error($"Found {numSeriesParents} parents for {parent.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
|
|
||||||
return new List<Item>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var realParent = seriesParents.Single(p => p.IsSeriesParent);
|
|
||||||
realParent.PurchaseDate = parent.PurchaseDate;
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
|
|
||||||
parent = realParent;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
children = await getEpisodeChildrenAsync(parent);
|
//multipart episodes may have the same episode number
|
||||||
if (!children.Any())
|
if (child.EpisodeNumber == lastEpNum)
|
||||||
return new();
|
dupeCount++;
|
||||||
|
else
|
||||||
|
lastEpNum = child.EpisodeNumber.Value;
|
||||||
|
|
||||||
|
sequence = (lastEpNum + dupeCount).ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
//A series parent will always have exactly 1 Series
|
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
|
||||||
parent.Series = new Series[]
|
child.PurchaseDate = parent.PurchaseDate;
|
||||||
|
// parent is essentially a series
|
||||||
|
child.Series = new[]
|
||||||
{
|
{
|
||||||
new Series
|
new Series
|
||||||
{
|
{
|
||||||
Asin = parent.Asin,
|
Asin = parent.Asin,
|
||||||
Sequence = "-1",
|
Sequence = sequence,
|
||||||
Title = parent.TitleWithSubtitle
|
Title = parent.TitleWithSubtitle
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var child in children)
|
|
||||||
{
|
|
||||||
// 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 Series[]
|
|
||||||
{
|
|
||||||
new Series
|
|
||||||
{
|
|
||||||
Asin = parent.Asin,
|
|
||||||
// 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",
|
|
||||||
Title = parent.TitleWithSubtitle
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
children.Add(parent);
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
|
|
||||||
|
|
||||||
return children;
|
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
concurrencySemaphore.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
|
|
||||||
{
|
|
||||||
var childrenIds = parent.Relationships
|
|
||||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
|
|
||||||
.Select(r => r.Asin)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// fetch children in batches
|
|
||||||
const int batchSize = 20;
|
|
||||||
|
|
||||||
var results = new List<Item>();
|
|
||||||
|
|
||||||
for (var i = 1; ; i++)
|
|
||||||
{
|
|
||||||
var idBatch = childrenIds.Skip((i - 1) * batchSize).Take(batchSize).ToList();
|
|
||||||
if (!idBatch.Any())
|
|
||||||
break;
|
|
||||||
|
|
||||||
List<Item> childrenBatch;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
childrenBatch = await Api.GetCatalogProductsAsync(idBatch, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
|
||||||
#if DEBUG
|
|
||||||
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
|
|
||||||
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
|
|
||||||
{
|
|
||||||
ParentId = parent.Asin,
|
|
||||||
ParentTitle = parent.Title,
|
|
||||||
BatchNumber = i,
|
|
||||||
ChildIdBatch = idBatch
|
|
||||||
});
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results\t({{parent}})", parent);
|
|
||||||
// the service returned no results. probably indicates an error. stop running batches
|
|
||||||
if (!childrenBatch.Any())
|
|
||||||
break;
|
|
||||||
|
|
||||||
results.AddRange(childrenBatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Parent episodes/podcasts series. Children found. {@DebugInfo}", new
|
|
||||||
{
|
|
||||||
ParentId = parent.Asin,
|
|
||||||
ParentTitle = parent.Title,
|
|
||||||
ChildCount = childrenIds.Count
|
|
||||||
});
|
|
||||||
|
|
||||||
if (childrenIds.Count != results.Count)
|
|
||||||
{
|
|
||||||
var ex = new ApplicationException($"Mis-match: Children defined by parent={childrenIds.Count}. Children returned by batches={results.Count}");
|
|
||||||
Serilog.Log.Logger.Error(ex, "{parent} - Quantity of series episodes defined by parent does not match quantity returned by batch fetching.", parent);
|
|
||||||
throw ex;
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,58 @@ using Newtonsoft.Json;
|
|||||||
|
|
||||||
namespace AudibleUtilities
|
namespace AudibleUtilities
|
||||||
{
|
{
|
||||||
|
public class AccountSettingsLoadErrorEventArgs : ErrorEventArgs
|
||||||
|
{
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
SettingsFilePath = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static class AudibleApiStorage
|
public static class AudibleApiStorage
|
||||||
{
|
{
|
||||||
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
|
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
|
||||||
|
|
||||||
|
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;
|
||||||
|
|
||||||
public static void EnsureAccountsSettingsFileExists()
|
public static void EnsureAccountsSettingsFileExists()
|
||||||
{
|
{
|
||||||
// saves. BEWARE: this will overwrite an existing file
|
// saves. BEWARE: this will overwrite an existing file
|
||||||
if (!File.Exists(AccountsSettingsFile))
|
if (!File.Exists(AccountsSettingsFile))
|
||||||
_ = new AccountsSettingsPersister(new AccountsSettings(), 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>
|
/// <summary>If you use this, be a good citizen and DISPOSE of it</summary>
|
||||||
public static AccountsSettingsPersister GetAccountsSettingsPersister() => new AccountsSettingsPersister(AccountsSettingsFile);
|
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)
|
public static string GetIdentityTokensJsonPath(this Account account)
|
||||||
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
|
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
|
||||||
|
|||||||
@@ -8,7 +8,18 @@ namespace AudibleUtilities
|
|||||||
public interface IValidator
|
public interface IValidator
|
||||||
{
|
{
|
||||||
IEnumerable<Exception> Validate(IEnumerable<Item> items);
|
IEnumerable<Exception> Validate(IEnumerable<Item> items);
|
||||||
|
|
||||||
|
public static IValidator[] GetAllValidators()
|
||||||
|
=> new IValidator[]
|
||||||
|
{
|
||||||
|
new LibraryValidator(),
|
||||||
|
new BookValidator(),
|
||||||
|
new CategoryValidator(),
|
||||||
|
new ContributorValidator(),
|
||||||
|
new SeriesValidator(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LibraryValidator : IValidator
|
public class LibraryValidator : IValidator
|
||||||
{
|
{
|
||||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
||||||
@@ -79,8 +90,10 @@ namespace AudibleUtilities
|
|||||||
var distinct = items.GetSeriesDistinct();
|
var distinct = items.GetSeriesDistinct();
|
||||||
if (distinct.Any(s => s.SeriesId is null))
|
if (distinct.Any(s => s.SeriesId is null))
|
||||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
|
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)));
|
//// unfortunately, a 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;
|
return exceptions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AudibleApi" Version="4.6.6.1" />
|
<PackageReference Include="AudibleApi" Version="9.3.2.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using AudibleApi.Common;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AudibleUtilities
|
||||||
|
{
|
||||||
|
public class ImportValidationException : AggregateException
|
||||||
|
{
|
||||||
|
public List<Item> Items { get; }
|
||||||
|
public ImportValidationException(List<Item> items, IEnumerable<Exception> exceptions) : base(exceptions)
|
||||||
|
{
|
||||||
|
Items = items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AudibleApi;
|
using AudibleApi;
|
||||||
using AudibleApi.Authorization;
|
using AudibleApi.Authorization;
|
||||||
|
using AudibleApi.Cryptography;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
@@ -42,6 +43,9 @@ namespace AudibleUtilities
|
|||||||
[JsonProperty("locale_code")]
|
[JsonProperty("locale_code")]
|
||||||
public string LocaleCode { get; private set; }
|
public string LocaleCode { get; private set; }
|
||||||
|
|
||||||
|
[JsonProperty("with_username")]
|
||||||
|
public bool WithUsername { get; private set; }
|
||||||
|
|
||||||
[JsonProperty("activation_bytes")]
|
[JsonProperty("activation_bytes")]
|
||||||
public string ActivationBytes { get; private set; }
|
public string ActivationBytes { get; private set; }
|
||||||
|
|
||||||
@@ -67,7 +71,8 @@ namespace AudibleUtilities
|
|||||||
}
|
}
|
||||||
|
|
||||||
[JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime();
|
[JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime();
|
||||||
[JsonIgnore] public Locale Locale => Localization.Get(LocaleCode);
|
[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 DeviceSerialNumber => DeviceInfo.DeviceSerialNumber;
|
||||||
[JsonIgnore] public string DeviceType => DeviceInfo.DeviceType;
|
[JsonIgnore] public string DeviceType => DeviceInfo.DeviceType;
|
||||||
[JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId;
|
[JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId;
|
||||||
@@ -176,9 +181,10 @@ namespace AudibleUtilities
|
|||||||
DevicePrivateKey = account.IdentityTokens.PrivateKey,
|
DevicePrivateKey = account.IdentityTokens.PrivateKey,
|
||||||
AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires,
|
AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires,
|
||||||
LocaleCode = account.Locale.CountryCode,
|
LocaleCode = account.Locale.CountryCode,
|
||||||
|
WithUsername = account.Locale.WithUsername,
|
||||||
RefreshToken = account.IdentityTokens.RefreshToken.Value,
|
RefreshToken = account.IdentityTokens.RefreshToken.Value,
|
||||||
StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie,
|
StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie,
|
||||||
WebsiteCookies = new(account.IdentityTokens.Cookies.ToKeyValuePair()),
|
WebsiteCookies = new(account.IdentityTokens.Cookies),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DataLayer.Configurations
|
||||||
|
{
|
||||||
|
internal class BookCategoryConfig : IEntityTypeConfiguration<BookCategory>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<BookCategory> entity)
|
||||||
|
{
|
||||||
|
entity.HasKey(bc => new { bc.BookId, bc.CategoryLadderId });
|
||||||
|
|
||||||
|
entity.HasIndex(bc => bc.BookId);
|
||||||
|
entity.HasIndex(bc => bc.CategoryLadderId);
|
||||||
|
|
||||||
|
entity
|
||||||
|
.HasOne(bc => bc.Book)
|
||||||
|
.WithMany(b => b.CategoriesLink)
|
||||||
|
.HasForeignKey(bc => bc.BookId);
|
||||||
|
|
||||||
|
entity
|
||||||
|
.HasOne(bc => bc.CategoryLadder)
|
||||||
|
.WithMany(c => c.BooksLink)
|
||||||
|
.HasForeignKey(bc => bc.CategoryLadderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace DataLayer.Configurations
|
namespace DataLayer.Configurations
|
||||||
{
|
{
|
||||||
@@ -19,55 +20,41 @@ namespace DataLayer.Configurations
|
|||||||
entity.Ignore(nameof(Book.Authors));
|
entity.Ignore(nameof(Book.Authors));
|
||||||
entity.Ignore(nameof(Book.Narrators));
|
entity.Ignore(nameof(Book.Narrators));
|
||||||
entity.Ignore(nameof(Book.AudioFormat));
|
entity.Ignore(nameof(Book.AudioFormat));
|
||||||
//// these don't seem to matter
|
entity.Ignore(nameof(Book.TitleWithSubtitle));
|
||||||
//entity.Ignore(nameof(Book.AuthorNames));
|
entity.Ignore(b => b.Categories);
|
||||||
//entity.Ignore(nameof(Book.NarratorNames));
|
|
||||||
//entity.Ignore(nameof(Book.HasPdfs));
|
|
||||||
|
|
||||||
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
|
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
|
||||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
|
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
|
||||||
entity
|
entity
|
||||||
.OwnsMany(b => b.Supplements, b_s =>
|
.OwnsMany(b => b.Supplements, b_s =>
|
||||||
{
|
{
|
||||||
b_s.WithOwner(s => s.Book)
|
b_s.WithOwner(s => s.Book)
|
||||||
.HasForeignKey(s => s.BookId);
|
.HasForeignKey(s => s.BookId);
|
||||||
b_s.HasKey(s => s.SupplementId);
|
b_s.HasKey(s => s.SupplementId);
|
||||||
});
|
});
|
||||||
// even though it's owned, we need to map its backing field
|
// even though it's owned, we need to map its backing field
|
||||||
entity
|
entity
|
||||||
.Metadata
|
.Metadata
|
||||||
.FindNavigation(nameof(Book.Supplements))
|
.FindNavigation(nameof(Book.Supplements))
|
||||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||||
|
|
||||||
// owns it 1:1, store in separate table
|
// owns it 1:1, store in separate table
|
||||||
entity
|
|
||||||
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
|
||||||
{
|
|
||||||
b_udi.WithOwner(udi => udi.Book)
|
|
||||||
.HasForeignKey(udi => udi.BookId);
|
|
||||||
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
|
||||||
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
|
||||||
|
|
||||||
// owns it 1:1, store in same table
|
|
||||||
b_udi.OwnsOne(udi => udi.Rating);
|
|
||||||
});
|
|
||||||
|
|
||||||
entity
|
|
||||||
.Metadata
|
|
||||||
.FindNavigation(nameof(Book.ContributorsLink))
|
|
||||||
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
|
|
||||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
|
||||||
|
|
||||||
entity
|
entity
|
||||||
.Metadata
|
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
||||||
.FindNavigation(nameof(Book.SeriesLink))
|
{
|
||||||
// PropertyAccessMode.Field : Series is a get-only property, not a field, so use its backing field
|
b_udi.WithOwner(udi => udi.Book)
|
||||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
.HasForeignKey(udi => udi.BookId);
|
||||||
|
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
||||||
|
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
||||||
|
|
||||||
entity
|
b_udi.Property(udi => udi.LastDownloaded);
|
||||||
.HasOne(b => b.Category)
|
b_udi
|
||||||
.WithMany()
|
.Property(udi => udi.LastDownloadedVersion)
|
||||||
.HasForeignKey(b => b.CategoryId);
|
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
|
||||||
}
|
|
||||||
|
// owns it 1:1, store in same table
|
||||||
|
b_udi.OwnsOne(udi => udi.Rating);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,12 @@ namespace DataLayer.Configurations
|
|||||||
{
|
{
|
||||||
entity.HasKey(c => c.CategoryId);
|
entity.HasKey(c => c.CategoryId);
|
||||||
entity.HasIndex(c => c.AudibleCategoryId);
|
entity.HasIndex(c => c.AudibleCategoryId);
|
||||||
|
|
||||||
|
entity.Ignore(c => c.CategoryLadders);
|
||||||
|
|
||||||
|
entity
|
||||||
|
.HasMany(e => e._categoryLadders)
|
||||||
|
.WithMany(e => e._categories);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DataLayer.Configurations
|
||||||
|
{
|
||||||
|
internal class CategoryLadderConfig : IEntityTypeConfiguration<CategoryLadder>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<CategoryLadder> entity)
|
||||||
|
{
|
||||||
|
entity.HasKey(cl => cl.CategoryLadderId);
|
||||||
|
|
||||||
|
entity.Ignore(cl => cl.Categories);
|
||||||
|
|
||||||
|
entity
|
||||||
|
.HasMany(cl => cl._categories)
|
||||||
|
.WithMany(c => c._categoryLadders);
|
||||||
|
|
||||||
|
entity
|
||||||
|
.Metadata
|
||||||
|
.FindNavigation(nameof(CategoryLadder.BooksLink))
|
||||||
|
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ namespace DataLayer.Configurations
|
|||||||
.Metadata
|
.Metadata
|
||||||
.FindNavigation(nameof(Contributor.BooksLink))
|
.FindNavigation(nameof(Contributor.BooksLink))
|
||||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||||
|
|
||||||
|
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
|
||||||
|
entity.HasData(Contributor.GetEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -10,14 +10,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="5.3.0.1" />
|
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
||||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="5.0.2.1" />
|
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.10">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.10" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.10">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.3">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ namespace DataLayer
|
|||||||
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
|
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
|
||||||
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
|
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
|
||||||
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
|
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
|
||||||
}
|
AAX_22_32 = LC_32_22050_stereo,
|
||||||
|
AAX_22_64 = LC_64_22050_stereo,
|
||||||
|
AAX_44_64 = LC_64_44100_stereo,
|
||||||
|
AAX_44_128 = LC_128_44100_stereo
|
||||||
|
}
|
||||||
|
|
||||||
public class AudioFormat : IComparable<AudioFormat>, IComparable
|
public class AudioFormat : IComparable<AudioFormat>, IComparable
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -34,6 +35,9 @@ namespace DataLayer
|
|||||||
// immutable
|
// immutable
|
||||||
public string AudibleProductId { get; private set; }
|
public string AudibleProductId { get; private set; }
|
||||||
public string Title { get; private set; }
|
public string Title { get; private set; }
|
||||||
|
public string Subtitle { get; private set; }
|
||||||
|
private string _titleWithSubtitle;
|
||||||
|
public string TitleWithSubtitle => _titleWithSubtitle ??= string.IsNullOrEmpty(Subtitle) ? Title : $"{Title}: {Subtitle}";
|
||||||
public string Description { get; private set; }
|
public string Description { get; private set; }
|
||||||
public int LengthInMinutes { get; private set; }
|
public int LengthInMinutes { get; private set; }
|
||||||
public ContentType ContentType { get; private set; }
|
public ContentType ContentType { get; private set; }
|
||||||
@@ -50,10 +54,7 @@ namespace DataLayer
|
|||||||
// book details
|
// book details
|
||||||
public bool IsAbridged { get; private set; }
|
public bool IsAbridged { get; private set; }
|
||||||
public DateTime? DatePublished { get; private set; }
|
public DateTime? DatePublished { get; private set; }
|
||||||
|
public string Language { get; private set; }
|
||||||
// non-null. use "empty pattern"
|
|
||||||
internal int CategoryId { get; private set; }
|
|
||||||
public Category Category { get; private set; }
|
|
||||||
|
|
||||||
// is owned, not optional 1:1
|
// is owned, not optional 1:1
|
||||||
public UserDefinedItem UserDefinedItem { get; private set; }
|
public UserDefinedItem UserDefinedItem { get; private set; }
|
||||||
@@ -69,12 +70,12 @@ namespace DataLayer
|
|||||||
public Book(
|
public Book(
|
||||||
AudibleProductId audibleProductId,
|
AudibleProductId audibleProductId,
|
||||||
string title,
|
string title,
|
||||||
|
string subtitle,
|
||||||
string description,
|
string description,
|
||||||
int lengthInMinutes,
|
int lengthInMinutes,
|
||||||
ContentType contentType,
|
ContentType contentType,
|
||||||
IEnumerable<Contributor> authors,
|
IEnumerable<Contributor> authors,
|
||||||
IEnumerable<Contributor> narrators,
|
IEnumerable<Contributor> narrators,
|
||||||
Category category,
|
|
||||||
string localeName)
|
string localeName)
|
||||||
{
|
{
|
||||||
// validate
|
// validate
|
||||||
@@ -82,7 +83,7 @@ namespace DataLayer
|
|||||||
var productId = audibleProductId.Id;
|
var productId = audibleProductId.Id;
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
|
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
|
||||||
|
|
||||||
// assign as soon as possible. stuff below relies on this
|
// assign as soon as possible. stuff below relies on this
|
||||||
AudibleProductId = productId;
|
AudibleProductId = productId;
|
||||||
Locale = localeName;
|
Locale = localeName;
|
||||||
|
|
||||||
@@ -90,14 +91,13 @@ namespace DataLayer
|
|||||||
|
|
||||||
// non-ef-ctor init.s
|
// non-ef-ctor init.s
|
||||||
UserDefinedItem = new UserDefinedItem(this);
|
UserDefinedItem = new UserDefinedItem(this);
|
||||||
_contributorsLink = new HashSet<BookContributor>();
|
ContributorsLink = new HashSet<BookContributor>();
|
||||||
|
CategoriesLink = new HashSet<BookCategory>();
|
||||||
_seriesLink = new HashSet<SeriesBook>();
|
_seriesLink = new HashSet<SeriesBook>();
|
||||||
_supplements = new HashSet<Supplement>();
|
_supplements = new HashSet<Supplement>();
|
||||||
|
|
||||||
Category = category;
|
|
||||||
|
|
||||||
// simple assigns
|
// simple assigns
|
||||||
Title = title.Trim() ?? "";
|
UpdateTitle(title, subtitle);
|
||||||
Description = description?.Trim() ?? "";
|
Description = description?.Trim() ?? "";
|
||||||
LengthInMinutes = lengthInMinutes;
|
LengthInMinutes = lengthInMinutes;
|
||||||
ContentType = contentType;
|
ContentType = contentType;
|
||||||
@@ -105,22 +105,24 @@ namespace DataLayer
|
|||||||
// assigns with biz logic
|
// assigns with biz logic
|
||||||
ReplaceAuthors(authors);
|
ReplaceAuthors(authors);
|
||||||
ReplaceNarrators(narrators);
|
ReplaceNarrators(narrators);
|
||||||
}
|
}
|
||||||
|
|
||||||
#region contributors, authors, narrators
|
public void UpdateTitle(string title, string subtitle)
|
||||||
// use uninitialised backing fields - this means we can detect if the collection was loaded
|
{
|
||||||
private HashSet<BookContributor> _contributorsLink;
|
Title = title?.Trim() ?? "";
|
||||||
// i'd like this to be internal but migration throws this exception when i try:
|
Subtitle = subtitle?.Trim() ?? "";
|
||||||
// Value cannot be null.
|
_titleWithSubtitle = null;
|
||||||
// Parameter name: property
|
}
|
||||||
public IEnumerable<BookContributor> ContributorsLink
|
|
||||||
=> _contributorsLink?
|
|
||||||
.OrderBy(bc => bc.Order)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
public IEnumerable<Contributor> Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList();
|
public void UpdateLengthInMinutes(int lengthInMinutes)
|
||||||
public IEnumerable<Contributor> Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList();
|
=> LengthInMinutes = lengthInMinutes;
|
||||||
public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name;
|
|
||||||
|
#region contributors, authors, narrators
|
||||||
|
internal HashSet<BookContributor> ContributorsLink { get; private set; }
|
||||||
|
|
||||||
|
public IEnumerable<Contributor> Authors => ContributorsLink.ByRole(Role.Author).Select(bc => bc.Contributor).ToList();
|
||||||
|
public IEnumerable<Contributor> Narrators => ContributorsLink.ByRole(Role.Narrator).Select(bc => bc.Contributor).ToList();
|
||||||
|
public string Publisher => ContributorsLink.ByRole(Role.Publisher).SingleOrDefault()?.Contributor.Name;
|
||||||
|
|
||||||
public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext context = null)
|
public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext context = null)
|
||||||
=> replaceContributors(authors, Role.Author, context);
|
=> replaceContributors(authors, Role.Author, context);
|
||||||
@@ -133,47 +135,70 @@ namespace DataLayer
|
|||||||
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
|
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
|
||||||
|
|
||||||
// the edge cases of doing local-loaded vs remote-only got weird. just load it
|
// the edge cases of doing local-loaded vs remote-only got weird. just load it
|
||||||
if (_contributorsLink is null)
|
if (ContributorsLink is null)
|
||||||
getEntry(context).Collection(s => s.ContributorsLink).Load();
|
getEntry(context).Collection(s => s.ContributorsLink).Load();
|
||||||
|
|
||||||
|
var isIdentical
|
||||||
|
= ContributorsLink
|
||||||
|
.ByRole(role)
|
||||||
|
.Select(c => c.Contributor)
|
||||||
|
.SequenceEqual(newContributors);
|
||||||
|
|
||||||
var roleContributions = getContributions(role);
|
|
||||||
var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors);
|
|
||||||
if (isIdentical)
|
if (isIdentical)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_contributorsLink.RemoveWhere(bc => bc.Role == role);
|
ContributorsLink.RemoveWhere(bc => bc.Role == role);
|
||||||
addNewContributors(newContributors, role);
|
addNewContributors(newContributors, role);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
|
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
|
||||||
{
|
{
|
||||||
byte order = 0;
|
byte order = 0;
|
||||||
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
|
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
|
||||||
var newContributions = new HashSet<BookContributor>(newContributionsEnum);
|
var newContributions = new HashSet<BookContributor>(newContributionsEnum);
|
||||||
_contributorsLink.UnionWith(newContributions);
|
ContributorsLink.UnionWith(newContributions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<BookContributor> getContributions(Role role)
|
|
||||||
=> ContributorsLink
|
|
||||||
.Where(a => a.Role == role)
|
|
||||||
.OrderBy(a => a.Order)
|
|
||||||
.ToList();
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
|
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
|
||||||
|
{
|
||||||
|
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||||
|
|
||||||
|
var entry = context.Entry(this);
|
||||||
|
|
||||||
|
if (!entry.IsKeySet)
|
||||||
|
throw new InvalidOperationException("Could not load a valid Book from database");
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region categories
|
||||||
|
internal HashSet<BookCategory> CategoriesLink { get; private set; }
|
||||||
|
|
||||||
|
private ReadOnlyCollection<BookCategory> _categoriesReadOnly;
|
||||||
|
public ReadOnlyCollection<BookCategory> Categories
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_categoriesReadOnly?.SequenceEqual(CategoriesLink) is not true)
|
||||||
|
_categoriesReadOnly = CategoriesLink.ToList().AsReadOnly();
|
||||||
|
return _categoriesReadOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public void SetCategoryLadders(IEnumerable<CategoryLadder> ladders)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
ArgumentValidator.EnsureNotNull(ladders, nameof(ladders));
|
||||||
|
|
||||||
var entry = context.Entry(this);
|
//Replace all existing category ladders.
|
||||||
|
//Some books make have duplicate ladders
|
||||||
if (!entry.IsKeySet)
|
CategoriesLink.Clear();
|
||||||
throw new InvalidOperationException("Could not load a valid Book from database");
|
CategoriesLink.UnionWith(ladders.Distinct().Select(l => new BookCategory(this, l)));
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region series
|
#region series
|
||||||
private HashSet<SeriesBook> _seriesLink;
|
private HashSet<SeriesBook> _seriesLink;
|
||||||
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
|
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
|
||||||
|
|
||||||
public void UpsertSeries(Series series, string order, DbContext context = null)
|
public void UpsertSeries(Series series, string order, DbContext context = null)
|
||||||
@@ -215,22 +240,14 @@ namespace DataLayer
|
|||||||
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
||||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
=> Rating.Update(overallRating, performanceRating, storyRating);
|
||||||
|
|
||||||
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished)
|
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished, string language)
|
||||||
{
|
{
|
||||||
// don't overwrite with default values
|
// don't overwrite with default values
|
||||||
IsAbridged |= isAbridged;
|
IsAbridged |= isAbridged;
|
||||||
DatePublished = datePublished ?? DatePublished;
|
DatePublished = datePublished ?? DatePublished;
|
||||||
|
Language = language?.FirstCharToUpper() ?? Language;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateCategory(Category category, DbContext context = null)
|
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
|
||||||
{
|
|
||||||
// since category is never null, nullity means it hasn't been loaded
|
|
||||||
if (Category is null)
|
|
||||||
getEntry(context).Reference(s => s.Category).Load();
|
|
||||||
|
|
||||||
Category = category;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => $"[{AudibleProductId}] {Title}";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Dinah.Core;
|
||||||
|
|
||||||
|
namespace DataLayer
|
||||||
|
{
|
||||||
|
public class BookCategory
|
||||||
|
{
|
||||||
|
internal int BookId { get; private set; }
|
||||||
|
internal int CategoryLadderId { get; private set; }
|
||||||
|
|
||||||
|
public Book Book { get; private set; }
|
||||||
|
public CategoryLadder CategoryLadder { get; private set; }
|
||||||
|
private BookCategory() { }
|
||||||
|
|
||||||
|
internal BookCategory(Book book, CategoryLadder categoriesList)
|
||||||
|
{
|
||||||
|
Book = ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||||
|
CategoryLadder = ArgumentValidator.EnsureNotNull(categoriesList, nameof(categoriesList));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using System;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
public class AudibleCategoryId
|
public class AudibleCategoryId
|
||||||
@@ -15,20 +15,29 @@ namespace DataLayer
|
|||||||
Id = id;
|
Id = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Category
|
public class Category
|
||||||
{
|
{
|
||||||
// Empty is a special case. use private ctor w/o validation
|
|
||||||
public static Category GetEmpty() => new() { CategoryId = -1, AudibleCategoryId = "", Name = "" };
|
|
||||||
|
|
||||||
internal int CategoryId { get; private set; }
|
internal int CategoryId { get; private set; }
|
||||||
public string AudibleCategoryId { get; private set; }
|
public string? AudibleCategoryId { get; private set; }
|
||||||
|
|
||||||
public string Name { get; private set; }
|
public string? Name { get; internal set; }
|
||||||
public Category ParentCategory { get; private set; }
|
|
||||||
|
|
||||||
private Category() { }
|
internal List<CategoryLadder> _categoryLadders = new();
|
||||||
|
private ReadOnlyCollection<CategoryLadder>? _categoryLaddersReadOnly;
|
||||||
|
public ReadOnlyCollection<CategoryLadder> CategoryLadders
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_categoryLaddersReadOnly?.SequenceEqual(_categoryLadders) is not true)
|
||||||
|
_categoryLaddersReadOnly = _categoryLadders.AsReadOnly();
|
||||||
|
return _categoryLaddersReadOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Category() { }
|
||||||
/// <summary>special id class b/c it's too easy to get string order mixed up</summary>
|
/// <summary>special id class b/c it's too easy to get string order mixed up</summary>
|
||||||
public Category(AudibleCategoryId audibleSeriesId, string name, Category parentCategory = null)
|
public Category(AudibleCategoryId audibleSeriesId, string name)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId));
|
ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId));
|
||||||
var id = audibleSeriesId.Id;
|
var id = audibleSeriesId.Id;
|
||||||
@@ -37,15 +46,6 @@ namespace DataLayer
|
|||||||
|
|
||||||
AudibleCategoryId = id;
|
AudibleCategoryId = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
|
|
||||||
UpdateParentCategory(parentCategory);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateParentCategory(Category parentCategory)
|
|
||||||
{
|
|
||||||
// don't overwrite with null but not an error
|
|
||||||
if (parentCategory is not null)
|
|
||||||
ParentCategory = parentCategory;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => $"[{AudibleCategoryId}] {Name}";
|
public override string ToString() => $"[{AudibleCategoryId}] {Name}";
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Dinah.Core;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace DataLayer
|
||||||
|
{
|
||||||
|
public class CategoryLadder : IEquatable<CategoryLadder>
|
||||||
|
{
|
||||||
|
internal int CategoryLadderId { get; private set; }
|
||||||
|
|
||||||
|
internal List<Category> _categories;
|
||||||
|
private ReadOnlyCollection<Category>? _categoriesReadOnly;
|
||||||
|
public ReadOnlyCollection<Category> Categories
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_categoriesReadOnly?.SequenceEqual(_categories) is not true)
|
||||||
|
_categoriesReadOnly = _categories.AsReadOnly();
|
||||||
|
return _categoriesReadOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HashSet<BookCategory>? _booksLink;
|
||||||
|
public IEnumerable<BookCategory>? BooksLink => _booksLink?.ToList();
|
||||||
|
private CategoryLadder() { _categories = new(); }
|
||||||
|
public CategoryLadder(List<Category> categories)
|
||||||
|
{
|
||||||
|
ArgumentValidator.EnsureNotNull(categories, nameof(categories));
|
||||||
|
ArgumentValidator.EnsureGreaterThan(categories.Count, nameof(categories), 0);
|
||||||
|
_booksLink = new HashSet<BookCategory>();
|
||||||
|
_categories = categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
HashCode hashCode = default;
|
||||||
|
foreach (var category in _categories)
|
||||||
|
hashCode.Add(category.AudibleCategoryId);
|
||||||
|
return hashCode.ToHashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(CategoryLadder? other)
|
||||||
|
=> other?._categories is not null
|
||||||
|
&& Equals(other._categories.Select(c => c.AudibleCategoryId));
|
||||||
|
|
||||||
|
public bool Equals(IEnumerable<string?>? categoryIds)
|
||||||
|
=> categoryIds is not null
|
||||||
|
&& _categories.Select(c => c.AudibleCategoryId).SequenceEqual(categoryIds);
|
||||||
|
|
||||||
|
public override bool Equals(object? obj)
|
||||||
|
=> obj is CategoryLadder other && Equals(other);
|
||||||
|
|
||||||
|
public override string ToString() => string.Join(" > ", _categories.Select(c => c.Name));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ namespace DataLayer
|
|||||||
public DateTime DateAdded { get; private set; }
|
public DateTime DateAdded { get; private set; }
|
||||||
public string Account { get; private set; }
|
public string Account { get; private set; }
|
||||||
|
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
public bool AbsentFromLastScan { get; set; }
|
||||||
|
|
||||||
private LibraryBook() { }
|
private LibraryBook() { }
|
||||||
public LibraryBook(Book book, DateTime dateAdded, string account)
|
public LibraryBook(Book book, DateTime dateAdded, string account)
|
||||||
{
|
{
|
||||||
@@ -22,6 +25,8 @@ namespace DataLayer
|
|||||||
Account = account;
|
Account = account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => $"{DateAdded:d} {Book}";
|
public void SetAccount(string account) => Account = account;
|
||||||
|
|
||||||
|
public override string ToString() => $"{DateAdded:d} {Book}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ using Dinah.Core;
|
|||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
/// <summary>Parameterless ctor and setters should be used by EF only. Everything else should treat it as immutable</summary>
|
/// <summary>Parameterless ctor and setters should be used by EF only. Everything else should treat it as immutable</summary>
|
||||||
public class Rating : ValueObject_Static<Rating>
|
public class Rating : ValueObject_Static<Rating>, IComparable<Rating>, IComparable
|
||||||
{
|
{
|
||||||
public float OverallRating { get; private set; }
|
public float OverallRating { get; private set; }
|
||||||
public float PerformanceRating { get; private set; }
|
public float PerformanceRating { get; private set; }
|
||||||
@@ -38,6 +38,16 @@ namespace DataLayer
|
|||||||
yield return StoryRating;
|
yield return StoryRating;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
|
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
|
||||||
}
|
|
||||||
|
public int CompareTo(Rating other)
|
||||||
|
{
|
||||||
|
var compare = OverallRating.CompareTo(other.OverallRating);
|
||||||
|
if (compare != 0) return compare;
|
||||||
|
compare = PerformanceRating.CompareTo(other.PerformanceRating);
|
||||||
|
if (compare != 0) return compare;
|
||||||
|
return StoryRating.CompareTo(other.StoryRating);
|
||||||
|
}
|
||||||
|
public int CompareTo(object obj) => obj is Rating second ? CompareTo(second) : -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -20,12 +20,31 @@ namespace DataLayer
|
|||||||
PartialDownload = 0x1000
|
PartialDownload = 0x1000
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UserDefinedItem
|
public partial class UserDefinedItem
|
||||||
{
|
{
|
||||||
internal int BookId { get; private set; }
|
internal int BookId { get; private set; }
|
||||||
public Book Book { get; private set; }
|
public Book Book { get; private set; }
|
||||||
|
public DateTime? LastDownloaded { get; private set; }
|
||||||
|
public Version LastDownloadedVersion { get; private set; }
|
||||||
|
|
||||||
private UserDefinedItem() { }
|
public void SetLastDownloaded(Version version)
|
||||||
|
{
|
||||||
|
if (LastDownloadedVersion != version)
|
||||||
|
{
|
||||||
|
LastDownloadedVersion = version;
|
||||||
|
OnItemChanged(nameof(LastDownloadedVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version is null)
|
||||||
|
LastDownloaded = null;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LastDownloaded = DateTime.Now;
|
||||||
|
OnItemChanged(nameof(LastDownloaded));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserDefinedItem() { }
|
||||||
internal UserDefinedItem(Book book)
|
internal UserDefinedItem(Book book)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||||
@@ -51,18 +70,23 @@ namespace DataLayer
|
|||||||
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
|
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
|
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
|
||||||
// only legal chars are letters numbers underscores and separating whitespace
|
|
||||||
//
|
/// <summary>
|
||||||
// technically, the only char.s which aren't easily supported are \ [ ]
|
/// only legal chars are letters numbers underscores and separating whitespace
|
||||||
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
|
///
|
||||||
// it's easy to expand whitelist as needed
|
/// technically, the only char.s which aren't easily supported are \ [ ]
|
||||||
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
|
/// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
|
||||||
//
|
/// it's easy to expand whitelist as needed
|
||||||
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
|
/// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
|
||||||
// full list of characters which must be escaped:
|
///
|
||||||
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
|
/// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
|
||||||
static Regex regex { get; } = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
|
/// full list of characters which must be escaped:
|
||||||
private static string sanitize(string input)
|
/// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
|
||||||
|
/// </summary>
|
||||||
|
|
||||||
|
[GeneratedRegex(@"[^\w\d\s_]")]
|
||||||
|
private static partial Regex IllegalCharacterRegex();
|
||||||
|
private static string sanitize(string input)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(input))
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
return "";
|
return "";
|
||||||
@@ -73,9 +97,9 @@ namespace DataLayer
|
|||||||
// assume a hyphen is supposed to be an underscore
|
// assume a hyphen is supposed to be an underscore
|
||||||
.Replace("-", "_");
|
.Replace("-", "_");
|
||||||
|
|
||||||
var unique = regex
|
var unique = IllegalCharacterRegex()
|
||||||
// turn illegal characters into a space. this will also take care of turning new lines into spaces
|
// turn illegal characters into a space. this will also take care of turning new lines into spaces
|
||||||
.Replace(str, " ")
|
.Replace(str, " ")
|
||||||
// split and remove excess spaces
|
// split and remove excess spaces
|
||||||
.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
// de-dup
|
// de-dup
|
||||||
@@ -98,7 +122,11 @@ namespace DataLayer
|
|||||||
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
|
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
|
||||||
|
|
||||||
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
|
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
|
||||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
{
|
||||||
|
var changed = Rating.OverallRating != overallRating || Rating.PerformanceRating != performanceRating || Rating.StoryRating != storyRating;
|
||||||
|
Rating.Update(overallRating, performanceRating, storyRating);
|
||||||
|
if (changed) OnItemChanged(nameof(Rating));
|
||||||
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region LiberatedStatuses
|
#region LiberatedStatuses
|
||||||
@@ -146,10 +174,19 @@ namespace DataLayer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public void SetPdfStatus(LiberatedStatus? pdfStatus)
|
||||||
|
{
|
||||||
|
// don't change whether pdf is actually available. if null, leave as null. if not null, only assign non-null
|
||||||
|
|
||||||
|
// null => non-null : only when adding a supplement
|
||||||
|
|
||||||
|
if (pdfStatus.HasValue && PdfStatus.HasValue)
|
||||||
|
PdfStatus = pdfStatus;
|
||||||
|
}
|
||||||
public LiberatedStatus? PdfStatus
|
public LiberatedStatus? PdfStatus
|
||||||
{
|
{
|
||||||
get => _pdfStatus;
|
get => _pdfStatus;
|
||||||
set
|
internal set
|
||||||
{
|
{
|
||||||
if (_pdfStatus != value)
|
if (_pdfStatus != value)
|
||||||
{
|
{
|
||||||
@@ -158,7 +195,23 @@ namespace DataLayer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region IsFinished
|
||||||
|
private bool _isFinished;
|
||||||
|
public bool IsFinished
|
||||||
|
{
|
||||||
|
get => _isFinished;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value != _isFinished)
|
||||||
|
{
|
||||||
|
_isFinished = value;
|
||||||
|
OnItemChanged(nameof(IsFinished));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
public override string ToString() => $"{Book} {Rating} {Tags}";
|
public override string ToString() => $"{Book} {Rating} {Tags}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ using System.Threading.Tasks;
|
|||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
public static class EntityExtensions
|
public static class EntityExtensions
|
||||||
{
|
{
|
||||||
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title);
|
public static IEnumerable<BookContributor> ByRole(this IEnumerable<BookContributor> contributors, Role role)
|
||||||
|
=> contributors
|
||||||
|
.Where(a => a.Role == role)
|
||||||
|
.OrderBy(a => a.Order);
|
||||||
|
|
||||||
|
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle);
|
||||||
|
|
||||||
public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name));
|
public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name));
|
||||||
public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name));
|
public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name));
|
||||||
@@ -18,9 +23,9 @@ namespace DataLayer
|
|||||||
/// <summary>True if exists and IsLiberated. Else false</summary>
|
/// <summary>True if exists and IsLiberated. Else false</summary>
|
||||||
public static bool PDF_Exists(this Book book) => book.UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
|
public static bool PDF_Exists(this Book book) => book.UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
|
||||||
|
|
||||||
public static string SeriesSortable(this Book book) => Formatters.GetSortName(book.SeriesNames());
|
public static string SeriesSortable(this Book book) => Formatters.GetSortName(book.SeriesNames(true));
|
||||||
public static bool HasPdf(this Book book) => book.Supplements.Any();
|
public static bool HasPdf(this Book book) => book.Supplements.Any();
|
||||||
public static string SeriesNames(this Book book)
|
public static string SeriesNames(this Book book, bool includeIndex = false)
|
||||||
{
|
{
|
||||||
if (book.SeriesLink is null)
|
if (book.SeriesLink is null)
|
||||||
return "";
|
return "";
|
||||||
@@ -28,7 +33,7 @@ namespace DataLayer
|
|||||||
// first: alphabetical by name
|
// first: alphabetical by name
|
||||||
var withNames = book.SeriesLink
|
var withNames = book.SeriesLink
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
|
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
|
||||||
.Select(s => s.Series.Name)
|
.Select(getSeriesNameString)
|
||||||
.OrderBy(a => a)
|
.OrderBy(a => a)
|
||||||
.ToList();
|
.ToList();
|
||||||
// then un-named are alpha by series id
|
// then un-named are alpha by series id
|
||||||
@@ -40,15 +45,37 @@ namespace DataLayer
|
|||||||
|
|
||||||
var all = withNames.Union(nullNames).ToList();
|
var all = withNames.Union(nullNames).ToList();
|
||||||
return string.Join(", ", all);
|
return string.Join(", ", all);
|
||||||
}
|
|
||||||
public static string[] CategoriesNames(this Book book)
|
string getSeriesNameString(SeriesBook sb)
|
||||||
=> book.Category is null ? new string[0]
|
=> includeIndex && !string.IsNullOrWhiteSpace(sb.Order) && sb.Order != "-1"
|
||||||
: book.Category.ParentCategory is null ? new[] { book.Category.Name }
|
? $"{sb.Series.Name} (#{sb.Order})"
|
||||||
: new[] { book.Category.ParentCategory.Name, book.Category.Name };
|
: sb.Series.Name;
|
||||||
public static string[] CategoriesIds(this Book book)
|
}
|
||||||
=> book.Category is null ? null
|
|
||||||
: book.Category.ParentCategory is null ? new[] { book.Category.AudibleCategoryId }
|
public static string[] LowestCategoryNames(this Book book)
|
||||||
: new[] { book.Category.ParentCategory.AudibleCategoryId, book.Category.AudibleCategoryId };
|
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
|
||||||
|
: book
|
||||||
|
.CategoriesLink
|
||||||
|
.Select(cl => cl.CategoryLadder.Categories.LastOrDefault()?.Name)
|
||||||
|
.Where(c => c is not null)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public static string[] AllCategoryNames(this Book book)
|
||||||
|
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
|
||||||
|
: book
|
||||||
|
.CategoriesLink
|
||||||
|
.SelectMany(cl => cl.CategoryLadder.Categories)
|
||||||
|
.Select(c => c.Name)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public static string[] AllCategoryIds(this Book book)
|
||||||
|
=> book.CategoriesLink?.Any() is not true ? null
|
||||||
|
: book
|
||||||
|
.CategoriesLink
|
||||||
|
.SelectMany(cl => cl.CategoryLadder.Categories)
|
||||||
|
.Select(c => c.AudibleCategoryId)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5)
|
public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5)
|
||||||
{
|
{
|
||||||
@@ -57,7 +84,7 @@ namespace DataLayer
|
|||||||
|
|
||||||
max = Math.Max(max, 1);
|
max = Math.Max(max, 1);
|
||||||
|
|
||||||
var titles = libraryBooks.Select(lb => "- " + lb.Book.Title).ToList();
|
var titles = libraryBooks.Select(lb => "- " + lb.Book.TitleWithSubtitle).ToList();
|
||||||
var titlesAgg = titles.Take(max).Aggregate((a, b) => $"{a}\r\n{b}");
|
var titlesAgg = titles.Take(max).Aggregate((a, b) => $"{a}\r\n{b}");
|
||||||
if (titles.Count == max + 1)
|
if (titles.Count == max + 1)
|
||||||
titlesAgg += $"\r\n\r\nand 1 other";
|
titlesAgg += $"\r\n\r\nand 1 other";
|
||||||
@@ -93,10 +120,12 @@ namespace DataLayer
|
|||||||
|
|
||||||
var starString = new string(STAR, fullStars);
|
var starString = new string(STAR, fullStars);
|
||||||
|
|
||||||
if (score - fullStars >= 0.25f)
|
if (score - fullStars >= 0.75f)
|
||||||
starString += HALF;
|
starString += STAR;
|
||||||
|
else if (score - fullStars >= 0.25f)
|
||||||
|
starString += HALF;
|
||||||
|
|
||||||
return starString;
|
return starString;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ namespace DataLayer
|
|||||||
public DbSet<Contributor> Contributors { get; private set; }
|
public DbSet<Contributor> Contributors { get; private set; }
|
||||||
public DbSet<Series> Series { get; private set; }
|
public DbSet<Series> Series { get; private set; }
|
||||||
public DbSet<Category> Categories { get; private set; }
|
public DbSet<Category> Categories { get; private set; }
|
||||||
|
public DbSet<CategoryLadder> CategoryLadders { get; private set; }
|
||||||
|
|
||||||
public static LibationContext Create(string connectionString)
|
public static LibationContext Create(string connectionString)
|
||||||
{
|
{
|
||||||
@@ -39,22 +40,15 @@ namespace DataLayer
|
|||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.ApplyConfiguration(new BookConfig());
|
modelBuilder.ApplyConfiguration(new BookConfig());
|
||||||
modelBuilder.ApplyConfiguration(new ContributorConfig());
|
modelBuilder.ApplyConfiguration(new ContributorConfig());
|
||||||
modelBuilder.ApplyConfiguration(new BookContributorConfig());
|
modelBuilder.ApplyConfiguration(new BookContributorConfig());
|
||||||
modelBuilder.ApplyConfiguration(new LibraryBookConfig());
|
modelBuilder.ApplyConfiguration(new LibraryBookConfig());
|
||||||
modelBuilder.ApplyConfiguration(new SeriesConfig());
|
modelBuilder.ApplyConfiguration(new SeriesConfig());
|
||||||
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
|
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
|
||||||
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
||||||
|
modelBuilder.ApplyConfiguration(new CategoryLadderConfig());
|
||||||
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
|
modelBuilder.ApplyConfiguration(new BookCategoryConfig());
|
||||||
|
|
||||||
modelBuilder
|
|
||||||
.Entity<Category>()
|
|
||||||
.HasData(Category.GetEmpty());
|
|
||||||
modelBuilder
|
|
||||||
.Entity<Contributor>()
|
|
||||||
.HasData(Contributor.GetEmpty());
|
|
||||||
|
|
||||||
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
|
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
|
||||||
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
|
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace DataLayer
|
|||||||
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
|
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
|
||||||
{
|
{
|
||||||
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
||||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString);
|
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString)
|
||||||
|
=> optionsBuilder.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+401
@@ -0,0 +1,401 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DataLayer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibationContext))]
|
||||||
|
[Migration("20221214205106_LibraryBookIsDeleted")]
|
||||||
|
partial class LibraryBookIsDeleted
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.0");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentCategoryCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
CategoryId = -1,
|
||||||
|
AudibleCategoryId = "",
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.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("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "Category")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.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<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("Category");
|
||||||
|
|
||||||
|
b.Navigation("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
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.Category", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.Navigation("ParentCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class LibraryBookIsDeleted : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsDeleted",
|
||||||
|
table: "LibraryBooks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsDeleted",
|
||||||
|
table: "LibraryBooks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DataLayer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibationContext))]
|
||||||
|
[Migration("20230201162454_AddBookLanguage")]
|
||||||
|
partial class AddBookLanguage
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentCategoryCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
CategoryId = -1,
|
||||||
|
AudibleCategoryId = "",
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.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("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "Category")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.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<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("Category");
|
||||||
|
|
||||||
|
b.Navigation("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
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.Category", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.Navigation("ParentCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBookLanguage : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Language",
|
||||||
|
table: "Books",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Language",
|
||||||
|
table: "Books");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+410
@@ -0,0 +1,410 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DataLayer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibationContext))]
|
||||||
|
[Migration("20230302220539_AddLastDownloadedInfo")]
|
||||||
|
partial class AddLastDownloadedInfo
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentCategoryCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
CategoryId = -1,
|
||||||
|
AudibleCategoryId = "",
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.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("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "Category")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.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<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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("Category");
|
||||||
|
|
||||||
|
b.Navigation("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
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.Category", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.Navigation("ParentCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLastDownloadedInfo : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "LastDownloaded",
|
||||||
|
table: "UserDefinedItem",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LastDownloadedVersion",
|
||||||
|
table: "UserDefinedItem",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastDownloaded",
|
||||||
|
table: "UserDefinedItem");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastDownloadedVersion",
|
||||||
|
table: "UserDefinedItem");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DataLayer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibationContext))]
|
||||||
|
[Migration("20230308013410_AddAbsentFromLastScan")]
|
||||||
|
partial class AddAbsentFromLastScan
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentCategoryCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
CategoryId = -1,
|
||||||
|
AudibleCategoryId = "",
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.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("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "Category")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.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<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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("Category");
|
||||||
|
|
||||||
|
b.Navigation("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
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.Category", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.Navigation("ParentCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAbsentFromLastScan : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AbsentFromLastScan",
|
||||||
|
table: "LibraryBooks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AbsentFromLastScan",
|
||||||
|
table: "LibraryBooks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DataLayer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibationContext))]
|
||||||
|
[Migration("20230626171442_AddBookSubtitle")]
|
||||||
|
partial class AddBookSubtitle
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentCategoryCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
CategoryId = -1,
|
||||||
|
AudibleCategoryId = "",
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.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("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "Category")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.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<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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("Category");
|
||||||
|
|
||||||
|
b.Navigation("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
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.Category", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.Navigation("ParentCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBookSubtitle : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Subtitle",
|
||||||
|
table: "Books",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Subtitle",
|
||||||
|
table: "Books");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DataLayer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibationContext))]
|
||||||
|
[Migration("20230718214617_AddCategoryLadder")]
|
||||||
|
partial class AddCategoryLadder
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("_categoriesCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryCategoryLadder");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "CategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("BookCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.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");
|
||||||
|
|
||||||
|
b.HasKey("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryLadders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleContributorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.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("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleSeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Order")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId", "BookId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesBook");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoriesCategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.CategoryLadder", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.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<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCategoryLadder : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Books_Categories_CategoryId",
|
||||||
|
table: "Books");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Categories_Categories_ParentCategoryCategoryId",
|
||||||
|
table: "Categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Categories_ParentCategoryCategoryId",
|
||||||
|
table: "Categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Books_CategoryId",
|
||||||
|
table: "Books");
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "CategoryId",
|
||||||
|
keyValue: -1);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ParentCategoryCategoryId",
|
||||||
|
table: "Categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CategoryId",
|
||||||
|
table: "Books");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CategoryLadders",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
CategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CategoryLadders", x => x.CategoryLadderId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "BookCategory",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BookId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
CategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_BookCategory", x => new { x.BookId, x.CategoryLadderId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_BookCategory_Books_BookId",
|
||||||
|
column: x => x.BookId,
|
||||||
|
principalTable: "Books",
|
||||||
|
principalColumn: "BookId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_BookCategory_CategoryLadders_CategoryLadderId",
|
||||||
|
column: x => x.CategoryLadderId,
|
||||||
|
principalTable: "CategoryLadders",
|
||||||
|
principalColumn: "CategoryLadderId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CategoryCategoryLadder",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
_categoriesCategoryId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
_categoryLaddersCategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CategoryCategoryLadder", x => new { x._categoriesCategoryId, x._categoryLaddersCategoryLadderId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CategoryCategoryLadder_Categories__categoriesCategoryId",
|
||||||
|
column: x => x._categoriesCategoryId,
|
||||||
|
principalTable: "Categories",
|
||||||
|
principalColumn: "CategoryId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CategoryCategoryLadder_CategoryLadders__categoryLaddersCategoryLadderId",
|
||||||
|
column: x => x._categoryLaddersCategoryLadderId,
|
||||||
|
principalTable: "CategoryLadders",
|
||||||
|
principalColumn: "CategoryLadderId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BookCategory_BookId",
|
||||||
|
table: "BookCategory",
|
||||||
|
column: "BookId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BookCategory_CategoryLadderId",
|
||||||
|
table: "BookCategory",
|
||||||
|
column: "CategoryLadderId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CategoryCategoryLadder__categoryLaddersCategoryLadderId",
|
||||||
|
table: "CategoryCategoryLadder",
|
||||||
|
column: "_categoryLaddersCategoryLadderId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "BookCategory");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CategoryCategoryLadder");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CategoryLadders");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ParentCategoryCategoryId",
|
||||||
|
table: "Categories",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "CategoryId",
|
||||||
|
table: "Books",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "Categories",
|
||||||
|
columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" },
|
||||||
|
values: new object[] { -1, "", "", null });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Categories_ParentCategoryCategoryId",
|
||||||
|
table: "Categories",
|
||||||
|
column: "ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Books_CategoryId",
|
||||||
|
table: "Books",
|
||||||
|
column: "CategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Books_Categories_CategoryId",
|
||||||
|
table: "Books",
|
||||||
|
column: "CategoryId",
|
||||||
|
principalTable: "Categories",
|
||||||
|
principalColumn: "CategoryId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Categories_Categories_ParentCategoryCategoryId",
|
||||||
|
table: "Categories",
|
||||||
|
column: "ParentCategoryCategoryId",
|
||||||
|
principalTable: "Categories",
|
||||||
|
principalColumn: "CategoryId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DataLayer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibationContext))]
|
||||||
|
[Migration("20240911114741_MyComment")]
|
||||||
|
partial class MyComment
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.5");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("_categoriesCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryCategoryLadder");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "CategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("BookCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.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");
|
||||||
|
|
||||||
|
b.HasKey("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryLadders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleContributorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.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("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleSeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Order")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId", "BookId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesBook");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoriesCategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.CategoryLadder", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("SupplementId");
|
||||||
|
|
||||||
|
b1.HasIndex("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Supplement");
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsFinished")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MyComment : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsFinished",
|
||||||
|
table: "UserDefinedItem",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsFinished",
|
||||||
|
table: "UserDefinedItem");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,22 @@ namespace DataLayer.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.5");
|
||||||
|
|
||||||
|
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 =>
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
{
|
{
|
||||||
@@ -26,9 +41,6 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<string>("AudibleProductId")
|
b.Property<string>("AudibleProductId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int>("CategoryId")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<int>("ContentType")
|
b.Property<int>("ContentType")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -41,6 +53,9 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<bool>("IsAbridged")
|
b.Property<bool>("IsAbridged")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int>("LengthInMinutes")
|
b.Property<int>("LengthInMinutes")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -53,6 +68,9 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<string>("PictureLarge")
|
b.Property<string>("PictureLarge")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Subtitle")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -63,11 +81,26 @@ namespace DataLayer.Migrations
|
|||||||
|
|
||||||
b.HasIndex("AudibleProductId");
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
b.HasIndex("CategoryId");
|
|
||||||
|
|
||||||
b.ToTable("Books");
|
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 =>
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("BookId")
|
b.Property<int>("BookId")
|
||||||
@@ -103,24 +136,22 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int?>("ParentCategoryCategoryId")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.HasKey("CategoryId");
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
b.HasIndex("AudibleCategoryId");
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
b.HasIndex("ParentCategoryCategoryId");
|
|
||||||
|
|
||||||
b.ToTable("Categories");
|
b.ToTable("Categories");
|
||||||
|
});
|
||||||
|
|
||||||
b.HasData(
|
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||||
new
|
{
|
||||||
{
|
b.Property<int>("CategoryLadderId")
|
||||||
CategoryId = -1,
|
.ValueGeneratedOnAdd()
|
||||||
AudibleCategoryId = "",
|
.HasColumnType("INTEGER");
|
||||||
Name = ""
|
|
||||||
});
|
b.HasKey("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryLadders");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
@@ -154,12 +185,18 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<int>("BookId")
|
b.Property<int>("BookId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("AbsentFromLastScan")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Account")
|
b.Property<string>("Account")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<DateTime>("DateAdded")
|
b.Property<DateTime>("DateAdded")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.HasKey("BookId");
|
b.HasKey("BookId");
|
||||||
|
|
||||||
b.ToTable("LibraryBooks");
|
b.ToTable("LibraryBooks");
|
||||||
@@ -204,14 +241,23 @@ namespace DataLayer.Migrations
|
|||||||
b.ToTable("SeriesBook");
|
b.ToTable("SeriesBook");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Book", b =>
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DataLayer.Category", "Category")
|
b.HasOne("DataLayer.Category", null)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("CategoryId")
|
.HasForeignKey("_categoriesCategoryId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.CategoryLadder", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
{
|
{
|
||||||
b1.Property<int>("BookId")
|
b1.Property<int>("BookId")
|
||||||
@@ -266,6 +312,15 @@ namespace DataLayer.Migrations
|
|||||||
b1.Property<int>("BookStatus")
|
b1.Property<int>("BookStatus")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsFinished")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<string>("LastDownloadedVersion")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b1.Property<int?>("PdfStatus")
|
b1.Property<int?>("PdfStatus")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -306,8 +361,6 @@ namespace DataLayer.Migrations
|
|||||||
b1.Navigation("Rating");
|
b1.Navigation("Rating");
|
||||||
});
|
});
|
||||||
|
|
||||||
b.Navigation("Category");
|
|
||||||
|
|
||||||
b.Navigation("Rating");
|
b.Navigation("Rating");
|
||||||
|
|
||||||
b.Navigation("Supplements");
|
b.Navigation("Supplements");
|
||||||
@@ -315,6 +368,25 @@ namespace DataLayer.Migrations
|
|||||||
b.Navigation("UserDefinedItem");
|
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 =>
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DataLayer.Book", "Book")
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
@@ -334,15 +406,6 @@ namespace DataLayer.Migrations
|
|||||||
b.Navigation("Contributor");
|
b.Navigation("Contributor");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Category", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ParentCategoryCategoryId");
|
|
||||||
|
|
||||||
b.Navigation("ParentCategory");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DataLayer.Book", "Book")
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
@@ -375,11 +438,18 @@ namespace DataLayer.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Book", b =>
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("CategoriesLink");
|
||||||
|
|
||||||
b.Navigation("ContributorsLink");
|
b.Navigation("ContributorsLink");
|
||||||
|
|
||||||
b.Navigation("SeriesLink");
|
b.Navigation("SeriesLink");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BooksLink");
|
b.Navigation("BooksLink");
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ namespace DataLayer
|
|||||||
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
|
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
|
||||||
.Include(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
.Include(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
||||||
.Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
|
.Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
|
||||||
.Include(b => b.Category).ThenInclude(c => c.ParentCategory);
|
.Include(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
|
||||||
|
|
||||||
public static bool IsProduct(this Book book)
|
public static bool IsProduct(this Book book)
|
||||||
=> book.ContentType is not ContentType.Episode and not ContentType.Parent;
|
=> book.ContentType is not ContentType.Episode and not ContentType.Parent;
|
||||||
@@ -44,7 +44,11 @@ namespace DataLayer
|
|||||||
|
|
||||||
public static bool IsEpisodeParent(this Book book)
|
public static bool IsEpisodeParent(this Book book)
|
||||||
=> book.ContentType is ContentType.Parent;
|
=> book.ContentType is ContentType.Parent;
|
||||||
public static bool HasLiberated(this Book book)
|
|
||||||
|
public static IEnumerable<LibraryBook> WithoutParents(this IEnumerable<LibraryBook> libraryBooks)
|
||||||
|
=> libraryBooks.Where(lb => !lb.Book.IsEpisodeParent());
|
||||||
|
|
||||||
|
public static bool HasLiberated(this Book book)
|
||||||
=> book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated ||
|
=> book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated ||
|
||||||
book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated;
|
book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace DataLayer
|
||||||
|
{
|
||||||
|
public static class CategoryQueries
|
||||||
|
{
|
||||||
|
public static IQueryable<CategoryLadder> GetCategoryLadders(this LibationContext context)
|
||||||
|
=> context.CategoryLadders.Include(c => c._categories);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 627 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user