mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-08 11:47:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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: "8.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: "8.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 }}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# This workflow will build a .NET project
|
|
||||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
|
||||||
|
|
||||||
name: build
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOTNET_CONFIGURATION: 'Release'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: windows-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [Linux, MacOS, Windows]
|
|
||||||
ui: [Avalonia]
|
|
||||||
release_name: [chardonnay]
|
|
||||||
include:
|
|
||||||
- os: Windows
|
|
||||||
ui: WinForms
|
|
||||||
release_name: classic
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v3
|
|
||||||
with:
|
|
||||||
dotnet-version: '7.x'
|
|
||||||
env:
|
|
||||||
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Build
|
|
||||||
working-directory: ./Source
|
|
||||||
run: |
|
|
||||||
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} Libation${{ matrix.ui }}\Libation${{ matrix.ui }}.csproj -p:PublishProfile=Libation${{ matrix.ui }}\Properties\PublishProfiles\${{ matrix.os }}Profile.pubxml
|
|
||||||
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} LoadByOS\${{ matrix.os }}ConfigApp\${{ matrix.os }}ConfigApp.csproj -p:PublishProfile=LoadByOS\Properties\${{ matrix.os }}ConfigApp\PublishProfiles\${{ matrix.os }}Profile.pubxml
|
|
||||||
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} LibationCli\LibationCli.csproj -p:PublishProfile=LibationCli\Properties\PublishProfiles\${{ matrix.os }}Profile.pubxml
|
|
||||||
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} Hangover${{ matrix.ui }}\Hangover${{ matrix.ui }}.csproj -p:PublishProfile=Hangover${{ matrix.ui }}\Properties\PublishProfiles\${{ matrix.os }}Profile.pubxml
|
|
||||||
- name: Publish artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: ${{ matrix.os }}-${{ matrix.release_name }}
|
|
||||||
path: ./Source/bin/Publish/${{ matrix.os }}-${{ matrix.release_name }}/*
|
|
||||||
if-no-files-found: error
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# This workflow will build a .NET project
|
|
||||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
|
||||||
name: release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOTNET_VERSION: '7' # The .NET SDK version to use
|
|
||||||
DOTNET_SOURCE: './Source'
|
|
||||||
DOTNET_CONFIGURATION: 'Release'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
uses: ./.github/workflows/dotnet-build.yml
|
|
||||||
|
|
||||||
release:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Calculate version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
export TAG=${{ github.ref_name }}
|
|
||||||
echo "version=${TAG#v}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Download artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Rename artifacts
|
|
||||||
id: rename
|
|
||||||
working-directory: ./artifacts
|
|
||||||
run: |
|
|
||||||
for FILENAME in *; do mv ${FILENAME} Libation.${{ steps.version.outputs.version }}-${FILENAME,,}; done
|
|
||||||
mv Libation.${{ steps.version.outputs.version }}-windows-classic Classic-Libation.${{ steps.version.outputs.version }}-windows-classic
|
|
||||||
|
|
||||||
- name: Zip assets
|
|
||||||
working-directory: ./artifacts
|
|
||||||
run: |
|
|
||||||
for FILENAME in *; do zip -r ${FILENAME}.zip ${FILENAME}; done
|
|
||||||
mkdir ./assets
|
|
||||||
mv *.zip ./assets
|
|
||||||
|
|
||||||
- name: Create release
|
|
||||||
id: create_release
|
|
||||||
uses: actions/create-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref }}
|
|
||||||
release_name: Libation ${{ steps.version.outputs.version }}
|
|
||||||
body: <Put a body here>
|
|
||||||
draft: true
|
|
||||||
prerelease: false
|
|
||||||
|
|
||||||
- name: Upload release assets
|
|
||||||
uses: dwenegar/upload-release-assets@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
release_id: ${{ steps.create_release.outputs.id }}
|
|
||||||
assets_path: ./artifacts/assets
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# This workflow will build a .NET project
|
|
||||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
|
||||||
|
|
||||||
name: validate
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "master" ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "master" ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOTNET_VERSION: '7' # The .NET SDK version to use
|
|
||||||
DOTNET_SLN: './Source/Libation.sln'
|
|
||||||
DOTNET_CONFIGURATION: 'Release'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
uses: ./.github/workflows/dotnet-build.yml
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 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>
|
||||||
|
draft: true
|
||||||
|
prerelease: false
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
uses: dwenegar/upload-release-assets@v2
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
with:
|
||||||
|
release_id: "${{ steps.release.outputs.id }}"
|
||||||
|
assets_path: ./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+-windows-classic\\.zip",
|
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
||||||
"WindowsAvalonia":"Libation\\.\\d+\\.\\d+\\.\\d+-windows-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:8.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:8.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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
## [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.
|
||||||
|
> * `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
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
## [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: 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,31 @@
|
|||||||
## [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 Ubuntu (Beta)
|
# Install and Run Libation on Ubuntu
|
||||||
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
|
|
||||||
|
|
||||||
### FFMpeg (Optional)
|
Run this command in your terminal to download and install Libation, replacing the url with the latest Libation package url:
|
||||||
If you want to convert your audiobooks to mp3, install FFMpeg using the following command:
|
|
||||||
|
|
||||||
```console
|
- Debian
|
||||||
sudo apt-get install -y ffmpeg
|
```Console
|
||||||
```
|
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
|
||||||
|
sudo apt install ./libation.deb
|
||||||
## Install Libation
|
```
|
||||||
|
- Redhat and CentOS
|
||||||
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:
|
```Console
|
||||||
|
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
|
||||||
<details>
|
sudo yum install ./libation.rpm
|
||||||
<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>
|
|
||||||
|
|
||||||
```console
|
|
||||||
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,73 @@
|
|||||||
## [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.9.4.2-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!
|
||||||
|
|
||||||
|
## 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>net7.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.3" />
|
||||||
</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>net7.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Version>8.6.0.1</Version>
|
<Version>11.6.0.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Octokit" Version="4.0.1" />
|
<PackageReference Include="Octokit" Version="13.0.1" />
|
||||||
|
<!-- 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,24 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +111,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 +155,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 +232,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 +255,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 +264,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)
|
||||||
@@ -370,7 +309,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 +329,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 +407,54 @@ namespace AppScaffolding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void migrate_from_7_10_1(Configuration config)
|
class FilterState_6_6_9
|
||||||
{
|
{
|
||||||
var lastMigrationThrew = config.GetNonString<bool>($"{nameof(migrate_from_7_10_1)}_ThrewError");
|
public bool UseDefault { get; set; }
|
||||||
|
public List<string> Filters { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
if (lastMigrationThrew) return;
|
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
|
try
|
||||||
{
|
{
|
||||||
|
if (JsonConvert.DeserializeObject<QuickFilters.FilterState>(File.ReadAllText(QuickFilters.JsonFile))
|
||||||
|
is QuickFilters.FilterState inMemState)
|
||||||
|
{
|
||||||
|
QuickFilters.InMemoryState = inMemState;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Eat
|
||||||
|
}
|
||||||
|
|
||||||
//https://github.com/rmcrackan/Libation/issues/270#issuecomment-1152863629
|
try
|
||||||
//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
|
if (JsonConvert.DeserializeObject<FilterState_6_6_9>(File.ReadAllText(QuickFilters.JsonFile))
|
||||||
//where individual episodes are in the db with a valid series link, but said series'
|
is FilterState_6_6_9 inMemState)
|
||||||
//parents have not been imported into the database. For those cases, Libation will
|
{
|
||||||
//attempt fixup by retrieving parents from the catalog endpoint
|
// 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));
|
||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
return;
|
||||||
|
}
|
||||||
//This migration removes books and series with SERIES_ prefix that were created
|
Debug.Assert(false, "Should not get here, QuickFilters.json deserialization issue");
|
||||||
//as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2
|
}
|
||||||
string removeHackSeries = "delete " +
|
catch
|
||||||
"from series " +
|
{
|
||||||
"where AudibleSeriesId like 'SERIES%'";
|
// Eat
|
||||||
|
}
|
||||||
string removeHackBooks = "delete " +
|
}
|
||||||
"from books " +
|
}
|
||||||
"where AudibleProductId like 'SERIES%'";
|
|
||||||
|
|
||||||
//Detect series parents that were added to the database as books with ContentType.Episode,
|
|
||||||
//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
|
|
||||||
//but are missing a series link, and add the link (don't know how this happened)
|
|
||||||
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);
|
|
||||||
context.Database.ExecuteSqlRaw(removeHackBooks);
|
|
||||||
context.Database.ExecuteSqlRaw(updateContentType);
|
|
||||||
context.Database.ExecuteSqlRaw(addMissingSeriesLink);
|
|
||||||
|
|
||||||
LibraryCommands.SaveContext(context);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Error(ex, "An error occurred while running database migrations in {0}", nameof(migrate_from_7_10_1));
|
|
||||||
config.SetObject($"{nameof(migrate_from_7_10_1)}_ThrewError", true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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>net7.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="30.0.1" />
|
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||||
<PackageReference Include="NPOI" Version="2.6.0" />
|
<PackageReference Include="NPOI" Version="2.7.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace ApplicationServices
|
|||||||
{
|
{
|
||||||
public class BulkSetDownloadStatus
|
public class BulkSetDownloadStatus
|
||||||
{
|
{
|
||||||
private List<(string message, LiberatedStatus newStatus, IEnumerable<Book> Books)> actionSets { get; } = new();
|
private List<(string message, LiberatedStatus newStatus, IEnumerable<LibraryBook> LibraryBooks)> actionSets { get; } = new();
|
||||||
|
|
||||||
public int Count => actionSets.Count;
|
public int Count => actionSets.Count;
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ namespace ApplicationServices
|
|||||||
var bookExistsList = _libraryBooks
|
var bookExistsList = _libraryBooks
|
||||||
.Select(libraryBook => new
|
.Select(libraryBook => new
|
||||||
{
|
{
|
||||||
libraryBook.Book,
|
LibraryBook = libraryBook,
|
||||||
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
|
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -41,8 +41,8 @@ namespace ApplicationServices
|
|||||||
if (_setDownloaded)
|
if (_setDownloaded)
|
||||||
{
|
{
|
||||||
var books2change = bookExistsList
|
var books2change = bookExistsList
|
||||||
.Where(a => a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
.Where(a => a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
||||||
.Select(a => a.Book)
|
.Select(a => a.LibraryBook)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (books2change.Any())
|
if (books2change.Any())
|
||||||
@@ -55,8 +55,8 @@ namespace ApplicationServices
|
|||||||
if (_setNotDownloaded)
|
if (_setNotDownloaded)
|
||||||
{
|
{
|
||||||
var books2change = bookExistsList
|
var books2change = bookExistsList
|
||||||
.Where(a => !a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
.Where(a => !a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
||||||
.Select(a => a.Book)
|
.Select(a => a.LibraryBook)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (books2change.Any())
|
if (books2change.Any())
|
||||||
@@ -72,7 +72,7 @@ namespace ApplicationServices
|
|||||||
public void Execute()
|
public void Execute()
|
||||||
{
|
{
|
||||||
foreach (var a in actionSets)
|
foreach (var a in actionSets)
|
||||||
a.Books.UpdateBookStatus(a.newStatus);
|
a.LibraryBooks.UpdateBookStatus(a.newStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,234 +1,333 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AudibleApi;
|
using AudibleApi;
|
||||||
using AudibleUtilities;
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
|
using Dinah.Core.Logging;
|
||||||
using DtoImporterService;
|
using DtoImporterService;
|
||||||
|
using FileManager;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using static System.Reflection.Metadata.BlobBuilder;
|
|
||||||
using static DtoImporterService.PerfLogger;
|
using static DtoImporterService.PerfLogger;
|
||||||
|
|
||||||
namespace ApplicationServices
|
namespace ApplicationServices
|
||||||
{
|
{
|
||||||
public static class LibraryCommands
|
public static class LibraryCommands
|
||||||
{
|
{
|
||||||
public static event EventHandler<int> ScanBegin;
|
public static event EventHandler<int> ScanBegin;
|
||||||
public static event EventHandler ScanEnd;
|
public static event EventHandler<int> ScanEnd;
|
||||||
|
|
||||||
public static bool Scanning { get; private set; }
|
public static bool Scanning { get; private set; }
|
||||||
private static object _lock { get; } = new();
|
private static object _lock { get; } = new();
|
||||||
|
|
||||||
static LibraryCommands()
|
static LibraryCommands()
|
||||||
{
|
{
|
||||||
ScanBegin += (_, __) => Scanning = true;
|
ScanBegin += (_, __) => Scanning = true;
|
||||||
ScanEnd += (_, __) => Scanning = false;
|
ScanEnd += (_, __) => Scanning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
|
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
|
||||||
{
|
{
|
||||||
logRestart();
|
logRestart();
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
|
||||||
if (Scanning)
|
|
||||||
return new();
|
|
||||||
}
|
|
||||||
ScanBegin?.Invoke(null, accounts.Length);
|
|
||||||
|
|
||||||
//These are the minimum response groups required for the
|
|
||||||
//library scanner to pass all validation and filtering.
|
|
||||||
var libraryOptions = new LibraryOptions
|
|
||||||
{
|
{
|
||||||
ResponseGroups
|
if (Scanning)
|
||||||
= LibraryOptions.ResponseGroupOptions.ProductAttrs
|
return new();
|
||||||
| LibraryOptions.ResponseGroupOptions.ProductDesc
|
}
|
||||||
| LibraryOptions.ResponseGroupOptions.Relationships
|
ScanBegin?.Invoke(null, accounts.Length);
|
||||||
};
|
|
||||||
|
//These are the minimum response groups required for the
|
||||||
|
//library scanner to pass all validation and filtering.
|
||||||
|
var libraryOptions = new LibraryOptions
|
||||||
|
{
|
||||||
|
ResponseGroups
|
||||||
|
= LibraryOptions.ResponseGroupOptions.ProductAttrs
|
||||||
|
| LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||||
|
| LibraryOptions.ResponseGroupOptions.Relationships
|
||||||
|
};
|
||||||
if (accounts is null || accounts.Length == 0)
|
if (accounts is null || accounts.Length == 0)
|
||||||
return new List<LibraryBook>();
|
return new List<LibraryBook>();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||||
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
||||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||||
|
|
||||||
var totalCount = libraryItems.Count;
|
var totalCount = libraryItems.Count;
|
||||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||||
|
|
||||||
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||||
|
|
||||||
return missingBookList;
|
return missingBookList;
|
||||||
}
|
}
|
||||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||||
{
|
{
|
||||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
||||||
|
|
||||||
// nuget Serilog.Exceptions would automatically log custom properties
|
// nuget Serilog.Exceptions would automatically log custom properties
|
||||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||||
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
|
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
|
||||||
{
|
|
||||||
lfEx.RequestUrl,
|
|
||||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
|
||||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
|
||||||
lfEx.ResponseInputFields,
|
|
||||||
lfEx.ResponseBodyFilePaths
|
|
||||||
});
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Logger.Error(ex, "Error scanning library");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
stop();
|
|
||||||
var putBreakPointHere = logOutput;
|
|
||||||
ScanEnd?.Invoke(null, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#region FULL LIBRARY scan and import
|
|
||||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
|
|
||||||
{
|
|
||||||
logRestart();
|
|
||||||
|
|
||||||
if (accounts is null || accounts.Length == 0)
|
|
||||||
return (0, 0);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
{
|
||||||
if (Scanning)
|
lfEx.RequestUrl,
|
||||||
return (0, 0);
|
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||||
}
|
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||||
ScanBegin?.Invoke(null, accounts.Length);
|
lfEx.ResponseInputFields,
|
||||||
|
lfEx.ResponseBodyFilePaths
|
||||||
|
});
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error scanning library");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stop();
|
||||||
|
var putBreakPointHere = logOutput;
|
||||||
|
ScanEnd?.Invoke(null, 0);
|
||||||
|
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
#region FULL LIBRARY scan and import
|
||||||
var libraryOptions = new LibraryOptions
|
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
|
||||||
|
{
|
||||||
|
logRestart();
|
||||||
|
|
||||||
|
if (accounts is null || accounts.Length == 0)
|
||||||
|
return (0, 0);
|
||||||
|
|
||||||
|
int newCount = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (Scanning)
|
||||||
|
return (0, 0);
|
||||||
|
}
|
||||||
|
ScanBegin?.Invoke(null, accounts.Length);
|
||||||
|
|
||||||
|
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||||
|
var libraryOptions = new LibraryOptions
|
||||||
{
|
{
|
||||||
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS,
|
ResponseGroups
|
||||||
|
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
|
||||||
|
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||||
|
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
|
||||||
|
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
|
||||||
|
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
|
||||||
|
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
|
||||||
|
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||||
};
|
};
|
||||||
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
||||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||||
|
|
||||||
var totalCount = importItems.Count;
|
var totalCount = importItems.Count;
|
||||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||||
|
|
||||||
if (totalCount == 0)
|
if (totalCount == 0)
|
||||||
return default;
|
return default;
|
||||||
|
|
||||||
|
Log.Logger.Information("Begin long-running import");
|
||||||
|
logTime($"pre {nameof(importIntoDbAsync)}");
|
||||||
|
newCount = await importIntoDbAsync(importItems);
|
||||||
|
logTime($"post {nameof(importIntoDbAsync)}");
|
||||||
|
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||||
|
|
||||||
Log.Logger.Information("Begin scan for orphaned episode parents");
|
return (totalCount, newCount);
|
||||||
var newParents = await findAndAddMissingParents(accounts);
|
}
|
||||||
Log.Logger.Information($"Orphan episode scan complete. New parents count {newParents}");
|
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||||
|
{
|
||||||
|
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
||||||
|
|
||||||
if (newParents >= 0)
|
// nuget Serilog.Exceptions would automatically log custom properties
|
||||||
{
|
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||||
//If any episodes are still orphaned, their series have been
|
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||||
//removed from the catalog and we'll never be able to find them.
|
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||||
|
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
|
||||||
|
{
|
||||||
|
lfEx.RequestUrl,
|
||||||
|
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||||
|
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||||
|
lfEx.ResponseInputFields,
|
||||||
|
lfEx.ResponseBodyFilePaths
|
||||||
|
});
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error importing library");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stop();
|
||||||
|
var putBreakPointHere = logOutput;
|
||||||
|
ScanEnd?.Invoke(null, newCount);
|
||||||
|
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//only do this if findAndAddMissingParents returned >= 0. If it
|
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
|
||||||
//returned < 0, an error happened and there's still a chance that
|
{
|
||||||
//a future successful run will find missing parents.
|
ArgumentValidator.EnsureNotNull(item, "item");
|
||||||
removedOrphanedEpisodes();
|
ArgumentValidator.EnsureNotNull(accountId, "accountId");
|
||||||
}
|
ArgumentValidator.EnsureNotNull(localeName, "localeName");
|
||||||
|
|
||||||
Log.Logger.Information("Begin long-running import");
|
var importItem = new ImportItem
|
||||||
logTime($"pre {nameof(importIntoDbAsync)}");
|
{
|
||||||
var newCount = await importIntoDbAsync(importItems);
|
DtoItem = item,
|
||||||
logTime($"post {nameof(importIntoDbAsync)}");
|
AccountId = accountId,
|
||||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
LocaleName = localeName
|
||||||
|
};
|
||||||
|
|
||||||
return (totalCount, newCount);
|
var importItems = new List<ImportItem> { importItem };
|
||||||
}
|
var validator = new LibraryValidator();
|
||||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
var exceptions = validator.Validate(importItems.Select(i => i.DtoItem));
|
||||||
{
|
|
||||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
|
||||||
|
|
||||||
// nuget Serilog.Exceptions would automatically log custom properties
|
if (exceptions?.Any() ?? false)
|
||||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
{
|
||||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
Log.Logger.Error(new AggregateException(exceptions), "Error validating library book. {@DebugInfo}", new { item, accountId, localeName });
|
||||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
return 0;
|
||||||
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
|
}
|
||||||
{
|
|
||||||
lfEx.RequestUrl,
|
using var context = DbContexts.GetContext();
|
||||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
|
||||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
var bookImporter = new BookImporter(context);
|
||||||
lfEx.ResponseInputFields,
|
await Task.Run(() => bookImporter.Import(importItems));
|
||||||
lfEx.ResponseBodyFilePaths
|
var book = await Task.Run(() => context.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId));
|
||||||
});
|
|
||||||
throw;
|
if (book is null)
|
||||||
}
|
{
|
||||||
catch (Exception ex)
|
book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId);
|
||||||
{
|
context.LibraryBooks.Add(book);
|
||||||
Log.Logger.Error(ex, "Error importing library");
|
}
|
||||||
throw;
|
else
|
||||||
}
|
{
|
||||||
finally
|
book.AbsentFromLastScan = false;
|
||||||
{
|
}
|
||||||
stop();
|
|
||||||
var putBreakPointHere = logOutput;
|
try
|
||||||
ScanEnd?.Invoke(null, null);
|
{
|
||||||
}
|
int qtyChanged = await Task.Run(() => SaveContext(context));
|
||||||
}
|
if (qtyChanged > 0)
|
||||||
|
await Task.Run(finalizeLibrarySizeChange);
|
||||||
|
return qtyChanged;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error adding single library book to DB. {@DebugInfo}", new { item, accountId, localeName });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
||||||
{
|
{
|
||||||
var tasks = new List<Task<List<ImportItem>>>();
|
var tasks = new List<Task<List<ImportItem>>>();
|
||||||
|
|
||||||
|
await using LogArchiver archiver
|
||||||
|
= Log.Logger.IsDebugEnabled()
|
||||||
|
? new LogArchiver(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
|
||||||
|
: default;
|
||||||
|
|
||||||
|
archiver?.DeleteAllButNewestN(20);
|
||||||
|
|
||||||
foreach (var account in accounts)
|
foreach (var account in accounts)
|
||||||
{
|
{
|
||||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
try
|
||||||
var apiExtended = await apiExtendedfunc(account);
|
{
|
||||||
|
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||||
|
var apiExtended = await apiExtendedfunc(account);
|
||||||
|
|
||||||
// add scanAccountAsync as a TASK: do not await
|
// add scanAccountAsync as a TASK: do not await
|
||||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions));
|
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
|
||||||
}
|
}
|
||||||
|
catch(Exception ex)
|
||||||
|
{
|
||||||
|
//Catch to allow other accounts to continue scanning.
|
||||||
|
Log.Logger.Error(ex, "Failed to scan account");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// import library in parallel
|
// import library in parallel
|
||||||
var arrayOfLists = await Task.WhenAll(tasks);
|
var arrayOfLists = await Task.WhenAll(tasks);
|
||||||
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
||||||
return importItems;
|
return importItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions)
|
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver archiver)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||||
|
|
||||||
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
||||||
|
{
|
||||||
|
Account = account?.MaskedLogEntry ?? "[null]"
|
||||||
|
});
|
||||||
|
|
||||||
|
logTime($"pre scanAccountAsync {account.AccountName}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
|
||||||
|
|
||||||
|
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
||||||
|
|
||||||
|
await logDtoItemsAsync(dtoItems);
|
||||||
|
|
||||||
|
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
|
||||||
|
}
|
||||||
|
catch(ImportValidationException ex)
|
||||||
{
|
{
|
||||||
Account = account?.MaskedLogEntry ?? "[null]"
|
await logDtoItemsAsync(ex.Items, ex.InnerExceptions.ToArray());
|
||||||
});
|
//If ImportValidationException is thrown, all Dto items get logged as part of the exception
|
||||||
|
throw new AggregateException(ex.InnerExceptions);
|
||||||
|
}
|
||||||
|
|
||||||
logTime($"pre scanAccountAsync {account.AccountName}");
|
async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception> exceptions = null)
|
||||||
|
{
|
||||||
|
if (archiver is not null)
|
||||||
|
{
|
||||||
|
var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry}.json";
|
||||||
|
var items = await Task.Run(() => JArray.FromObject(dtoItems.Select(i => i.SourceJson)));
|
||||||
|
|
||||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
|
var scanFile = new JObject
|
||||||
|
{
|
||||||
|
{ "Account", account.MaskedLogEntry },
|
||||||
|
{ "ScannedDateTime", DateTime.Now.ToString("u") },
|
||||||
|
};
|
||||||
|
|
||||||
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
if (exceptions?.Any() is true)
|
||||||
|
scanFile.Add("Exceptions", JArray.FromObject(exceptions));
|
||||||
|
|
||||||
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
|
scanFile.Add("Items", items);
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
await archiver.AddFileAsync(fileName, scanFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
||||||
{
|
{
|
||||||
logTime("importIntoDbAsync -- pre db");
|
logTime("importIntoDbAsync -- pre db");
|
||||||
using var context = DbContexts.GetContext();
|
using var context = DbContexts.GetContext();
|
||||||
var libraryBookImporter = new LibraryBookImporter(context);
|
var libraryBookImporter = new LibraryBookImporter(context);
|
||||||
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
|
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
|
||||||
logTime("importIntoDbAsync -- post Import()");
|
logTime("importIntoDbAsync -- post Import()");
|
||||||
int qtyChanges = SaveContext(context);
|
int qtyChanges = SaveContext(context);
|
||||||
logTime("importIntoDbAsync -- post SaveChanges");
|
logTime("importIntoDbAsync -- post SaveChanges");
|
||||||
|
|
||||||
// this is any changes at all to the database, not just new books
|
// this is any changes at all to the database, not just new books
|
||||||
if (qtyChanges > 0)
|
if (qtyChanges > 0)
|
||||||
await Task.Run(() => finalizeLibrarySizeChange());
|
await Task.Run(() => finalizeLibrarySizeChange());
|
||||||
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
||||||
@@ -236,219 +335,204 @@ namespace ApplicationServices
|
|||||||
return newCount;
|
return newCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void removedOrphanedEpisodes()
|
public static int SaveContext(LibationContext context)
|
||||||
{
|
{
|
||||||
using var context = DbContexts.GetContext();
|
try
|
||||||
try
|
{
|
||||||
{
|
return context.SaveChanges();
|
||||||
var orphanedEpisodes =
|
}
|
||||||
context
|
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
|
||||||
.GetLibrary_Flat_NoTracking(includeParents: true)
|
{
|
||||||
.FindOrphanedEpisodes();
|
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culprit is the "WithExceptionDetails" serilog extension
|
||||||
|
|
||||||
context.LibraryBooks.RemoveRange(orphanedEpisodes);
|
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
|
||||||
context.Books.RemoveRange(orphanedEpisodes.Select(lb => lb.Book));
|
|
||||||
|
|
||||||
}
|
var msg = "Microsoft.EntityFrameworkCore.DbUpdateException";
|
||||||
catch (Exception ex)
|
if (ex.InnerException is null)
|
||||||
{
|
throw new Exception($"{msg}{format(ex)}");
|
||||||
Serilog.Log.Logger.Error(ex, "An error occurred while trying to remove orphaned episodes from the database");
|
throw new Exception(
|
||||||
}
|
$"{msg}{format(ex)}",
|
||||||
}
|
new Exception($"Inner Exception{format(ex.InnerException)}"));
|
||||||
|
}
|
||||||
static async Task<int> findAndAddMissingParents(Account[] accounts)
|
|
||||||
{
|
|
||||||
using var context = DbContexts.GetContext();
|
|
||||||
|
|
||||||
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var orphanedEpisodes = library.FindOrphanedEpisodes().ToList();
|
|
||||||
|
|
||||||
if (!orphanedEpisodes.Any())
|
|
||||||
return -1;
|
|
||||||
|
|
||||||
var orphanedSeries =
|
|
||||||
orphanedEpisodes
|
|
||||||
.SelectMany(lb => lb.Book.SeriesLink)
|
|
||||||
.DistinctBy(s => s.Series.AudibleSeriesId)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// The Catalog endpoint does not require authentication.
|
|
||||||
var api = new ApiUnauthenticated(accounts[0].Locale);
|
|
||||||
|
|
||||||
var seriesParents = orphanedSeries.Select(o => o.Series.AudibleSeriesId).ToList();
|
|
||||||
var items = await api.GetCatalogProductsAsync(seriesParents, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
|
||||||
|
|
||||||
List<ImportItem> newParentsImportItems = new();
|
|
||||||
foreach (var sp in orphanedSeries)
|
|
||||||
{
|
|
||||||
var seriesItem = items.First(i => i.Asin == sp.Series.AudibleSeriesId);
|
|
||||||
|
|
||||||
if (seriesItem.Relationships is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var episode = orphanedEpisodes.First(l => l.Book.AudibleProductId == sp.Book.AudibleProductId);
|
|
||||||
|
|
||||||
seriesItem.PurchaseDate = new DateTimeOffset(episode.DateAdded);
|
|
||||||
seriesItem.Series = new AudibleApi.Common.Series[]
|
|
||||||
{
|
|
||||||
new AudibleApi.Common.Series{ Asin = seriesItem.Asin, Title = seriesItem.TitleWithSubtitle, Sequence = "-1"}
|
|
||||||
};
|
|
||||||
|
|
||||||
newParentsImportItems.Add(new ImportItem { DtoItem = seriesItem, AccountId = episode.Account, LocaleName = episode.Book.Locale });
|
|
||||||
}
|
|
||||||
|
|
||||||
var newCoutn = new LibraryBookImporter(context)
|
|
||||||
.Import(newParentsImportItems);
|
|
||||||
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
|
|
||||||
return newCoutn;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Error(ex, "An error occurred while trying to scan for orphaned episode parents.");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static int SaveContext(LibationContext context)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return context.SaveChanges();
|
|
||||||
}
|
|
||||||
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
|
|
||||||
{
|
|
||||||
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culprit is the "WithExceptionDetails" serilog extension
|
|
||||||
|
|
||||||
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
|
|
||||||
|
|
||||||
var msg = "Microsoft.EntityFrameworkCore.DbUpdateException";
|
|
||||||
if (ex.InnerException is null)
|
|
||||||
throw new Exception($"{msg}{format(ex)}");
|
|
||||||
throw new Exception(
|
|
||||||
$"{msg}{format(ex)}",
|
|
||||||
new Exception($"Inner Exception{format(ex.InnerException)}"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region remove books
|
#region remove/restore books
|
||||||
public static Task<List<LibraryBook>> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||||
private static List<LibraryBook> removeBooks(List<string> idsToRemove)
|
public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove });
|
||||||
{
|
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
|
||||||
using var context = DbContexts.GetContext();
|
{
|
||||||
var libBooks = context.GetLibrary_Flat_NoTracking();
|
try
|
||||||
|
{
|
||||||
|
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
|
using var context = DbContexts.GetContext();
|
||||||
context.LibraryBooks.RemoveRange(removeLibraryBooks);
|
|
||||||
context.Books.RemoveRange(removeLibraryBooks.Select(lb => lb.Book));
|
|
||||||
|
|
||||||
var qtyChanges = context.SaveChanges();
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
if (qtyChanges > 0)
|
foreach (var lb in removeLibraryBooks)
|
||||||
finalizeLibrarySizeChange();
|
{
|
||||||
|
lb.IsDeleted = true;
|
||||||
|
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
|
}
|
||||||
|
|
||||||
return removeLibraryBooks;
|
var qtyChanges = context.SaveChanges();
|
||||||
}
|
if (qtyChanges > 0)
|
||||||
#endregion
|
finalizeLibrarySizeChange();
|
||||||
|
|
||||||
// call this whenever books are added or removed from library
|
return qtyChanges;
|
||||||
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error removing books");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
|
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||||
public static event EventHandler LibrarySizeChanged;
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (libraryBooks is null || !libraryBooks.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
/// <summary>
|
using var context = DbContexts.GetContext();
|
||||||
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
|
||||||
/// </summary>
|
|
||||||
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
|
|
||||||
|
|
||||||
#region Update book details
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
public static int UpdateUserDefinedItem(
|
foreach (var lb in libraryBooks)
|
||||||
this Book book,
|
{
|
||||||
string tags = null,
|
lb.IsDeleted = false;
|
||||||
LiberatedStatus? bookStatus = null,
|
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
LiberatedStatus? pdfStatus = null)
|
}
|
||||||
=> new[] { book }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus);
|
|
||||||
|
|
||||||
public static int UpdateUserDefinedItem(
|
var qtyChanges = context.SaveChanges();
|
||||||
this IEnumerable<Book> books,
|
if (qtyChanges > 0)
|
||||||
string tags = null,
|
finalizeLibrarySizeChange();
|
||||||
LiberatedStatus? bookStatus = null,
|
|
||||||
LiberatedStatus? pdfStatus = null)
|
return qtyChanges;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error restoring books");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int PermanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (libraryBooks is null || !libraryBooks.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
|
context.LibraryBooks.RemoveRange(libraryBooks);
|
||||||
|
context.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
|
||||||
|
|
||||||
|
var qtyChanges = context.SaveChanges();
|
||||||
|
if (qtyChanges > 0)
|
||||||
|
finalizeLibrarySizeChange();
|
||||||
|
|
||||||
|
return qtyChanges;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error restoring books");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
// call this whenever books are added or removed from library
|
||||||
|
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
|
||||||
|
|
||||||
|
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
|
||||||
|
public static event EventHandler LibrarySizeChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
||||||
|
/// </summary>
|
||||||
|
public static event EventHandler<IEnumerable<LibraryBook>> BookUserDefinedItemCommitted;
|
||||||
|
|
||||||
|
#region Update book details
|
||||||
|
public static int UpdateUserDefinedItem(
|
||||||
|
this LibraryBook lb,
|
||||||
|
string tags = null,
|
||||||
|
LiberatedStatus? bookStatus = null,
|
||||||
|
LiberatedStatus? pdfStatus = null,
|
||||||
|
Rating rating = null)
|
||||||
|
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
|
||||||
|
|
||||||
|
public static int UpdateUserDefinedItem(
|
||||||
|
this IEnumerable<LibraryBook> lb,
|
||||||
|
string tags = null,
|
||||||
|
LiberatedStatus? bookStatus = null,
|
||||||
|
LiberatedStatus? pdfStatus = null,
|
||||||
|
Rating rating = null)
|
||||||
=> updateUserDefinedItem(
|
=> updateUserDefinedItem(
|
||||||
books,
|
lb,
|
||||||
udi => {
|
udi => {
|
||||||
// blank tags are expected. null tags are not
|
// blank tags are expected. null tags are not
|
||||||
if (tags is not null && udi.Tags != tags)
|
if (tags is not null)
|
||||||
udi.Tags = tags;
|
udi.Tags = tags;
|
||||||
|
|
||||||
if (bookStatus is not null && udi.BookStatus != bookStatus.Value)
|
if (bookStatus.HasValue)
|
||||||
udi.BookStatus = bookStatus.Value;
|
udi.BookStatus = bookStatus.Value;
|
||||||
|
|
||||||
// even though PdfStatus is nullable, there's no case where we'd actually overwrite with null
|
// method handles null logic
|
||||||
if (pdfStatus is not null && udi.PdfStatus != pdfStatus.Value)
|
udi.SetPdfStatus(pdfStatus);
|
||||||
udi.PdfStatus = pdfStatus.Value;
|
|
||||||
});
|
if (rating is not null)
|
||||||
|
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
||||||
|
});
|
||||||
|
|
||||||
|
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version libationVersion)
|
||||||
|
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); });
|
||||||
|
|
||||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
|
|
||||||
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
|
||||||
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
|
|
||||||
=> books.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
|
||||||
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
||||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
|
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
|
||||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
|
|
||||||
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
|
|
||||||
=> book.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
|
|
||||||
public static int UpdatePdfStatus(this IEnumerable<Book> books, LiberatedStatus pdfStatus)
|
|
||||||
=> books.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
|
|
||||||
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
|
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
|
||||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||||
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
|
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
|
||||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
|
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||||
|
|
||||||
public static int UpdateTags(this Book book, string tags)
|
|
||||||
=> book.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
|
||||||
public static int UpdateTags(this IEnumerable<Book> books, string tags)
|
|
||||||
=> books.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
|
||||||
public static int UpdateTags(this LibraryBook libraryBook, string tags)
|
public static int UpdateTags(this LibraryBook libraryBook, string tags)
|
||||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||||
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
|
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
|
||||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||||
|
|
||||||
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
|
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
|
||||||
=> libraryBook.Book.updateUserDefinedItem(action);
|
=> libraryBook.updateUserDefinedItem(action);
|
||||||
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||||
=> libraryBooks.Select(lb => lb.Book).updateUserDefinedItem(action);
|
=> libraryBooks.updateUserDefinedItem(action);
|
||||||
|
|
||||||
public static int UpdateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => book.updateUserDefinedItem(action);
|
private static int updateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action) => new[] { libraryBook }.updateUserDefinedItem(action);
|
||||||
public static int UpdateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action) => books.updateUserDefinedItem(action);
|
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||||
|
|
||||||
private static int updateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => new[] { book }.updateUserDefinedItem(action);
|
|
||||||
private static int updateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (books is null || !books.Any())
|
if (libraryBooks is null || !libraryBooks.Any())
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
foreach (var book in books)
|
foreach (var book in libraryBooks)
|
||||||
action?.Invoke(book.UserDefinedItem);
|
action?.Invoke(book.Book.UserDefinedItem);
|
||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
// Attach() NoTracking entities before SaveChanges()
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
foreach (var book in books)
|
foreach (var book in libraryBooks)
|
||||||
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
{
|
||||||
|
context.Attach(book.Book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
|
context.Attach(book.Book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
|
}
|
||||||
|
|
||||||
var qtyChanges = context.SaveChanges();
|
var qtyChanges = context.SaveChanges();
|
||||||
if (qtyChanges > 0)
|
if (qtyChanges > 0)
|
||||||
BookUserDefinedItemCommitted?.Invoke(null, books);
|
BookUserDefinedItemCommitted?.Invoke(null, libraryBooks);
|
||||||
|
|
||||||
return qtyChanges;
|
return qtyChanges;
|
||||||
}
|
}
|
||||||
@@ -462,49 +546,83 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
// must be here instead of in db layer due to AaxcExists
|
// must be here instead of in db layer due to AaxcExists
|
||||||
public static LiberatedStatus Liberated_Status(Book book)
|
public static LiberatedStatus Liberated_Status(Book book)
|
||||||
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
|
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
|
||||||
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
|
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
|
||||||
: LiberatedStatus.NotLiberated;
|
: LiberatedStatus.NotLiberated;
|
||||||
|
|
||||||
// exists here for feature predictability. It makes sense for this to be where Liberated_Status is
|
// exists here for feature predictability. It makes sense for this to be where Liberated_Status is
|
||||||
public static LiberatedStatus? Pdf_Status(Book book) => book.UserDefinedItem.PdfStatus;
|
public static LiberatedStatus? Pdf_Status(Book book) => book.UserDefinedItem.PdfStatus;
|
||||||
|
|
||||||
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
||||||
|
|
||||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
|
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable)
|
||||||
{
|
{
|
||||||
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
||||||
public bool HasPendingBooks => PendingBooks > 0;
|
public bool HasPendingBooks => PendingBooks > 0;
|
||||||
|
|
||||||
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
|
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError + booksUnavailable);
|
||||||
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
|
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded + pdfsUnavailable);
|
||||||
|
|
||||||
|
public string StatusString => HasPdfResults ? $"{toBookStatusString()} | {toPdfStatusString()}" : toBookStatusString();
|
||||||
|
|
||||||
|
private string toBookStatusString()
|
||||||
|
{
|
||||||
|
if (!HasBookResults) return "No books. Begin by importing your library";
|
||||||
|
|
||||||
|
if (!HasPendingBooks && booksError + booksUnavailable == 0) return $"All {"book".PluralizeWithCount(booksFullyBackedUp)} backed up";
|
||||||
|
|
||||||
|
var sb = new StringBuilder($"BACKUPS: No progress: {booksNoProgress} In process: {booksDownloadedOnly} Fully backed up: {booksFullyBackedUp}");
|
||||||
|
|
||||||
|
if (booksError > 0)
|
||||||
|
sb.Append($" Errors: {booksError}");
|
||||||
|
if (booksUnavailable > 0)
|
||||||
|
sb.Append($" Unavailable: {booksUnavailable}");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string toPdfStatusString()
|
||||||
|
{
|
||||||
|
if (pdfsNotDownloaded + pdfsUnavailable == 0) return $"All {pdfsDownloaded} PDFs downloaded";
|
||||||
|
|
||||||
|
var sb = new StringBuilder($"PDFs: NOT d/l'ed: {pdfsNotDownloaded} Downloaded: {pdfsDownloaded}");
|
||||||
|
|
||||||
|
if (pdfsUnavailable > 0)
|
||||||
|
sb.Append($" Unavailable: {pdfsUnavailable}");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
public static LibraryStats GetCounts()
|
public static LibraryStats GetCounts(IEnumerable<LibraryBook> libraryBooks = null)
|
||||||
{
|
{
|
||||||
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||||
|
|
||||||
var results = libraryBooks
|
var results = libraryBooks
|
||||||
.AsParallel()
|
.AsParallel()
|
||||||
.Select(lb => Liberated_Status(lb.Book))
|
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
|
||||||
.ToList();
|
.ToList();
|
||||||
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
|
|
||||||
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
|
|
||||||
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
|
|
||||||
var booksError = results.Count(r => r == LiberatedStatus.Error);
|
|
||||||
|
|
||||||
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
|
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);
|
||||||
|
var booksDownloadedOnly = results.Count(r => !r.absent && r.status == LiberatedStatus.PartialDownload);
|
||||||
|
var booksNoProgress = results.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||||
|
var booksError = results.Count(r => r.status == LiberatedStatus.Error);
|
||||||
|
var booksUnavailable = results.Count(r => r.absent && r.status is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload);
|
||||||
|
|
||||||
var boolResults = libraryBooks
|
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
|
||||||
.AsParallel()
|
|
||||||
.Where(lb => lb.Book.HasPdf())
|
|
||||||
.Select(lb => Pdf_Status(lb.Book))
|
|
||||||
.ToList();
|
|
||||||
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
|
|
||||||
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
|
|
||||||
|
|
||||||
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
|
var pdfResults = libraryBooks
|
||||||
|
.AsParallel()
|
||||||
|
.Where(lb => lb.Book.HasPdf())
|
||||||
|
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
|
var pdfsDownloaded = pdfResults.Count(r => r.status == LiberatedStatus.Liberated);
|
||||||
}
|
var pdfsNotDownloaded = pdfResults.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||||
}
|
var pdfsUnavailable = pdfResults.Count(r => r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||||
|
|
||||||
|
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
|
||||||
|
|
||||||
|
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 = a.Book.LowestCategoryNames().Any() ? a.Book.LowestCategoryNames().Aggregate((a, b) => $"{a}, {b}") : "",
|
||||||
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,18 @@ 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)
|
|
||||||
);
|
|
||||||
|
|
||||||
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 +82,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>net7.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AudibleApi" Version="7.0.0.4" />
|
<PackageReference Include="AudibleApi" Version="9.3.0.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>net7.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -10,14 +10,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="7.0.0.1" />
|
<PackageReference Include="Dinah.Core" Version="9.0.0.1" />
|
||||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.0.0.2" />
|
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.0">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
|
||||||
<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="7.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
|
||||||
<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
|
||||||
|
|||||||
+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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,14 +37,28 @@ namespace DataLayer
|
|||||||
|
|
||||||
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
|
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
|
||||||
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
|
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
|
||||||
|
=> library
|
||||||
|
.Where(lb => !lb.IsDeleted)
|
||||||
|
.getLibrary();
|
||||||
|
|
||||||
|
public static List<LibraryBook> GetDeletedLibraryBooks(this LibationContext context)
|
||||||
|
=> context
|
||||||
|
.LibraryBooks
|
||||||
|
.AsNoTrackingWithIdentityResolution()
|
||||||
|
.Where(lb => lb.IsDeleted)
|
||||||
|
.getLibrary()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
|
||||||
|
private static IQueryable<LibraryBook> getLibrary(this IQueryable<LibraryBook> library)
|
||||||
=> library
|
=> library
|
||||||
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
|
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
|
||||||
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
||||||
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
|
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
|
||||||
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
|
.Include(le => le.Book).ThenInclude(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
|
||||||
|
|
||||||
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
||||||
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(s => libraryBooks.FindChildren(s));
|
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren);
|
||||||
|
|
||||||
public static IEnumerable<LibraryBook> FindOrphanedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
public static IEnumerable<LibraryBook> FindOrphanedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
||||||
=> libraryBooks
|
=> libraryBooks
|
||||||
@@ -93,8 +107,9 @@ namespace DataLayer
|
|||||||
=> bookList
|
=> bookList
|
||||||
.Where(
|
.Where(
|
||||||
lb =>
|
lb =>
|
||||||
lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
!lb.AbsentFromLastScan &&
|
||||||
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||||
|
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 620 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user