mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-08-07 04:42:17 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef1edf1136 | ||
|
|
0def1b426a | ||
|
|
230e014bb1 | ||
|
|
34f56d2fd7 | ||
|
|
c45ffaf4a6 | ||
|
|
ae43ab103e | ||
|
|
559977ce0b | ||
|
|
ccd4d3e26d | ||
|
|
e76f99ff28 | ||
|
|
d3607583ab | ||
|
|
3ebd4ce243 | ||
|
|
f6dcc0db1d | ||
|
|
bd49db83e4 | ||
|
|
4140722a6d | ||
|
|
da36f9414d | ||
|
|
1510f71ca6 | ||
|
|
cdb27ef712 | ||
|
|
790319ed98 | ||
|
|
1b0fb2b316 | ||
|
|
02371f2221 | ||
|
|
2b672f86be | ||
|
|
36176bff33 | ||
|
|
174b0c26b8 | ||
|
|
26c60e8e79 | ||
|
|
d94759d868 | ||
|
|
bd7e45ca3c | ||
|
|
52a863c62a | ||
|
|
fe55b90ee3 | ||
|
|
df224cc7f3 | ||
|
|
2a59329350 | ||
|
|
abdf0e7261 | ||
|
|
b9c2a1cce3 | ||
|
|
aa86fca08f | ||
|
|
cf9ec9facf | ||
|
|
f6084ef10c | ||
|
|
740b73beb7 | ||
|
|
5c45802391 | ||
|
|
429aa603f5 | ||
|
|
80ea394934 | ||
|
|
bce4437c79 | ||
|
|
b6ad1a289b | ||
|
|
2a22d05f37 | ||
|
|
d787843fd2 | ||
|
|
ded58f687d | ||
|
|
1f1f34b6ce | ||
|
|
ffadf90f4f | ||
|
|
67807efacf | ||
|
|
980f5afa54 | ||
|
|
b2f68760b2 | ||
|
|
faf86711a5 | ||
|
|
4a78b9d28f | ||
|
|
1b0a7f5062 | ||
|
|
49982043e0 | ||
|
|
378cf7057e | ||
|
|
abdc0f018e | ||
|
|
c65f61b92e | ||
|
|
c12805c8ce | ||
|
|
67f9a6db78 | ||
|
|
bb6336ce2a | ||
|
|
af7a4a6acf | ||
|
|
21d18aa565 | ||
|
|
c96875ba5d | ||
|
|
6ebbfb8e59 | ||
|
|
1e6e28cd57 | ||
|
|
defed72862 | ||
|
|
71503b34b5 | ||
|
|
a00849fb6f | ||
|
|
14b63c0883 | ||
|
|
59d556733e | ||
|
|
a99a175683 | ||
|
|
26fedcfb60 | ||
|
|
dde8024506 | ||
|
|
25f7c29380 | ||
|
|
2f347e83e8 | ||
|
|
080a74884d | ||
|
|
2dbeb64c38 | ||
|
|
bb508c0718 | ||
|
|
9a450b0d63 | ||
|
|
c1de0e60d2 | ||
|
|
dc7c03661d | ||
|
|
952eee6d32 | ||
|
|
472a0f30b9 | ||
|
|
73533c58a8 | ||
|
|
65ef018719 | ||
|
|
f0ca349539 | ||
|
|
500b287721 | ||
|
|
21f3ae45d3 | ||
|
|
d496564f0d | ||
|
|
6fdd6293ce | ||
|
|
3bca495521 | ||
|
|
0fb580f1a5 | ||
|
|
a7cd47e0b1 | ||
|
|
30aecedfae | ||
|
|
e72799efe5 | ||
|
|
ee8c0ae27b | ||
|
|
5b4a4341ad | ||
|
|
56823c1105 | ||
|
|
1f4ada604a | ||
|
|
3a4ab80892 | ||
|
|
bba9c2ba7b | ||
|
|
c4acd5d208 | ||
|
|
381440db4c | ||
|
|
00c8be1f7e | ||
|
|
d665122aa2 | ||
|
|
bb40df5fa3 | ||
|
|
e3c9f70dff | ||
|
|
b351033cec | ||
|
|
18f69bc73d | ||
|
|
39fe7b79d2 | ||
|
|
85769d797b | ||
|
|
9a80f18e1c | ||
|
|
aec8305e52 | ||
|
|
a672174a9b | ||
|
|
6f490b4491 | ||
|
|
5917d059e4 | ||
|
|
40602c7626 | ||
|
|
7d5ee2afa8 | ||
|
|
08b6f8fa11 | ||
|
|
5f9699aa3b | ||
|
|
70607aaaf4 | ||
|
|
1d96d39af7 | ||
|
|
5557772957 | ||
|
|
5c7db6cd23 | ||
|
|
c72b64d74c | ||
|
|
20474e0b3c | ||
|
|
867085600c | ||
|
|
74290ec609 | ||
|
|
5ee555e60c | ||
|
|
a36c28d48f | ||
|
|
0877f2c042 | ||
|
|
2baf5243ea | ||
|
|
b7e71f5812 | ||
|
|
2ed1076fab | ||
|
|
0b20aa751f | ||
|
|
05a4ece8d1 | ||
|
|
25b37c6266 | ||
|
|
b668cff0ac | ||
|
|
4d6c742ae9 | ||
|
|
933f663d22 | ||
|
|
0c55f278a4 | ||
|
|
3f567ee82e | ||
|
|
8dc912c11d | ||
|
|
f1b4e2a17d | ||
|
|
630cfdeab3 | ||
|
|
7029409792 | ||
|
|
d0727b5a85 | ||
|
|
9f52ad5e0a | ||
|
|
501ae643f7 | ||
|
|
400074170e | ||
|
|
17103ed066 | ||
|
|
b6b29309c9 | ||
|
|
a04538710f | ||
|
|
01f6f5c137 | ||
|
|
b1a37cbd8c | ||
|
|
8c59e1280b | ||
|
|
00339127aa | ||
|
|
5935b40b60 | ||
|
|
6cfd2dea96 | ||
|
|
7d3a39c693 | ||
|
|
6e7a4ea475 | ||
|
|
3479dbc3f0 | ||
|
|
9309aea6d9 | ||
|
|
f72551fa9a | ||
|
|
e3b237b75f | ||
|
|
b1ddf18f73 | ||
|
|
13f522abb8 | ||
|
|
3c3d956bf3 | ||
|
|
8160547c11 | ||
|
|
ef71d36dee | ||
|
|
b0d8434455 | ||
|
|
9be0d58461 | ||
|
|
1addcc8211 | ||
|
|
38c75dc8c5 | ||
|
|
89c3ea8311 | ||
|
|
18ff799fb1 | ||
|
|
67b6aaed99 | ||
|
|
08bb463560 | ||
|
|
97767dcabb | ||
|
|
fb18940a5c | ||
|
|
b823f5fa00 | ||
|
|
d64fb081a0 | ||
|
|
09118b1ddf | ||
|
|
de20590fd5 | ||
|
|
1050ffdb24 | ||
|
|
2e49c7f697 | ||
|
|
708cdcc24c | ||
|
|
c89eafd568 | ||
|
|
10de241d53 | ||
|
|
e58952035f | ||
|
|
50a8c7508a | ||
|
|
2b243a6934 | ||
|
|
ece93cb4d7 | ||
|
|
5b3ca0ed32 | ||
|
|
7474f1221a | ||
|
|
d023a943c1 | ||
|
|
4e80af5c53 | ||
|
|
eee785377f | ||
|
|
915906e6ed | ||
|
|
358c8b577e | ||
|
|
5c450a01a4 | ||
|
|
36264c6c6e | ||
|
|
fca946bf15 | ||
|
|
452ceef285 | ||
|
|
7fafee804d | ||
|
|
3a48479435 | ||
|
|
cab8555ab5 | ||
|
|
e3b7cbcc2a | ||
|
|
ed15614288 | ||
|
|
acb6d1b335 | ||
|
|
fe804796ab | ||
|
|
4725fe36d1 | ||
|
|
5c73beff4b | ||
|
|
1f7000c2c9 | ||
|
|
f09baa1318 | ||
|
|
7eaa03e43c | ||
|
|
26099303fa | ||
|
|
6417aee780 | ||
|
|
f9deaba4c5 | ||
|
|
ddd6a3b279 | ||
|
|
9359950666 | ||
|
|
d31b2a1b65 | ||
|
|
b89b4e0af4 | ||
|
|
cbcde027b3 | ||
|
|
d306e6bd22 | ||
|
|
9ec877999e | ||
|
|
f4189bf409 | ||
|
|
0ed5062683 | ||
|
|
7ef666dc91 | ||
|
|
1ac825919a | ||
|
|
a7bf30954d | ||
|
|
613cfdd903 | ||
|
|
28802c8279 | ||
|
|
6d7b3bd5f0 | ||
|
|
b97d8e9403 | ||
|
|
b4838d364e | ||
|
|
05ac5c63e1 | ||
|
|
874bf9e7c0 | ||
|
|
c9497ef39e | ||
|
|
496830d01d | ||
|
|
ccebcdd4c7 | ||
|
|
c900fe8461 | ||
|
|
a0158db37e | ||
|
|
b8c26b01ad | ||
|
|
3a44bef0d9 | ||
|
|
57a4ee781b | ||
|
|
e12f475850 | ||
|
|
f822a23daa | ||
|
|
6901b8be35 | ||
|
|
83fb2cd1d0 | ||
|
|
c98664d584 | ||
|
|
d098be8b03 | ||
|
|
3f6689d032 | ||
|
|
b4206fc203 | ||
|
|
cfa4a0c07f | ||
|
|
357b220ace | ||
|
|
47968304c9 | ||
|
|
2024d5e116 | ||
|
|
5ae2a99c14 | ||
|
|
7fd002d2c9 | ||
|
|
b7b7038244 | ||
|
|
b5519c4875 | ||
|
|
44feab9eb2 | ||
|
|
96c45c33e5 | ||
|
|
36efbcb812 | ||
|
|
03f44b4e9c | ||
|
|
19860e9f09 | ||
|
|
0701cb3970 | ||
|
|
7d6000e3b6 | ||
|
|
ef973ac56a | ||
|
|
91a1033c52 | ||
|
|
4197db6af9 | ||
|
|
210ab065c2 | ||
|
|
9cd10eca58 | ||
|
|
ba676be46d | ||
|
|
665a2e1866 | ||
|
|
94469cae3d | ||
|
|
a0dd2ccad6 | ||
|
|
b2cf837de7 | ||
|
|
80bcf60b5b | ||
|
|
7ad0ab566a | ||
|
|
2b16e86c7b | ||
|
|
f2ea02ae0b | ||
|
|
f65cd39040 | ||
|
|
5ca0d2a399 | ||
|
|
d1528a095b | ||
|
|
749173a463 | ||
|
|
6fbd90a6b3 | ||
|
|
f39d272e6a | ||
|
|
bb3854f512 | ||
|
|
e40daecfb8 | ||
|
|
3716ab9cb5 | ||
|
|
0cc6d6337a | ||
|
|
ce711a36ba | ||
|
|
451af7bea9 | ||
|
|
63200592bf | ||
|
|
d165dfbeb5 | ||
|
|
eed3d84517 | ||
|
|
ba7d890966 | ||
|
|
5140fc63d9 | ||
|
|
78509c07e0 | ||
|
|
5084141215 | ||
|
|
3f2ac83474 | ||
|
|
58a0468728 | ||
|
|
8e13aa7513 | ||
|
|
48e2d91fc8 | ||
|
|
a7f119217f | ||
|
|
865f2261fe | ||
|
|
dfedb23efd | ||
|
|
c01e1c3e4b | ||
|
|
ad8dac5fb0 | ||
|
|
84e81b6218 | ||
|
|
86efe631fe | ||
|
|
f5f1dc483b | ||
|
|
8aa4328c6c | ||
|
|
a01a8c4b19 | ||
|
|
4b2387b621 | ||
|
|
74d16d8ef9 | ||
|
|
b1ea8f9fa7 | ||
|
|
c666fdeaff | ||
|
|
7068782975 | ||
|
|
c4cebbebe7 | ||
|
|
53d43d9fa9 | ||
|
|
11d59beeed | ||
|
|
ef71e297f4 | ||
|
|
1e4d1d1973 | ||
|
|
893d99854b | ||
|
|
db93980cd5 | ||
|
|
34fac30b2b | ||
|
|
2fa0bcb765 | ||
|
|
78fd09aa91 | ||
|
|
a54516b4f5 | ||
|
|
f193d6f376 | ||
|
|
8a82c294a1 | ||
|
|
9392cf4bf0 | ||
|
|
ec4deb9099 | ||
|
|
cf0548aab9 | ||
|
|
064801380b | ||
|
|
9dc2a7424a | ||
|
|
4c8a56a5b9 | ||
|
|
9aad263996 | ||
|
|
ce1ab7c20d | ||
|
|
c9217990cd | ||
|
|
90cbf3b7a6 | ||
|
|
c4f1b22ddf | ||
|
|
fb612ea6ab | ||
|
|
bce44b6f6d | ||
|
|
7575736991 | ||
|
|
06f8d055fc | ||
|
|
d64e043fe8 | ||
|
|
99564d9c25 | ||
|
|
29bccd3e33 | ||
|
|
20f65f6534 | ||
|
|
8ca72b2e2d | ||
|
|
75429f288f | ||
|
|
d1bb921346 | ||
|
|
b979b6ddad | ||
|
|
4eba41ddbb | ||
|
|
418f5062ff | ||
|
|
f736f7f909 | ||
|
|
96ead28246 | ||
|
|
34bad7a53d | ||
|
|
7ac1fff3a0 | ||
|
|
a4c5c53df3 | ||
|
|
87db5cfd94 | ||
|
|
85e7bbf366 | ||
|
|
c55c5fac23 | ||
|
|
e25e2f7211 | ||
|
|
f310d583d8 | ||
|
|
f05465b29b | ||
|
|
959e31972e | ||
|
|
17181811f0 | ||
|
|
6d2624d52b | ||
|
|
9dd5940c8c | ||
|
|
1927d19961 | ||
|
|
09cc838bb4 | ||
|
|
8af4c71101 | ||
|
|
7ffdf45164 | ||
|
|
e0999dc9ae | ||
|
|
a0f3d44e97 | ||
|
|
1510a86579 | ||
|
|
b3581455d2 | ||
|
|
8ee1019fa5 | ||
|
|
285b10a95f | ||
|
|
0ca33f864b | ||
|
|
a0823fa26c | ||
|
|
aa9040da5d | ||
|
|
222031ecc5 | ||
|
|
dda8f5a974 | ||
|
|
e9b484df04 | ||
|
|
d505264e86 | ||
|
|
c0b1f1dc0a | ||
|
|
1524d558a4 | ||
|
|
aea8c11dc4 | ||
|
|
86c7f89788 | ||
|
|
3272541e81 | ||
|
|
3b3d40e4e6 | ||
|
|
a47866b6f7 | ||
|
|
0df4dfdef5 | ||
|
|
fe2de6ecf7 | ||
|
|
fc25e73b1a | ||
|
|
a3df85c87e | ||
|
|
553a936e7e | ||
|
|
635764625e | ||
|
|
f5599f7c57 | ||
|
|
dc6aaf2dd6 | ||
|
|
f1ba2b4ae8 | ||
|
|
742310b8d6 | ||
|
|
073787173d | ||
|
|
66679ace2f | ||
|
|
3982537d46 | ||
|
|
7cf4c63d79 | ||
|
|
7c4575cf66 | ||
|
|
f4749d703f | ||
|
|
f2f562619b | ||
|
|
16c019a9c6 | ||
|
|
644dcbdd4d | ||
|
|
6b112f5248 | ||
|
|
0bfa609058 | ||
|
|
8020ded642 | ||
|
|
c4cd6b16fc | ||
|
|
310012fd17 | ||
|
|
06163db6ff | ||
|
|
7689eed711 | ||
|
|
d396d697d7 | ||
|
|
27ed11d904 | ||
|
|
9e7670b918 | ||
|
|
31e97defd1 | ||
|
|
1a447627c7 | ||
|
|
962b386d07 | ||
|
|
d69ff24c2d | ||
|
|
070ed1d373 | ||
|
|
47729bf7b0 | ||
|
|
ed0ce2976b | ||
|
|
2224f46ed5 | ||
|
|
433974323c | ||
|
|
7525d318c0 | ||
|
|
92327dcc0d | ||
|
|
aeaf234edd | ||
|
|
a99b644917 | ||
|
|
d79a55e5c9 | ||
|
|
16b0feeb82 | ||
|
|
7b3a25e45a | ||
|
|
8effdcb92d | ||
|
|
b12bef81bd | ||
|
|
f04a5e0168 | ||
|
|
e093729707 | ||
|
|
369151ada2 | ||
|
|
1f685ae8a0 | ||
|
|
bbe91099cb | ||
|
|
92015ba4c2 | ||
|
|
3bcacabadc | ||
|
|
f5736d9151 | ||
|
|
59015f438e | ||
|
|
3af47ab395 | ||
|
|
308619b01a | ||
|
|
4efce57488 | ||
|
|
c8ee950f7d | ||
|
|
0bba0f9256 | ||
|
|
05bdff5123 | ||
|
|
e58e6cfb9f | ||
|
|
b052871004 | ||
|
|
d738f4f35f | ||
|
|
7286aee9dd | ||
|
|
ca455978a5 | ||
|
|
9c38bea5b7 | ||
|
|
fbec1bc569 | ||
|
|
6dd885f0b2 | ||
|
|
ab38eb5571 | ||
|
|
0e4b9ab396 | ||
|
|
7dfedbc73b | ||
|
|
625ae1d63c | ||
|
|
71098ef02f | ||
|
|
d63a6de543 | ||
|
|
2a71a85306 | ||
|
|
6de3a8a2bf | ||
|
|
3fc1da66de | ||
|
|
683c221ca8 | ||
|
|
fe6cfc899b | ||
|
|
ffd947eb2e | ||
|
|
8dd59cb08a | ||
|
|
1e4c489983 | ||
|
|
17b0da358f | ||
|
|
6aa0a1f8b9 | ||
|
|
ab731a63af | ||
|
|
07d2c656fc | ||
|
|
9ecb32c3d2 | ||
|
|
503e1e143e | ||
|
|
e34ce67a2c | ||
|
|
a0fd0a3de6 | ||
|
|
7f3cbc454f | ||
|
|
30eb117fa1 | ||
|
|
63877160aa | ||
|
|
77e61479cf | ||
|
|
ca71283108 | ||
|
|
285563af5e | ||
|
|
62cbad0d8f | ||
|
|
2cb2479d63 | ||
|
|
e7c5b1d8dc | ||
|
|
7f086aeaac | ||
|
|
78186d4973 | ||
|
|
4d84174ba6 | ||
|
|
579536f65a | ||
|
|
a4ff739684 | ||
|
|
9e06c70319 | ||
|
|
0c98ce000b | ||
|
|
230b23dc80 | ||
|
|
d55b8eeeba | ||
|
|
decf75411f | ||
|
|
c69f14dac5 | ||
|
|
10359aa5e8 | ||
|
|
72e030faaf | ||
|
|
b21055d0ea | ||
|
|
720fd64c97 | ||
|
|
e9a331292a | ||
|
|
51fee4ae24 | ||
|
|
4cfe72a63b | ||
|
|
6a8476c976 | ||
|
|
8bb17d09c3 | ||
|
|
ad6b86fcb4 | ||
|
|
1578be2520 | ||
|
|
82d8d954ef | ||
|
|
eff9c2b35d | ||
|
|
ccdd1dc9f3 | ||
|
|
952173d450 | ||
|
|
35f677a0fa | ||
|
|
51d0645699 | ||
|
|
0189a197a8 | ||
|
|
1ce5fedc8c | ||
|
|
d336848ed0 | ||
|
|
8cd6219bd9 | ||
|
|
c2a2e51bde | ||
|
|
d62821cd60 | ||
|
|
180d591b0a | ||
|
|
7b7e1d8574 | ||
|
|
efd6156fa8 | ||
|
|
428ea5e864 | ||
|
|
2b6d1201b6 | ||
|
|
de3524d688 | ||
|
|
61a529e62b | ||
|
|
a5d225dc44 | ||
|
|
7b28a274a8 | ||
|
|
26508e6a8a | ||
|
|
c8d91032c0 | ||
|
|
7a8e910697 | ||
|
|
31d6fc8197 | ||
|
|
e23e267d17 | ||
|
|
c727286d22 | ||
|
|
3a61c32881 | ||
|
|
e33fd6ea1b | ||
|
|
aa8e3ac09b | ||
|
|
eb49dcfc54 | ||
|
|
6182b2bcee | ||
|
|
6e091230cf | ||
|
|
5f45d28b9f | ||
|
|
f8e9c16bc1 | ||
|
|
a66b7a6eab | ||
|
|
3b42b52ff4 | ||
|
|
df5293ce1e | ||
|
|
664ff6aabd | ||
|
|
0de62ce010 | ||
|
|
9eafbacad9 | ||
|
|
058eb31110 | ||
|
|
29de8f5706 | ||
|
|
ef869dbe09 | ||
|
|
9f8b320493 | ||
|
|
ef72e04be3 | ||
|
|
38d280b7f4 | ||
|
|
468356d676 | ||
|
|
7364700899 | ||
|
|
e65f19cf24 | ||
|
|
4272dfe03d | ||
|
|
3b739328fb | ||
|
|
81c3dca740 | ||
|
|
dceb3121b1 | ||
|
|
cb60a97b91 | ||
|
|
eb658396d2 | ||
|
|
0a1cefdb76 | ||
|
|
fb618e6719 | ||
|
|
2d529539cd | ||
|
|
9d93a98a58 | ||
|
|
38dcb10a6e | ||
|
|
50651339ec | ||
|
|
d0b2889fec | ||
|
|
3ce1f94f87 | ||
|
|
888967be31 | ||
|
|
6826237657 | ||
|
|
a8987cf1d3 | ||
|
|
d48a74912a | ||
|
|
1668b7c9a1 | ||
|
|
efa2cfb50b | ||
|
|
071b1a54d5 | ||
|
|
7c3bba2ffd | ||
|
|
d58092968a | ||
|
|
1b20bb06ad | ||
|
|
5815a04712 | ||
|
|
85c449bec0 | ||
|
|
10bdddb262 | ||
|
|
b65875386d | ||
|
|
76b5e09f72 | ||
|
|
0fe07695b2 | ||
|
|
51f9b4f473 | ||
|
|
153e1b92bf | ||
|
|
fc5ae7403a | ||
|
|
13149eff08 | ||
|
|
9c53d9bf87 | ||
|
|
bc9625fece | ||
|
|
7e00162ef2 | ||
|
|
af38750e29 | ||
|
|
314f4850bc | ||
|
|
9ff2a83ba3 | ||
|
|
2ab466c570 | ||
|
|
184ba84600 | ||
|
|
99dddb1af4 | ||
|
|
48eca3f5af | ||
|
|
71192cc2ee | ||
|
|
29c7344540 | ||
|
|
6411d23744 | ||
|
|
1a74736115 | ||
|
|
7c11ecb3a7 | ||
|
|
fd7c833de0 | ||
|
|
7fec8b0d7e | ||
|
|
52622fadbb | ||
|
|
57255e0aec | ||
|
|
17ecfa132d | ||
|
|
d1365c3d7d | ||
|
|
c33891a4bc | ||
|
|
9a63f57147 | ||
|
|
839a62cb07 | ||
|
|
dc598e466e | ||
|
|
b698697256 | ||
|
|
f802d1524f | ||
|
|
0cb18f9e1a | ||
|
|
ba722487d8 | ||
|
|
eff2634b32 | ||
|
|
1470aefd42 |
No files matched your search
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve Libation
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Platform**
|
||||||
|
|
||||||
|
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
||||||
|
|
||||||
|
**Log Files**
|
||||||
|
Attach your Libation log file here.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Maintain dependencies for GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_CONFIGURATION: 'Release'
|
||||||
|
DOTNET_VERSION: '7.0.x'
|
||||||
|
RELEASE_NAME: 'chardonnay'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest]
|
||||||
|
arch: [x64, arm64]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v3
|
||||||
|
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: |
|
||||||
|
os=${{ matrix.os }}
|
||||||
|
target_os="$(echo ${os/-latest/} | sed 's/ubuntu/linux/')"
|
||||||
|
display_os="$(echo ${target_os/macos/macOS} | sed 's/linux/Linux/')"
|
||||||
|
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
|
||||||
|
RUNTIME_IDENTIFIER="$(echo ${target_os/macos/osx})-${{ matrix.arch }}"
|
||||||
|
echo "$RUNTIME_IDENTIFIER"
|
||||||
|
dotnet publish \
|
||||||
|
LibationAvalonia/LibationAvalonia.csproj \
|
||||||
|
--runtime "$RUNTIME_IDENTIFIER" \
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
|
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
||||||
|
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||||
|
dotnet publish \
|
||||||
|
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
|
||||||
|
--runtime "$RUNTIME_IDENTIFIER" \
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
|
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
||||||
|
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
|
||||||
|
dotnet publish \
|
||||||
|
LibationCli/LibationCli.csproj \
|
||||||
|
--runtime "$RUNTIME_IDENTIFIER" \
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
|
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
||||||
|
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||||
|
dotnet publish \
|
||||||
|
HangoverAvalonia/HangoverAvalonia.csproj \
|
||||||
|
--runtime "$RUNTIME_IDENTIFIER" \
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
|
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
||||||
|
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||||
|
- name: Build bundle
|
||||||
|
id: bundle
|
||||||
|
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ matrix.arch }}-${{ env.RELEASE_NAME }}
|
||||||
|
run: |
|
||||||
|
BUNDLE_DIR=$(pwd)
|
||||||
|
echo "Bundle dir: ${BUNDLE_DIR}"
|
||||||
|
cd ..
|
||||||
|
SCRIPT=../../../Scripts/Bundle_${{ steps.publish.outputs.display_os }}.sh
|
||||||
|
chmod +rx ${SCRIPT}
|
||||||
|
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ matrix.arch }}"
|
||||||
|
artifact=$(ls ./bundle)
|
||||||
|
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
||||||
|
- name: Publish bundle
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: ${{ steps.bundle.outputs.artifact }}
|
||||||
|
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 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: '7.0.x'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
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@v3
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v3
|
||||||
|
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/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
|
dotnet publish `
|
||||||
|
LibationCli/LibationCli.csproj `
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
|
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||||
|
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
|
dotnet publish `
|
||||||
|
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
|
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||||
|
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
|
|
||||||
|
- 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@v3
|
||||||
|
with:
|
||||||
|
name: ${{ steps.zip.outputs.artifact }}.zip
|
||||||
|
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 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:
|
||||||
|
uses: ./.github/workflows/build-linux.yml
|
||||||
|
with:
|
||||||
|
version_override: ${{ inputs.version_override }}
|
||||||
|
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 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
|
||||||
|
secrets:
|
||||||
|
docker_username:
|
||||||
|
required: true
|
||||||
|
docker_token:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_IMAGE: ${{ secrets.docker_username }}/libation
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.docker_username }}
|
||||||
|
password: ${{ secrets.docker_token }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
build-args: 'FOLDER_NAME=Linux-chardonnay'
|
||||||
|
tags: ${{ env.DOCKER_IMAGE }}:latest,${{ env.DOCKER_IMAGE }}:${{ inputs.version }}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 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 }}
|
||||||
|
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@v3
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
id: release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
name: Libation v${{ needs.prerelease.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.release.outputs.id }}'
|
||||||
|
assets_path: ./artifacts
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 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
|
||||||
+2
-1
@@ -184,7 +184,7 @@ publish/
|
|||||||
*.azurePubxml
|
*.azurePubxml
|
||||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
# but database connection strings (with potential passwords) will be unencrypted
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
*.pubxml
|
#*.pubxml
|
||||||
*.publishproj
|
*.publishproj
|
||||||
|
|
||||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
@@ -370,3 +370,4 @@ FodyWeavers.xsd
|
|||||||
|
|
||||||
/__TODO.txt
|
/__TODO.txt
|
||||||
/DataLayer/LibationContext.db
|
/DataLayer/LibationContext.db
|
||||||
|
*/bin-Avalonia
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
||||||
|
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
|
||||||
|
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
|
||||||
|
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz",
|
||||||
|
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Rewire echo to print date time
|
||||||
|
echo() {
|
||||||
|
if [[ -n $1 ]]; then
|
||||||
|
printf "$(date '+%F %T'): %s\n" "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ################################
|
||||||
|
# Setup
|
||||||
|
# ################################
|
||||||
|
echo "Starting"
|
||||||
|
if [[ -z "${SLEEP_TIME}" ]]; then
|
||||||
|
echo "No sleep time passed in. Will run once and exit."
|
||||||
|
else
|
||||||
|
echo "Sleep time is set to ${SLEEP_TIME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if the config directory is passed in, and there is no link to it then create the link.
|
||||||
|
if [ -d "/config" ] && [ ! -d "/root/Libation" ]; then
|
||||||
|
echo "Linking config directory to the Libation config directory"
|
||||||
|
ln -s /config/ /root/Libation
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If no config error and exit
|
||||||
|
if [ ! -d "/config" ]; then
|
||||||
|
echo "ERROR: No /config directory. You must pass in a volume containing your config mapped to /config"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If user passes in db from a /db/ folder and a db does not already exist / is not already linked
|
||||||
|
FILE=/db/LibationContext.db
|
||||||
|
if [ -f "${FILE}" ] && [ ! -f "/config/LibationContext.db" ]; then
|
||||||
|
echo "Linking passed in Libation database from /db/ to the Libation config directory"
|
||||||
|
ln -s $FILE /config/LibationContext.db
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Confirm we have a db in the config direcotry.
|
||||||
|
if [ ! -f "/config/LibationContext.db" ]; then
|
||||||
|
echo "ERROR: No Libation database detected, exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ################################
|
||||||
|
# Loop and liberate
|
||||||
|
# ################################
|
||||||
|
while true
|
||||||
|
do
|
||||||
|
echo ""
|
||||||
|
echo "Scanning accounts"
|
||||||
|
/libation/LibationCli scan
|
||||||
|
echo "Liberating books"
|
||||||
|
/libation/LibationCli liberate
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Liberate only once if SLEEP_TIME was set to -1
|
||||||
|
if [ "${SLEEP_TIME}" = -1 ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Sleeping for ${SLEEP_TIME}"
|
||||||
|
sleep "${SLEEP_TIME}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Exiting"
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Dockerfile
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
|
||||||
|
|
||||||
|
COPY Source /Source
|
||||||
|
RUN dotnet publish -c Release -o /Source/bin/Publish/Linux-chardonnay /Source/LibationCli/LibationCli.csproj -p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
|
||||||
|
COPY Docker/liberate.sh /Source/bin/Publish/Linux-chardonnay
|
||||||
|
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:7.0
|
||||||
|
|
||||||
|
ENV SLEEP_TIME "30m"
|
||||||
|
|
||||||
|
# Sets the character set that will be used for folder and filenames when liberating
|
||||||
|
ENV LANG C.UTF-8
|
||||||
|
ENV LC_ALL C.UTF-8
|
||||||
|
|
||||||
|
RUN mkdir /db /config /data
|
||||||
|
|
||||||
|
COPY --from=build-env /Source/bin/Publish/Linux-chardonnay /libation
|
||||||
|
|
||||||
|
|
||||||
|
CMD ["./libation/liberate.sh"]
|
||||||
+13
-13
@@ -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**.
|
||||||
|
|
||||||
|
|
||||||
@@ -8,9 +8,8 @@
|
|||||||
# Advanced: Table of Contents
|
# Advanced: Table of Contents
|
||||||
|
|
||||||
- [Files and folders](#files-and-folders)
|
- [Files and folders](#files-and-folders)
|
||||||
- [Linux and Mac (unofficial)](#linux-and-mac)
|
|
||||||
- [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)
|
||||||
|
|
||||||
|
|
||||||
@@ -25,20 +24,10 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
|
|||||||
|
|
||||||
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
|
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
|
||||||
|
|
||||||
### Linux and Mac
|
|
||||||
|
|
||||||
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592))
|
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
|
|
||||||
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
|
* 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 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`.
|
|
||||||
|
|
||||||
These templates apply to GUI and CLI.
|
|
||||||
|
|
||||||
### Command Line Interface
|
### Command Line Interface
|
||||||
|
|
||||||
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
|
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
|
||||||
@@ -76,4 +65,15 @@ export library to file
|
|||||||
libationcli export -p "C:\foo\bar\my.csv" -c
|
libationcli export -p "C:\foo\bar\my.csv" -c
|
||||||
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
|
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
|
||||||
libationcli export -p "C:\foo\bar\my.xlsx" -x
|
libationcli export -p "C:\foo\bar\my.xlsx" -x
|
||||||
|
|
||||||
|
Set download statuses throughout library based on whether each book's audio file can be found.
|
||||||
|
Must include at least one flag: --downloaded , --not-downloaded.
|
||||||
|
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
|
||||||
|
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
|
||||||
|
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
|
||||||
|
CLI: Full library. No prompt
|
||||||
|
|
||||||
|
libationcli set-status -d
|
||||||
|
libationcli set-status -n
|
||||||
|
libationcli set-status -d -n
|
||||||
```
|
```
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
## [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**.
|
||||||
|
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
In order to use the docker image, you'll need to provide it with a copy of the `AccountsSettings.json`, `Settings.json`, and `LibationContext.db` files. These files can usually be found in the Libation folder in your user's home directory. If you haven't run Libation yet, you'll need to launch it to generate these files and setup your accounts. Once you have them, copy these files to a new location, such as `/opt/libation/config`. Before using them we'll need to make a couple edits so that the filepaths referenced are correct when running from the docker image.
|
||||||
|
|
||||||
|
In Settings.json, make the following changes:
|
||||||
|
* Change `Books` to `/data`
|
||||||
|
* Change `InProgress` to `/tmp`
|
||||||
|
|
||||||
|
### Running
|
||||||
|
Once the configuration files are copied and edited, the docker image can be run with the following command.
|
||||||
|
```
|
||||||
|
sudo docker run -d \
|
||||||
|
-v /opt/libation/config:/config \
|
||||||
|
-v /opt/libation/books:/data \
|
||||||
|
--name libation \
|
||||||
|
--restart=always \
|
||||||
|
rmcrackan/libation
|
||||||
|
```
|
||||||
|
|
||||||
|
By default the container will scan for new books every 30 minutes and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. Additionally, if you pass in `-1` it will scan and download books once and then exit.
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo docker run -d \
|
||||||
|
-v /opt/libation/config:/config \
|
||||||
|
-v /opt/libation/books:/data \
|
||||||
|
-e SLEEP_TIME='10m' \
|
||||||
|
--name libation \
|
||||||
|
--restart=always \
|
||||||
|
rmcrackan/libation
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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,9 +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
|
||||||
|
|
||||||
To install Libation, extract the zip file to a folder, for example `C:\Libation`, and then run Libation.exe from that folder to begin the configuration process and configure your account(s).
|
* Windows
|
||||||
|
|
||||||
|
Extract the zip file to a folder and then run `Libation.exe` from inside of that folder. Do not put it in Program Files. The inability to edit files from there causes problems with configuration and updating.
|
||||||
|
|
||||||
|
* [Ubuntu Linux](InstallOnLinux.md)
|
||||||
|
* [MacOS](InstallOnMac.md)
|
||||||
|
|
||||||
### Create Accounts
|
### Create Accounts
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## [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**.
|
||||||
|
|
||||||
|
|
||||||
|
### Install and Run Libation on Ubuntu
|
||||||
|
|
||||||
|
New Libation releases are automatically packed into a debian package and are available from the Libation repository's releases page.
|
||||||
|
|
||||||
|
Run this command in your terminal to dowbnload and install Libation, replacing the url with the Latest Libation .deb package url:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
## [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**.
|
||||||
|
|
||||||
|
|
||||||
|
# Run Libation on MacOS
|
||||||
|
This walkthrough should get you up and running with Libation on your Mac.
|
||||||
|
|
||||||
|
## Install Libation
|
||||||
|
|
||||||
|
- Download the file from the latest release and extract it.
|
||||||
|
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz`
|
||||||
|
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
|
||||||
|
- Move the extracted Libation app bundle to your applications folder.
|
||||||
|
- Open a terminal (Go > Utilities > Terminal)
|
||||||
|
- Copy/paste/run the following command (you'll be prompted to enter your password)
|
||||||
|
|
||||||
|
```Console
|
||||||
|
sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable
|
||||||
|
```
|
||||||
|
- Close the terminal and use Libation!
|
||||||
|
|
||||||
|
## Running Hangover
|
||||||
|
|
||||||
|
Libation comes with a recovery app called Hangover. You can start it by running this command:
|
||||||
|
```Console
|
||||||
|
open /Applications/Libation.app --args hangover
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runnign LibationCli
|
||||||
|
|
||||||
|
Libation comes with a command-line interface. Unfortunately, due to the way apps are sandboxed on mac, its use is somewhat limited. To open a new sandboxed terminal in LibationCli's directory, run the following command:
|
||||||
|
```Console
|
||||||
|
open /Applications/Libation.app --args cli
|
||||||
|
```
|
||||||
|
To use LibationCli from an unsandboxed terminal, you must disable gatekeeper again and run the program directly at `/Applications/Libation.app/Contents/MacOS/LibationCli`
|
||||||
|
|
||||||
|
Then use `./LibationCli` to execute a command.
|
||||||
|
|
||||||
|
## Get Libation running on Mac
|
||||||
|
|
||||||
|
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/219271379-a922e4e1-48a0-48e4-bd81-48aa1226a4f5.mp4)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Naming Templates
|
||||||
|
File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options.
|
||||||
|
|
||||||
|
These templates apply to both GUI and CLI.
|
||||||
|
|
||||||
|
# Table of Contents
|
||||||
|
|
||||||
|
- [Template Tags](#template-tags)
|
||||||
|
- [Property Tags](#property-tags)
|
||||||
|
- [Conditional Tags](#conditional-tags)
|
||||||
|
- [Tag Formatters](#tag-formatters)
|
||||||
|
- [Text Formatters](#text-formatters)
|
||||||
|
- [Name List Formatters](#name-list-formatters)
|
||||||
|
- [Integer Formatters](#integer-formatters)
|
||||||
|
- [Date Formatters](#date-formatters)
|
||||||
|
|
||||||
|
|
||||||
|
# Template Tags
|
||||||
|
|
||||||
|
These are the naming template tags currently supported by Libation.
|
||||||
|
|
||||||
|
## Property Tags
|
||||||
|
These tags will be replaced in the template with the audiobook's values.
|
||||||
|
|
||||||
|
|Tag|Description|Type|
|
||||||
|
|-|-|-|
|
||||||
|
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
||||||
|
|\<title\>|Full title|Text|
|
||||||
|
|\<title short\>|Title. Stop at first colon|Text|
|
||||||
|
|\<author\>|Author(s)|Name List|
|
||||||
|
|\<first author\>|First author|Text|
|
||||||
|
|\<narrator\>|Narrator(s)|Name List|
|
||||||
|
|\<first narrator\>|First narrator|Text|
|
||||||
|
|\<series\>|Name of series|Text|
|
||||||
|
|\<series#\>|Number order in series|Text|
|
||||||
|
|\<bitrate\>|File's original bitrate (Kbps)|Integer|
|
||||||
|
|\<samplerate\>|File's original audio sample rate|Integer|
|
||||||
|
|\<channels\>|Number of audio channels|Integer|
|
||||||
|
|\<account\>|Audible account of this book|Text|
|
||||||
|
|\<locale\>|Region/country|Text|
|
||||||
|
|\<year\>|Year published|Integer|
|
||||||
|
|\<language\>|Book's language|Text|
|
||||||
|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
||||||
|
|\<file date\>|File creation date/time.|DateTime|
|
||||||
|
|\<pub date\>|Audiobook publication date|DateTime|
|
||||||
|
|\<date added\>|Date the book added to your Audible account|DateTime|
|
||||||
|
|\<ch count\> **‡**|Number of chapters|Integer|
|
||||||
|
|\<ch title\> **‡**|Chapter title|Text|
|
||||||
|
|\<ch#\> **‡**|Chapter number|Integer|
|
||||||
|
|\<ch# 0\> **‡**|Chapter number with leading zeros|Integer|
|
||||||
|
|
||||||
|
**†** Does not support custom formatting
|
||||||
|
|
||||||
|
**‡** Only valid for Chapter Filename and Chapter Tile Metadata
|
||||||
|
|
||||||
|
To change how these properties are displayed, [read about custom formatters](#tag-formatters)
|
||||||
|
|
||||||
|
## Conditional Tags
|
||||||
|
Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
|
||||||
|
|
||||||
|
|Tag|Description|Type|
|
||||||
|
|-|-|-|
|
||||||
|
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|
||||||
|
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|
||||||
|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|
||||||
|
|
||||||
|
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
|
||||||
|
|
||||||
|
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
|
||||||
|
|
||||||
|
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
|
||||||
|
|
||||||
|
\<if podcast-\>Podcasts<-if podcast\>\<!if podcast-\>Books\<-if podcast\>\\\<title\>
|
||||||
|
|
||||||
|
|
||||||
|
# Tag Formatters
|
||||||
|
**Text**, **Name List**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
|
||||||
|
|
||||||
|
## Text Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet꞉ a sherlock holmes novel|
|
||||||
|
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
|
||||||
|
|
||||||
|
## Name List Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|separator()|Speficy the text used to join multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|
||||||
|
|format(\{T \| F \| M \| L \| S\})|Formats the human name using the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\} |`<author[format({L}, {F}) separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|
||||||
|
|sort(F \| M \| L)|Sorts the names by first, middle, or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|
||||||
|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
|
||||||
|
|
||||||
|
## Integer Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|# (a number)|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
|
||||||
|
|
||||||
|
## Date Formatters
|
||||||
|
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
|
||||||
|
### Standard DateTime Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|s|Sortable date/time pattern.|\<file date[s]\>|2023-02-14T13:45:30|
|
||||||
|
|Y|Year month pattern.|\<file date[Y]\>|February 2023|
|
||||||
|
|
||||||
|
### Custom DateTime Formatters
|
||||||
|
You can use custom formatters to construct customized DateTime string. For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|yyyy|4-digit year|\<file date[yyyy]\>|2023|
|
||||||
|
|yy|2-digit year|\<file date[yy]\>|23|
|
||||||
|
|MM|2-digit month|\<file date[MM]\>|02|
|
||||||
|
|dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14|
|
||||||
|
|HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45|
|
||||||
|
|
||||||
|
|
||||||
@@ -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**.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,28 @@
|
|||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
|
||||||
|
<path id="glass" d=
|
||||||
|
"M139,2
|
||||||
|
A 192,200 0 0 0 103,84
|
||||||
|
A 222,334 41 0 0 241,320
|
||||||
|
V478
|
||||||
|
H160
|
||||||
|
A 16,16 0 0 0 160,510
|
||||||
|
H352
|
||||||
|
A16 16 0 0 0 352,478
|
||||||
|
H271
|
||||||
|
V320
|
||||||
|
A 222,334 -41 0 0 409,84
|
||||||
|
A 192,200 0 0 0 373,2
|
||||||
|
M355,32
|
||||||
|
A 192,200 0 0 1 381,127
|
||||||
|
A 187.5,334 -35 0 1 256,286
|
||||||
|
A 187.5,334 35 0 1 131,127
|
||||||
|
A 192,200 0 0 1 157,32
|
||||||
|
H355
|
||||||
|
z" />
|
||||||
|
|
||||||
|
<path id="wine-level" d=
|
||||||
|
"M146,128
|
||||||
|
A 168,300 35 0 0 256,270
|
||||||
|
A 168,300 -35 0 0 366,128
|
||||||
|
z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 585 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,7 +2,7 @@
|
|||||||
|
|
||||||
## [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
|
||||||
@@ -27,10 +27,10 @@
|
|||||||
- [Filters](Documentation/SearchingAndFiltering.md#filters)
|
- [Filters](Documentation/SearchingAndFiltering.md#filters)
|
||||||
- [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)
|
||||||
- [Linux and Mac (unofficial)](Documentation/Advanced.md#linux-and-mac)
|
|
||||||
- [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)
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
|
|
||||||
### The bad
|
### The bad
|
||||||
|
|
||||||
* Windows only
|
* 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
|
||||||
|
|
||||||
@@ -66,4 +66,4 @@
|
|||||||
|
|
||||||
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk.
|
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk.
|
||||||
|
|
||||||
I made this for myself and I want to share it with the great programming and audible/audiobook communiites which have been so generous with their time and help.
|
I made this for myself and I want to share it with the great programming and audible/audiobook communities which have been so generous with their time and help.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#!/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
|
||||||
|
# workaround until this file is moved to the user's home directory
|
||||||
|
touch /usr/lib/libation/appsettings.json
|
||||||
|
chmod 666 /usr/lib/libation/appsettings.json
|
||||||
|
" >> $FOLDER_DEBIAN/postinst
|
||||||
|
|
||||||
|
echo "Creating control file..."
|
||||||
|
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,114 @@
|
|||||||
|
#!/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')
|
||||||
|
else
|
||||||
|
delfiles+=('libmp3lame.arm64.dylib' 'ffmpegaac.arm64.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!"
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
<PackageReference Include="AAXClean.Codecs" Version="0.2.10" />
|
<DebugType>embedded</DebugType>
|
||||||
</ItemGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AAXClean.Codecs" Version="1.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||||
</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,9 +22,17 @@ 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);
|
||||||
|
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||||
|
|
||||||
if (DownloadOptions.StripUnabridged)
|
if (DownloadOptions.StripUnabridged)
|
||||||
{
|
{
|
||||||
@@ -32,29 +40,22 @@ namespace AaxDecrypter
|
|||||||
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
|
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (DownloadOptions.FixupFile)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddTag("TCOM", AaxFile.AppleTags.Narrator);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
|
||||||
|
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("©", "©");
|
||||||
|
}
|
||||||
|
|
||||||
//Finishing configuring lame encoder.
|
//Finishing configuring lame encoder.
|
||||||
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||||
{
|
MpegUtil.ConfigureLameOptions(
|
||||||
double bitrateMultiple = 1;
|
AaxFile,
|
||||||
|
DownloadOptions.LameConfig,
|
||||||
if (AaxFile.AudioChannels == 2)
|
DownloadOptions.Downsample,
|
||||||
{
|
DownloadOptions.MatchSourceBitrate);
|
||||||
if (DownloadOptions.Downsample)
|
|
||||||
bitrateMultiple = 0.5;
|
|
||||||
else
|
|
||||||
DownloadOptions.LameConfig.Mode = NAudio.Lame.MPEGMode.Stereo;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DownloadOptions.MatchSourceBitrate)
|
|
||||||
{
|
|
||||||
int kbps = (int)(AaxFile.AverageBitrate * bitrateMultiple / 1024);
|
|
||||||
|
|
||||||
if (DownloadOptions.LameConfig.VBR is null)
|
|
||||||
DownloadOptions.LameConfig.BitRate = kbps;
|
|
||||||
else if (DownloadOptions.LameConfig.VBR == NAudio.Lame.VBRMode.ABR)
|
|
||||||
DownloadOptions.LameConfig.ABRRateKbps = kbps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
||||||
@@ -65,58 +66,5 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
return !IsCanceled;
|
return !IsCanceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected DownloadProgress Step_DownloadAudiobook_Start()
|
|
||||||
{
|
|
||||||
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,32 +1,24 @@
|
|||||||
using System;
|
using AAXClean;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using AAXClean;
|
|
||||||
using AAXClean.Codecs;
|
using AAXClean.Codecs;
|
||||||
using Dinah.Core;
|
|
||||||
using Dinah.Core.StepRunner;
|
|
||||||
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
|
||||||
{
|
{
|
||||||
protected override StepSequence Steps { get; }
|
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
|
||||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
private FileStream workingFileStream;
|
||||||
private List<string> multiPartFilePaths { get; } = new List<string>();
|
|
||||||
|
|
||||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions)
|
: base(outFileName, cacheDirectory, dlOptions)
|
||||||
{
|
{
|
||||||
Steps = new StepSequence
|
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
|
||||||
{
|
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||||
Name = "Download and Convert Aaxc To " + DownloadOptions.OutputFormat,
|
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
|
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||||
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
|
|
||||||
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsMultipleFilesPerChapter,
|
|
||||||
["Step 3: Cleanup"] = Step_Cleanup,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -53,10 +45,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 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.
|
||||||
@@ -79,77 +69,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 = ConvertToMultiMp4a(splitChapters);
|
|
||||||
else
|
|
||||||
result = ConvertToMultiMp3(splitChapters);
|
|
||||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
|
||||||
|
|
||||||
Step_DownloadAudiobook_End(zeroProgress);
|
|
||||||
|
|
||||||
return result == ConversionResult.NoErrorsDetected;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ConversionResult ConvertToMultiMp4a(ChapterInfo splitChapters)
|
|
||||||
{
|
|
||||||
var chapterCount = 0;
|
|
||||||
return AaxFile.ConvertToMultiMp4a
|
|
||||||
(
|
|
||||||
splitChapters,
|
|
||||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
|
||||||
DownloadOptions.TrimOutputToChapterLength
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ConversionResult ConvertToMultiMp3(ChapterInfo splitChapters)
|
|
||||||
{
|
|
||||||
var chapterCount = 0;
|
|
||||||
return AaxFile.ConvertToMultiMp3
|
|
||||||
(
|
|
||||||
splitChapters,
|
|
||||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
|
||||||
DownloadOptions.LameConfig,
|
|
||||||
DownloadOptions.TrimOutputToChapterLength
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void Callback(int currentChapter, ChapterInfo splitChapters, NewMP3SplitCallback newSplitCallback)
|
|
||||||
=> Callback(currentChapter, splitChapters, 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);
|
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,55 +1,92 @@
|
|||||||
using System;
|
using AAXClean;
|
||||||
using System.IO;
|
|
||||||
using AAXClean;
|
|
||||||
using AAXClean.Codecs;
|
using AAXClean.Codecs;
|
||||||
using Dinah.Core.StepRunner;
|
using Dinah.Core.Net.Http;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||||
{
|
{
|
||||||
protected override StepSequence Steps { get; }
|
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)
|
||||||
{
|
{
|
||||||
Steps = new StepSequence
|
var step = 1;
|
||||||
{
|
|
||||||
Name = "Download and Convert Aaxc To " + DownloadOptions.OutputFormat,
|
|
||||||
|
|
||||||
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
|
AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}";
|
||||||
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsSingleFile,
|
AsyncSteps[$"Step {step++}: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||||
["Step 3: Create Cue"] = Step_CreateCue,
|
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
["Step 4: Cleanup"] = Step_Cleanup,
|
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
|
||||||
};
|
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
|
||||||
|
AsyncSteps[$"Step {step++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||||
|
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool Step_DownloadAudiobookAsSingleFile()
|
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
|
||||||
|
|
||||||
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
|
||||||
var decryptionResult
|
{
|
||||||
= DownloadOptions.OutputFormat == OutputFormat.M4b
|
await (AaxConversion = decryptAsync(outputFile));
|
||||||
? AaxFile.ConvertToMp4a(outputFile, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength)
|
|
||||||
: AaxFile.ConvertToMp3(outputFile, DownloadOptions.LameConfig, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength);
|
|
||||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
|
||||||
|
|
||||||
DownloadOptions.ChapterInfo = AaxFile.Chapters;
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
|
}
|
||||||
Step_DownloadAudiobook_End(zeroProgress);
|
finally
|
||||||
|
{
|
||||||
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
|
FinalizeDownload();
|
||||||
if (success)
|
}
|
||||||
base.OnFileCreated(OutputFileName);
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<bool> Step_MoveMoov()
|
||||||
|
{
|
||||||
|
AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName);
|
||||||
|
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
|
||||||
|
await AaxConversion;
|
||||||
|
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
|
||||||
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
DownloadOptions.LameConfig,
|
||||||
|
DownloadOptions.ChapterInfo
|
||||||
|
)
|
||||||
|
: DownloadOptions.FixupFile
|
||||||
|
? AaxFile.ConvertToMp4aAsync
|
||||||
|
(
|
||||||
|
outputFile,
|
||||||
|
DownloadOptions.ChapterInfo
|
||||||
|
)
|
||||||
|
: AaxFile.ConvertToMp4aAsync(outputFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
using System;
|
using Dinah.Core;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Dinah.Core;
|
|
||||||
using Dinah.Core.Net.Http;
|
using Dinah.Core.Net.Http;
|
||||||
using Dinah.Core.StepRunner;
|
using Dinah.Core.StepRunner;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
@@ -21,20 +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;
|
||||||
protected abstract StepSequence Steps { get; }
|
private readonly string tempFilePath;
|
||||||
private NetworkFileStreamPersister nfsPersister;
|
|
||||||
|
|
||||||
private string jsonDownloadState { get; }
|
|
||||||
|
|
||||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
{
|
{
|
||||||
@@ -48,15 +45,78 @@ 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);
|
||||||
}
|
|
||||||
|
nfsPersister = OpenNetworkFileStream();
|
||||||
|
|
||||||
|
zeroProgress = new DownloadProgress
|
||||||
|
{
|
||||||
|
BytesReceived = 0,
|
||||||
|
ProgressPercentage = 0,
|
||||||
|
TotalBytesToReceive = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
OnDecryptProgressUpdate(zeroProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
public abstract Task CancelAsync();
|
||||||
|
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
||||||
|
|
||||||
public virtual void SetCoverArt(byte[] coverArt)
|
public virtual void SetCoverArt(byte[] coverArt)
|
||||||
{
|
{
|
||||||
@@ -64,16 +124,6 @@ namespace AaxDecrypter
|
|||||||
OnRetrievedCoverArt(coverArt);
|
OnRetrievedCoverArt(coverArt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Run()
|
|
||||||
{
|
|
||||||
var (IsSuccess, _) = Steps.Run();
|
|
||||||
|
|
||||||
if (!IsSuccess)
|
|
||||||
Serilog.Log.Logger.Error("Conversion failed");
|
|
||||||
|
|
||||||
return IsSuccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void OnRetrievedTitle(string title)
|
protected void OnRetrievedTitle(string title)
|
||||||
=> RetrievedTitle?.Invoke(this, title);
|
=> RetrievedTitle?.Invoke(this, title);
|
||||||
protected void OnRetrievedAuthors(string authors)
|
protected void OnRetrievedAuthors(string authors)
|
||||||
@@ -82,97 +132,107 @@ 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);
|
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) &&
|
||||||
|
!string.IsNullOrEmpty(DownloadOptions.AudibleIV) &&
|
||||||
|
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);
|
||||||
{
|
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
||||||
string aaxPath = Path.ChangeExtension(TempFilePath, ".aax");
|
|
||||||
FileUtility.SaferMove(TempFilePath, aaxPath);
|
|
||||||
|
|
||||||
//Write aax decryption key
|
OnFileCreated(aaxPath);
|
||||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
OnFileCreated(keyPath);
|
||||||
FileUtility.SaferDelete(keyPath);
|
|
||||||
File.WriteAllText(keyPath, $"Key={DownloadOptions.AudibleKey}\r\nIV={DownloadOptions.AudibleIV}");
|
|
||||||
|
|
||||||
OnFileCreated(aaxPath);
|
|
||||||
OnFileCreated(keyPath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
FileUtility.SaferDelete(TempFilePath);
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
FileUtility.SaferDelete(tempFilePath);
|
||||||
|
|
||||||
return success;
|
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,23 +1,32 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public interface IDownloadOptions
|
public interface IDownloadOptions
|
||||||
{
|
{
|
||||||
|
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; }
|
||||||
ChapterInfo ChapterInfo { get; set; }
|
bool DownloadClipsBookmarks { get; }
|
||||||
NAudio.Lame.LameConfig LameConfig { get; set; }
|
long DownloadSpeedBps { get; }
|
||||||
bool Downsample { get; set; }
|
ChapterInfo ChapterInfo { get; }
|
||||||
bool MatchSourceBitrate { get; set; }
|
bool FixupFile { get; }
|
||||||
|
NAudio.Lame.LameConfig LameConfig { get; }
|
||||||
|
bool Downsample { 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using AAXClean;
|
||||||
|
using NAudio.Lame;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AaxDecrypter
|
||||||
|
{
|
||||||
|
public static class MpegUtil
|
||||||
|
{
|
||||||
|
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
|
||||||
|
{
|
||||||
|
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 (downsample)
|
||||||
|
bitrateMultiple /= 2;
|
||||||
|
else
|
||||||
|
lameConfig.Mode = MPEGMode.Stereo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchSourceBitrate)
|
||||||
|
{
|
||||||
|
int kbps = (int)Math.Round(mp4File.AverageBitrate * bitrateMultiple / 1024);
|
||||||
|
|
||||||
|
if (lameConfig.VBR is null)
|
||||||
|
lameConfig.BitRate = kbps;
|
||||||
|
else if (lameConfig.VBR == VBRMode.ABR)
|
||||||
|
lameConfig.ABRRateKbps = kbps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
|
||||||
using FileManager;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
@@ -10,16 +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;
|
||||||
public static string DefaultMultipartFilename(MultiConvertFileProperties multiConvertFileProperties)
|
|
||||||
{
|
|
||||||
var template = Path.ChangeExtension(multiConvertFileProperties.OutputFileName, null) + " - <ch# 0> - <title>" + Path.GetExtension(multiConvertFileProperties.OutputFileName);
|
|
||||||
|
|
||||||
var fileNamingTemplate = new FileNamingTemplate(template) { IllegalCharacterReplacements = " " };
|
|
||||||
fileNamingTemplate.AddParameterReplacement("ch# 0", FileUtility.GetSequenceFormatted(multiConvertFileProperties.PartsPosition, multiConvertFileProperties.PartsTotal));
|
|
||||||
fileNamingTemplate.AddParameterReplacement("title", multiConvertFileProperties.Title ?? "");
|
|
||||||
|
|
||||||
return fileNamingTemplate.GetFilePath();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,91 +1,58 @@
|
|||||||
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;
|
||||||
|
public event EventHandler DownloadCompleted;
|
||||||
|
|
||||||
#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;
|
||||||
|
|
||||||
|
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 Task _backgroundDownloadTask { get; set; }
|
||||||
private EventWaitHandle downloadEnded { get; set; }
|
|
||||||
private EventWaitHandle downloadedPiece { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -98,32 +65,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 +106,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,9 +120,7 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -165,37 +128,32 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
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 (_backgroundDownloadTask 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;
|
_backgroundDownloadTask = 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 +161,17 @@ 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())
|
_backgroundDownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
|
||||||
{ IsBackground = true }
|
|
||||||
.Start();
|
|
||||||
|
|
||||||
hasBegunDownloading = true;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
||||||
/// Downlod <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 +179,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 +224,17 @@ 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();
|
||||||
|
DownloadCompleted?.Invoke(this, null);
|
||||||
#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 +243,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 +256,8 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (!hasBegunDownloading)
|
if (_backgroundDownloadTask is null)
|
||||||
BeginDownloading();
|
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
||||||
return ContentLength;
|
return ContentLength;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,18 +274,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 (_backgroundDownloadTask 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 +301,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
|
&& _backgroundDownloadTask?.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();
|
||||||
|
_backgroundDownloadTask?.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,78 +1,46 @@
|
|||||||
using System;
|
using FileManager;
|
||||||
using System.Threading;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Dinah.Core.Net.Http;
|
|
||||||
using Dinah.Core.StepRunner;
|
|
||||||
using FileManager;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
||||||
{
|
{
|
||||||
protected override StepSequence Steps { get; }
|
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)
|
||||||
{
|
{
|
||||||
Steps = new StepSequence
|
AsyncSteps.Name = "Download Unencrypted Audiobook";
|
||||||
{
|
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
Name = "Download Mp3 Audiobook",
|
AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||||
|
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
|
||||||
["Step 1: Get Mp3 Metadata"] = Step_GetMetadata,
|
|
||||||
["Step 2: Download Audiobook"] = Step_DownloadAudiobookAsSingleFile,
|
|
||||||
["Step 3: Create Cue"] = Step_CreateCue,
|
|
||||||
["Step 4: Cleanup"] = Step_Cleanup,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
TaskCompletionSource completionSource = new();
|
||||||
|
|
||||||
return !IsCanceled;
|
InputFileStream.DownloadCompleted += (_, _) => completionSource.SetResult();
|
||||||
}
|
|
||||||
|
|
||||||
private bool Step_DownloadAudiobookAsSingleFile()
|
await completionSource.Task;
|
||||||
{
|
|
||||||
DateTime startTime = DateTime.Now;
|
|
||||||
|
|
||||||
// MUST put InputFileStream.Length first, because it starts background downloader.
|
if (IsCanceled)
|
||||||
|
return false;
|
||||||
while (InputFileStream.Length > InputFileStream.WritePosition && !InputFileStream.IsCancelled)
|
else
|
||||||
{
|
{
|
||||||
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);
|
|
||||||
SetOutputFileName(realOutputFileName);
|
|
||||||
OnFileCreated(realOutputFileName);
|
|
||||||
|
|
||||||
return !IsCanceled;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"//": "https://github.com/BalassaMarton/MSBump",
|
|
||||||
BumpRevision: true
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
<Version>8.1.1.1</Version>
|
<Version>9.4.5.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MSBump" Version="2.3.2">
|
<PackageReference Include="Octokit" Version="5.0.0" />
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Octokit" Version="0.51.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -5,17 +5,59 @@ using System.Linq;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using ApplicationServices;
|
using ApplicationServices;
|
||||||
using AudibleUtilities;
|
using AudibleUtilities;
|
||||||
|
using Dinah.Core;
|
||||||
using Dinah.Core.IO;
|
using Dinah.Core.IO;
|
||||||
using Dinah.Core.Logging;
|
using Dinah.Core.Logging;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using System.Runtime.InteropServices;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
namespace AppScaffolding
|
namespace AppScaffolding
|
||||||
{
|
{
|
||||||
|
public enum ReleaseIdentifier
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
WindowsClassic = OS.Windows | Variety.Classic | Architecture.X64,
|
||||||
|
WindowsAvalonia = OS.Windows | Variety.Chardonnay | Architecture.X64,
|
||||||
|
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
|
||||||
|
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
|
||||||
|
LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64,
|
||||||
|
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64
|
||||||
|
}
|
||||||
|
|
||||||
|
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
|
||||||
|
[Flags]
|
||||||
|
public enum Variety
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Classic = 0x10000,
|
||||||
|
Chardonnay = 0x20000,
|
||||||
|
}
|
||||||
|
|
||||||
public static class LibationScaffolding
|
public static class LibationScaffolding
|
||||||
{
|
{
|
||||||
|
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
|
||||||
|
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
|
||||||
|
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
|
||||||
|
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
|
||||||
|
public static Variety Variety { get; private set; }
|
||||||
|
|
||||||
|
public static void SetReleaseIdentifier(Variety varietyType)
|
||||||
|
{
|
||||||
|
Variety = Enum.IsDefined(varietyType) ? varietyType : Variety.None;
|
||||||
|
|
||||||
|
var releaseID = (ReleaseIdentifier)((int)varietyType | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
|
||||||
|
|
||||||
|
if (Enum.IsDefined(releaseID))
|
||||||
|
ReleaseIdentifier = releaseID;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ReleaseIdentifier = ReleaseIdentifier.None;
|
||||||
|
Serilog.Log.Logger.Warning("Unknown release identifier @{DebugInfo}", new { Variety = varietyType, Configuration.OS, RuntimeInformation.ProcessArchitecture });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AppScaffolding
|
// AppScaffolding
|
||||||
private static Assembly _executingAssembly;
|
private static Assembly _executingAssembly;
|
||||||
private static Assembly ExecutingAssembly
|
private static Assembly ExecutingAssembly
|
||||||
@@ -40,6 +82,8 @@ namespace AppScaffolding
|
|||||||
// // 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 //
|
||||||
@@ -52,100 +96,12 @@ 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void PopulateMissingConfigValues(Configuration config)
|
|
||||||
{
|
|
||||||
config.InProgress ??= Configuration.WinTemp;
|
|
||||||
|
|
||||||
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.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>
|
||||||
@@ -155,6 +111,8 @@ namespace AppScaffolding
|
|||||||
configureLogging(config);
|
configureLogging(config);
|
||||||
logStartupState(config);
|
logStartupState(config);
|
||||||
|
|
||||||
|
// all else should occur after logging
|
||||||
|
|
||||||
wireUpSystemEvents(config);
|
wireUpSystemEvents(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +152,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);
|
||||||
@@ -212,8 +170,8 @@ namespace AppScaffolding
|
|||||||
// However, empirical testing so far has shown no issues.
|
// However, empirical testing so far has shown no issues.
|
||||||
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
||||||
|
|
||||||
#region Console => Serilog tests
|
#region Console => Serilog tests
|
||||||
/*
|
/*
|
||||||
// all below apply to "Console." and "Console.Out."
|
// all below apply to "Console." and "Console.Out."
|
||||||
|
|
||||||
// captured
|
// captured
|
||||||
@@ -252,12 +210,12 @@ namespace AppScaffolding
|
|||||||
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
|
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
|
||||||
Console.Write("{0}", new object[] { "arr" });
|
Console.Write("{0}", new object[] { "arr" });
|
||||||
*/
|
*/
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
||||||
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
||||||
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void logStartupState(Configuration config)
|
private static void logStartupState(Configuration config)
|
||||||
{
|
{
|
||||||
@@ -275,7 +233,10 @@ namespace AppScaffolding
|
|||||||
{
|
{
|
||||||
AppName = EntryAssembly.GetName().Name,
|
AppName = EntryAssembly.GetName().Name,
|
||||||
Version = BuildVersion.ToString(),
|
Version = BuildVersion.ToString(),
|
||||||
Mode = mode,
|
ReleaseIdentifier,
|
||||||
|
Configuration.OS,
|
||||||
|
InteropFactory.InteropFunctionsType,
|
||||||
|
Mode = mode,
|
||||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||||
@@ -283,7 +244,9 @@ 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.LibationFiles,
|
config.BetaOptIn,
|
||||||
|
config.UseCoverAsFolderIcon,
|
||||||
|
config.LibationFiles,
|
||||||
AudibleFileStorage.BooksDirectory,
|
AudibleFileStorage.BooksDirectory,
|
||||||
|
|
||||||
config.InProgress,
|
config.InProgress,
|
||||||
@@ -294,9 +257,12 @@ namespace AppScaffolding
|
|||||||
AudibleFileStorage.DecryptInProgressDirectory,
|
AudibleFileStorage.DecryptInProgressDirectory,
|
||||||
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
private static void wireUpSystemEvents(Configuration configuration)
|
if (InteropFactory.InteropFunctionsType is null)
|
||||||
|
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void wireUpSystemEvents(Configuration configuration)
|
||||||
{
|
{
|
||||||
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
|
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
|
||||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
|
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
|
||||||
@@ -305,8 +271,9 @@ namespace AppScaffolding
|
|||||||
public static UpgradeProperties GetLatestRelease()
|
public static UpgradeProperties GetLatestRelease()
|
||||||
{
|
{
|
||||||
// timed out
|
// timed out
|
||||||
var latest = getLatestRelease(TimeSpan.FromSeconds(10));
|
(var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||||
if (latest is null)
|
|
||||||
|
if (latest is null || zip is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var latestVersionString = latest.TagName.Trim('v');
|
var latestVersionString = latest.TagName.Trim('v');
|
||||||
@@ -318,7 +285,7 @@ namespace AppScaffolding
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
// we have an update
|
// we have an update
|
||||||
var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip"));
|
|
||||||
var zipUrl = zip?.BrowserDownloadUrl;
|
var zipUrl = zip?.BrowserDownloadUrl;
|
||||||
|
|
||||||
Log.Logger.Information("Update available: {@DebugInfo}", new
|
Log.Logger.Information("Update available: {@DebugInfo}", new
|
||||||
@@ -328,13 +295,13 @@ 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 getLatestRelease(TimeSpan timeout)
|
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var task = System.Threading.Tasks.Task.Run(() => getLatestRelease());
|
var task = getLatestRelease();
|
||||||
if (task.Wait(timeout))
|
if (task.Wait(timeout))
|
||||||
return task.Result;
|
return task.Result;
|
||||||
|
|
||||||
@@ -344,16 +311,25 @@ namespace AppScaffolding
|
|||||||
{
|
{
|
||||||
Log.Logger.Error(aggEx, "Checking for new version too often");
|
Log.Logger.Error(aggEx, "Checking for new version too often");
|
||||||
}
|
}
|
||||||
return null;
|
return (null, null);
|
||||||
}
|
}
|
||||||
private static Octokit.Release getLatestRelease()
|
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
||||||
{
|
{
|
||||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("Libation"));
|
const string ownerAccount = "rmcrackan";
|
||||||
|
const string repoName = "Libation";
|
||||||
|
|
||||||
// https://octokitnet.readthedocs.io/en/latest/releases/
|
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
||||||
var releases = gitHubClient.Repository.Release.GetAll("rmcrackan", "Libation").GetAwaiter().GetResult();
|
|
||||||
var latest = releases.First(r => !r.Draft && !r.Prerelease);
|
//Download the release index
|
||||||
return latest;
|
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
||||||
|
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||||
|
var regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
||||||
|
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
//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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,74 +381,5 @@ namespace AppScaffolding
|
|||||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void migrate_from_7_10_1(Configuration config)
|
|
||||||
{
|
|
||||||
var lastNigrationThres = config.GetNonString<bool>($"{nameof(migrate_from_7_10_1)}_ThrewError");
|
|
||||||
|
|
||||||
if (lastNigrationThres) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
|
|
||||||
//https://github.com/rmcrackan/Libation/issues/270#issuecomment-1152863629
|
|
||||||
//This migration helps fix databases contaminated with the 7.10.1 hack workaround
|
|
||||||
//and those with improperly identified or missing series. This does not solve cases
|
|
||||||
//where individual episodes are in the db with a valid series link, but said series'
|
|
||||||
//parents have not been imported into the database. For those cases, Libation will
|
|
||||||
//attempt fixup by retrieving parents from the catalog endpoint
|
|
||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
|
||||||
|
|
||||||
//This migration removes books and series with SERIES_ prefix that were created
|
|
||||||
//as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2
|
|
||||||
string removeHackSeries = "delete " +
|
|
||||||
"from series " +
|
|
||||||
"where AudibleSeriesId like 'SERIES%'";
|
|
||||||
|
|
||||||
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 occured while running database migrations in {0}", nameof(migrate_from_7_10_1));
|
|
||||||
config.SetObject($"{nameof(migrate_from_7_10_1)}_ThrewError", true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(System.Reflection.Assembly.GetEntryAssembly().Location), "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,27 @@
|
|||||||
using System;
|
using NPOI.XWPF.UserModel;
|
||||||
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace AppScaffolding
|
namespace AppScaffolding
|
||||||
{
|
{
|
||||||
public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease);
|
public partial record UpgradeProperties
|
||||||
|
{
|
||||||
|
public string ZipUrl { get; }
|
||||||
|
public string HtmlUrl { get; }
|
||||||
|
public string ZipName { get; }
|
||||||
|
public Version LatestRelease { get; }
|
||||||
|
public string Notes { get; }
|
||||||
|
|
||||||
|
public UpgradeProperties(string zipUrl, string htmlUrl, string zipName, Version latestRelease, string notes)
|
||||||
|
{
|
||||||
|
ZipName = zipName;
|
||||||
|
HtmlUrl = htmlUrl;
|
||||||
|
ZipUrl = zipUrl;
|
||||||
|
LatestRelease = latestRelease;
|
||||||
|
Notes = LinkStripRegex().Replace(notes, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\[(.*)\]\(.*\)")]
|
||||||
|
private static partial Regex LinkStripRegex();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="27.2.1" />
|
<PackageReference Include="CsvHelper" Version="30.0.1" />
|
||||||
<PackageReference Include="NPOI" Version="2.5.6" />
|
<PackageReference Include="NPOI" Version="2.6.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -14,4 +14,12 @@
|
|||||||
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
|
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DataLayer;
|
||||||
|
using Dinah.Core;
|
||||||
|
using LibationFileManager;
|
||||||
|
|
||||||
|
namespace ApplicationServices
|
||||||
|
{
|
||||||
|
public class BulkSetDownloadStatus
|
||||||
|
{
|
||||||
|
private List<(string message, LiberatedStatus newStatus, IEnumerable<Book> Books)> actionSets { get; } = new();
|
||||||
|
|
||||||
|
public int Count => actionSets.Count;
|
||||||
|
|
||||||
|
public IEnumerable<string> Messages => actionSets.Select(a => a.message);
|
||||||
|
public string AggregateMessage => $"Are you sure you want to set {Messages.Aggregate((a, b) => $"{a} and {b}")}?";
|
||||||
|
|
||||||
|
private List<LibraryBook> _libraryBooks;
|
||||||
|
private bool _setDownloaded;
|
||||||
|
private bool _setNotDownloaded;
|
||||||
|
|
||||||
|
public BulkSetDownloadStatus(List<LibraryBook> libraryBooks, bool setDownloaded, bool setNotDownloaded)
|
||||||
|
{
|
||||||
|
_libraryBooks = libraryBooks;
|
||||||
|
_setDownloaded = setDownloaded;
|
||||||
|
_setNotDownloaded = setNotDownloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Discover()
|
||||||
|
{
|
||||||
|
var bookExistsList = _libraryBooks
|
||||||
|
.Select(libraryBook => new
|
||||||
|
{
|
||||||
|
libraryBook.Book,
|
||||||
|
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (_setDownloaded)
|
||||||
|
{
|
||||||
|
var books2change = bookExistsList
|
||||||
|
.Where(a => a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
||||||
|
.Select(a => a.Book)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (books2change.Any())
|
||||||
|
actionSets.Add((
|
||||||
|
$"{"book".PluralizeWithCount(books2change.Count)} to 'Downloaded'",
|
||||||
|
LiberatedStatus.Liberated,
|
||||||
|
books2change));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_setNotDownloaded)
|
||||||
|
{
|
||||||
|
var books2change = bookExistsList
|
||||||
|
.Where(a => !a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
||||||
|
.Select(a => a.Book)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (books2change.Any())
|
||||||
|
actionSets.Add((
|
||||||
|
$"{"book".PluralizeWithCount(books2change.Count)} to 'Not Downloaded'",
|
||||||
|
LiberatedStatus.NotLiberated,
|
||||||
|
books2change));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Execute()
|
||||||
|
{
|
||||||
|
foreach (var a in actionSets)
|
||||||
|
a.Books.UpdateBookStatus(a.newStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,233 +1,231 @@
|
|||||||
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 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 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, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS,
|
logRestart();
|
||||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
|
||||||
};
|
if (accounts is null || accounts.Length == 0)
|
||||||
|
return (0, 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,
|
||||||
|
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)}");
|
||||||
|
var 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 wel'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, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//only do this if findAndAddMissingParents returned >= 0. If it
|
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
||||||
//returned < 0, an error happened and there's still a chance that
|
{
|
||||||
//a future successful run will find missing parents.
|
var tasks = new List<Task<List<ImportItem>>>();
|
||||||
removedOrphanedEpisodes();
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Logger.Information("Begin long-running import");
|
using LogArchiver archiver
|
||||||
logTime($"pre {nameof(importIntoDbAsync)}");
|
= Log.Logger.IsDebugEnabled()
|
||||||
var newCount = await importIntoDbAsync(importItems);
|
? new LogArchiver(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
|
||||||
logTime($"post {nameof(importIntoDbAsync)}");
|
: default;
|
||||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
|
||||||
|
|
||||||
return (totalCount, newCount);
|
archiver?.DeleteAllButNewestN(20);
|
||||||
}
|
|
||||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
|
||||||
{
|
|
||||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
|
||||||
|
|
||||||
// nuget Serilog.Exceptions would automatically log custom properties
|
|
||||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
|
||||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
|
||||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
|
||||||
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
|
|
||||||
{
|
|
||||||
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, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
|
||||||
{
|
|
||||||
var tasks = new List<Task<List<ImportItem>>>();
|
|
||||||
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)
|
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||||
var apiExtended = await apiExtendedfunc(account);
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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]"
|
Account = account?.MaskedLogEntry ?? "[null]"
|
||||||
});
|
});
|
||||||
|
|
||||||
logTime($"pre scanAccountAsync {account.AccountName}");
|
logTime($"pre scanAccountAsync {account.AccountName}");
|
||||||
|
|
||||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
|
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
|
||||||
|
|
||||||
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
archiver?.AddFile($"{DateTime.Now:u} {account.MaskedLogEntry}.json", new JObject { { "Account", account.MaskedLogEntry }, { "ScannedDateTime", DateTime.Now.ToString("u") }, {"Items", JArray.FromObject(dtoItems) } });
|
||||||
|
|
||||||
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
|
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
@@ -235,248 +233,308 @@ 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 occured 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 endpointdoes 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 occured 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 culpret 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();
|
|
||||||
|
|
||||||
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
|
|
||||||
context.LibraryBooks.RemoveRange(removeLibraryBooks);
|
|
||||||
context.Books.RemoveRange(removeLibraryBooks.Select(lb => lb.Book));
|
|
||||||
|
|
||||||
var qtyChanges = context.SaveChanges();
|
|
||||||
if (qtyChanges > 0)
|
|
||||||
finalizeLibrarySizeChange();
|
|
||||||
|
|
||||||
return removeLibraryBooks;
|
|
||||||
}
|
|
||||||
#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<Book>> BookUserDefinedItemCommitted;
|
|
||||||
|
|
||||||
#region Update book details
|
|
||||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
|
|
||||||
{
|
|
||||||
book.UserDefinedItem.BookStatus = bookStatus;
|
|
||||||
return UpdateUserDefinedItem(book);
|
|
||||||
}
|
|
||||||
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
|
|
||||||
{
|
|
||||||
book.UserDefinedItem.PdfStatus = pdfStatus;
|
|
||||||
return UpdateUserDefinedItem(book);
|
|
||||||
}
|
|
||||||
public static int UpdateBook(
|
|
||||||
this Book book,
|
|
||||||
string tags = null,
|
|
||||||
LiberatedStatus? bookStatus = null,
|
|
||||||
LiberatedStatus? pdfStatus = null)
|
|
||||||
=> UpdateBooks(tags, bookStatus, pdfStatus, book);
|
|
||||||
public static int UpdateBooks(
|
|
||||||
string tags = null,
|
|
||||||
LiberatedStatus? bookStatus = null,
|
|
||||||
LiberatedStatus? pdfStatus = null,
|
|
||||||
params Book[] books)
|
|
||||||
{
|
{
|
||||||
foreach (var book in books)
|
try
|
||||||
{
|
{
|
||||||
// blank tags are expected. null tags are not
|
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
|
||||||
if (tags is not null && book.UserDefinedItem.Tags != tags)
|
return 0;
|
||||||
book.UserDefinedItem.Tags = tags;
|
|
||||||
|
|
||||||
if (bookStatus is not null && book.UserDefinedItem.BookStatus != bookStatus.Value)
|
using var context = DbContexts.GetContext();
|
||||||
book.UserDefinedItem.BookStatus = bookStatus.Value;
|
|
||||||
|
|
||||||
// even though PdfStatus is nullable, there's no case where we'd actually overwrite with null
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
if (pdfStatus is not null && book.UserDefinedItem.PdfStatus != pdfStatus.Value)
|
foreach (var lb in removeLibraryBooks)
|
||||||
book.UserDefinedItem.PdfStatus = pdfStatus.Value;
|
{
|
||||||
}
|
lb.IsDeleted = true;
|
||||||
|
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
|
}
|
||||||
|
|
||||||
return UpdateUserDefinedItem(books);
|
var qtyChanges = context.SaveChanges();
|
||||||
}
|
if (qtyChanges > 0)
|
||||||
public static int UpdateUserDefinedItem(params Book[] books) => UpdateUserDefinedItem(books.ToList());
|
finalizeLibrarySizeChange();
|
||||||
public static int UpdateUserDefinedItem(IEnumerable<Book> books)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (books is null || !books.Any())
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
return qtyChanges;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error removing books");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Attach() NoTracking entities before SaveChanges()
|
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||||
foreach (var book in books)
|
{
|
||||||
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
try
|
||||||
|
{
|
||||||
|
if (libraryBooks is null || !libraryBooks.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
var qtyChanges = context.SaveChanges();
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
|
foreach (var lb in libraryBooks)
|
||||||
|
{
|
||||||
|
lb.IsDeleted = false;
|
||||||
|
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
var qtyChanges = context.SaveChanges();
|
||||||
|
if (qtyChanges > 0)
|
||||||
|
finalizeLibrarySizeChange();
|
||||||
|
|
||||||
|
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)
|
if (qtyChanges > 0)
|
||||||
BookUserDefinedItemCommitted?.Invoke(null, books);
|
finalizeLibrarySizeChange();
|
||||||
|
|
||||||
return qtyChanges;
|
return qtyChanges;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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<Book>> BookUserDefinedItemCommitted;
|
||||||
|
|
||||||
|
#region Update book details
|
||||||
|
public static int UpdateUserDefinedItem(
|
||||||
|
this Book book,
|
||||||
|
string tags = null,
|
||||||
|
LiberatedStatus? bookStatus = null,
|
||||||
|
LiberatedStatus? pdfStatus = null,
|
||||||
|
Rating rating = null)
|
||||||
|
=> new[] { book }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
|
||||||
|
|
||||||
|
public static int UpdateUserDefinedItem(
|
||||||
|
this IEnumerable<Book> books,
|
||||||
|
string tags = null,
|
||||||
|
LiberatedStatus? bookStatus = null,
|
||||||
|
LiberatedStatus? pdfStatus = null,
|
||||||
|
Rating rating = null)
|
||||||
|
=> updateUserDefinedItem(
|
||||||
|
books,
|
||||||
|
udi => {
|
||||||
|
// blank tags are expected. null tags are not
|
||||||
|
if (tags is not null)
|
||||||
|
udi.Tags = tags;
|
||||||
|
|
||||||
|
if (bookStatus.HasValue)
|
||||||
|
udi.BookStatus = bookStatus.Value;
|
||||||
|
|
||||||
|
// method handles null logic
|
||||||
|
udi.SetPdfStatus(pdfStatus);
|
||||||
|
|
||||||
|
if (rating is not null)
|
||||||
|
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
||||||
|
});
|
||||||
|
|
||||||
|
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus, Version libationVersion)
|
||||||
|
=> book.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)
|
||||||
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
|
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
|
||||||
|
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
|
|
||||||
|
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
|
||||||
|
=> book.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||||
|
public static int UpdatePdfStatus(this IEnumerable<Book> books, LiberatedStatus pdfStatus)
|
||||||
|
=> books.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||||
|
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
|
||||||
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||||
|
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus 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)
|
||||||
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||||
|
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
|
||||||
|
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||||
|
|
||||||
|
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
|
||||||
|
=> libraryBook.Book.updateUserDefinedItem(action);
|
||||||
|
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||||
|
=> libraryBooks.Select(lb => lb.Book).updateUserDefinedItem(action);
|
||||||
|
|
||||||
|
public static int UpdateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => book.updateUserDefinedItem(action);
|
||||||
|
public static int UpdateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action) => books.updateUserDefinedItem(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
|
||||||
|
{
|
||||||
|
if (books is null || !books.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
foreach (var book in books)
|
||||||
|
action?.Invoke(book.UserDefinedItem);
|
||||||
|
|
||||||
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
|
foreach (var book in books)
|
||||||
|
{
|
||||||
|
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
|
context.Attach(book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
var qtyChanges = context.SaveChanges();
|
||||||
|
if (qtyChanges > 0)
|
||||||
|
BookUserDefinedItemCommitted?.Invoke(null, books);
|
||||||
|
|
||||||
|
return qtyChanges;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, $"Error updating {nameof(Book.UserDefinedItem)}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
// must be here instead of in db layer due to AaxcExists
|
||||||
|
public static LiberatedStatus Liberated_Status(Book book)
|
||||||
|
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
|
||||||
|
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
|
||||||
|
: LiberatedStatus.NotLiberated;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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 booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable)
|
||||||
|
{
|
||||||
|
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
||||||
|
public bool HasPendingBooks => PendingBooks > 0;
|
||||||
|
|
||||||
|
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError + booksUnavailable);
|
||||||
|
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded + pdfsUnavailable);
|
||||||
|
|
||||||
|
public string StatusString => HasPdfResults ? $"{toBookStatusString()} | {toPdfStatusString()}" : toBookStatusString();
|
||||||
|
|
||||||
|
private string toBookStatusString()
|
||||||
{
|
{
|
||||||
Log.Logger.Error(ex, $"Error updating {nameof(Book.UserDefinedItem)}");
|
if (!HasBookResults) return "No books. Begin by importing your library";
|
||||||
throw;
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
public static LibraryStats GetCounts(IEnumerable<LibraryBook> libraryBooks = null)
|
||||||
|
{
|
||||||
|
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||||
|
|
||||||
// must be here instead of in db layer due to AaxcExists
|
var results = libraryBooks
|
||||||
public static LiberatedStatus Liberated_Status(Book book)
|
.AsParallel()
|
||||||
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
|
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
|
||||||
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
|
.ToList();
|
||||||
: LiberatedStatus.NotLiberated;
|
|
||||||
|
|
||||||
// exists here for feature predictability. It makes sense for this to be where Liberated_Status is
|
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);
|
||||||
public static LiberatedStatus? Pdf_Status(Book book) => book.UserDefinedItem.PdfStatus;
|
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);
|
||||||
|
|
||||||
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
|
||||||
|
|
||||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
|
var pdfResults = libraryBooks
|
||||||
{
|
.AsParallel()
|
||||||
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
.Where(lb => lb.Book.HasPdf())
|
||||||
public bool HasPendingBooks => PendingBooks > 0;
|
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
|
var pdfsDownloaded = pdfResults.Count(r => r.status == LiberatedStatus.Liberated);
|
||||||
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
|
var pdfsNotDownloaded = pdfResults.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||||
}
|
var pdfsUnavailable = pdfResults.Count(r => r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||||
public static LibraryStats GetCounts()
|
|
||||||
{
|
|
||||||
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
|
||||||
|
|
||||||
var results = libraryBooks
|
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
|
||||||
.AsParallel()
|
|
||||||
.Select(lb => Liberated_Status(lb.Book))
|
|
||||||
.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 });
|
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable);
|
||||||
|
}
|
||||||
var boolResults = libraryBooks
|
}
|
||||||
.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 });
|
|
||||||
|
|
||||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,19 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
[Name("Content Type")]
|
[Name("Content Type")]
|
||||||
public string ContentType { get; set; }
|
public string ContentType { get; set; }
|
||||||
}
|
|
||||||
|
[Name("Audio Format")]
|
||||||
|
public string AudioFormat { get; set; }
|
||||||
|
|
||||||
|
[Name("Language")]
|
||||||
|
public string Language { get; set; }
|
||||||
|
|
||||||
|
[Name("LastDownloaded")]
|
||||||
|
public DateTime? LastDownloaded { get; set; }
|
||||||
|
|
||||||
|
[Name("LastDownloadedVersion")]
|
||||||
|
public string LastDownloadedVersion { get; set; }
|
||||||
|
}
|
||||||
public static class LibToDtos
|
public static class LibToDtos
|
||||||
{
|
{
|
||||||
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
||||||
@@ -132,7 +144,12 @@ namespace ApplicationServices
|
|||||||
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
||||||
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(),
|
||||||
|
Language = a.Book.Language,
|
||||||
|
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
||||||
|
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
||||||
|
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
public static class LibraryExporter
|
public static class LibraryExporter
|
||||||
@@ -176,34 +193,38 @@ namespace ApplicationServices
|
|||||||
var row = sheet.CreateRow(rowIndex);
|
var row = sheet.CreateRow(rowIndex);
|
||||||
|
|
||||||
var columns = new[] {
|
var columns = new[] {
|
||||||
nameof (ExportDto.Account),
|
nameof(ExportDto.Account),
|
||||||
nameof (ExportDto.DateAdded),
|
nameof(ExportDto.DateAdded),
|
||||||
nameof (ExportDto.AudibleProductId),
|
nameof(ExportDto.AudibleProductId),
|
||||||
nameof (ExportDto.Locale),
|
nameof(ExportDto.Locale),
|
||||||
nameof (ExportDto.Title),
|
nameof(ExportDto.Title),
|
||||||
nameof (ExportDto.AuthorNames),
|
nameof(ExportDto.AuthorNames),
|
||||||
nameof (ExportDto.NarratorNames),
|
nameof(ExportDto.NarratorNames),
|
||||||
nameof (ExportDto.LengthInMinutes),
|
nameof(ExportDto.LengthInMinutes),
|
||||||
nameof (ExportDto.Description),
|
nameof(ExportDto.Description),
|
||||||
nameof (ExportDto.Publisher),
|
nameof(ExportDto.Publisher),
|
||||||
nameof (ExportDto.HasPdf),
|
nameof(ExportDto.HasPdf),
|
||||||
nameof (ExportDto.SeriesNames),
|
nameof(ExportDto.SeriesNames),
|
||||||
nameof (ExportDto.SeriesOrder),
|
nameof(ExportDto.SeriesOrder),
|
||||||
nameof (ExportDto.CommunityRatingOverall),
|
nameof(ExportDto.CommunityRatingOverall),
|
||||||
nameof (ExportDto.CommunityRatingPerformance),
|
nameof(ExportDto.CommunityRatingPerformance),
|
||||||
nameof (ExportDto.CommunityRatingStory),
|
nameof(ExportDto.CommunityRatingStory),
|
||||||
nameof (ExportDto.PictureId),
|
nameof(ExportDto.PictureId),
|
||||||
nameof (ExportDto.IsAbridged),
|
nameof(ExportDto.IsAbridged),
|
||||||
nameof (ExportDto.DatePublished),
|
nameof(ExportDto.DatePublished),
|
||||||
nameof (ExportDto.CategoriesNames),
|
nameof(ExportDto.CategoriesNames),
|
||||||
nameof (ExportDto.MyRatingOverall),
|
nameof(ExportDto.MyRatingOverall),
|
||||||
nameof (ExportDto.MyRatingPerformance),
|
nameof(ExportDto.MyRatingPerformance),
|
||||||
nameof (ExportDto.MyRatingStory),
|
nameof(ExportDto.MyRatingStory),
|
||||||
nameof (ExportDto.MyLibationTags),
|
nameof(ExportDto.MyLibationTags),
|
||||||
nameof (ExportDto.BookStatus),
|
nameof(ExportDto.BookStatus),
|
||||||
nameof (ExportDto.PdfStatus),
|
nameof(ExportDto.PdfStatus),
|
||||||
nameof (ExportDto.ContentType)
|
nameof(ExportDto.ContentType),
|
||||||
};
|
nameof(ExportDto.AudioFormat),
|
||||||
|
nameof(ExportDto.Language),
|
||||||
|
nameof(ExportDto.LastDownloaded),
|
||||||
|
nameof(ExportDto.LastDownloadedVersion),
|
||||||
|
};
|
||||||
var col = 0;
|
var col = 0;
|
||||||
foreach (var c in columns)
|
foreach (var c in columns)
|
||||||
{
|
{
|
||||||
@@ -228,9 +249,9 @@ 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);
|
||||||
@@ -268,8 +289,19 @@ 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.Language);
|
||||||
|
|
||||||
rowIndex++;
|
if (dto.LastDownloaded.HasValue)
|
||||||
|
{
|
||||||
|
dateCell = row.CreateCell(col);
|
||||||
|
dateCell.CellStyle = dateStyle;
|
||||||
|
dateCell.SetCellValue(dto.LastDownloaded.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
|
||||||
|
|
||||||
|
rowIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
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.Title},
|
||||||
|
{ "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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(Book book) => performSafeCommand(e =>
|
||||||
e.UpdateLiberatedStatus(book)
|
{
|
||||||
);
|
e.UpdateLiberatedStatus(book);
|
||||||
|
e.UpdateTags(book.AudibleProductId, 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
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;
|
||||||
@@ -19,6 +20,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 +43,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,39 +89,52 @@ 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();
|
int count = 0;
|
||||||
|
List<Item> items = new();
|
||||||
|
List<Item> seriesItems = new();
|
||||||
|
|
||||||
int count = 0, maxConcurrentEpisodeScans = 5;
|
var sw = Stopwatch.StartNew();
|
||||||
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
|
|
||||||
|
|
||||||
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
|
//Scan the library for all added books, and add any episode-type items to seriesItems to be scanned for episodes/parents
|
||||||
|
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions, BatchSize, MaxConcurrency))
|
||||||
{
|
{
|
||||||
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
|
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
|
||||||
{
|
seriesItems.Add(item);
|
||||||
//Get child episodes asynchronously and await all at the end
|
|
||||||
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
|
|
||||||
}
|
|
||||||
else if (!item.IsEpisodes && !item.IsSeriesParent)
|
else if (!item.IsEpisodes && !item.IsSeriesParent)
|
||||||
items.Add(item);
|
items.Add(item);
|
||||||
|
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
|
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on series episode scans to complete.", count);
|
||||||
|
Serilog.Log.Logger.Debug("Beginning episode scan.");
|
||||||
|
|
||||||
//await and add all episides from all parents
|
count = 0;
|
||||||
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
|
|
||||||
items.AddRange(epList);
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Completed library scan.");
|
//'get' Tasks are activated when they are written to the channel. To avoid more concurrency than is desired, the
|
||||||
|
//channel is bounded with a capacity of 1. Channel write operations are blocked until the current item is read
|
||||||
|
var episodeChannel = Channel.CreateBounded<Task<List<Item>>>(new BoundedChannelOptions(1) { SingleReader = true });
|
||||||
|
|
||||||
|
//Start scanning for all episodes. Episode batch 'get' Tasks are written to the channel.
|
||||||
|
var scanAllSeriesTask = scanAllSeries(seriesItems, episodeChannel.Writer);
|
||||||
|
|
||||||
|
//Read all episodes from the channel and add them to the import items.
|
||||||
|
//This method blocks until episodeChannel.Writer is closed by scanAllSeries()
|
||||||
|
await foreach (var ep in getAllEpisodesAsync(episodeChannel.Reader))
|
||||||
|
{
|
||||||
|
items.AddRange(ep);
|
||||||
|
count += ep.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Be sure to await the scanAllSeries Task so that any exceptions are thrown
|
||||||
|
await scanAllSeriesTask;
|
||||||
|
|
||||||
|
sw.Stop();
|
||||||
|
Serilog.Log.Logger.Debug("Episode scan complete. Found {count} episodes and series.", count);
|
||||||
|
Serilog.Log.Logger.Debug($"Completed library scan in {sw.Elapsed.TotalMilliseconds:F0} ms.");
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
|
|
||||||
#endif
|
|
||||||
var validators = new List<IValidator>();
|
var validators = new List<IValidator>();
|
||||||
validators.AddRange(getValidators());
|
validators.AddRange(getValidators());
|
||||||
foreach (var v in validators)
|
foreach (var v in validators)
|
||||||
@@ -162,174 +143,9 @@ namespace AudibleUtilities
|
|||||||
if (exceptions is not null && exceptions.Any())
|
if (exceptions is not null && exceptions.Any())
|
||||||
throw new AggregateException(exceptions);
|
throw new AggregateException(exceptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
#region episodes and podcasts
|
|
||||||
|
|
||||||
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
|
|
||||||
{
|
|
||||||
await concurrencySemaphore.WaitAsync();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
|
|
||||||
|
|
||||||
List<Item> children;
|
|
||||||
|
|
||||||
if (parent.IsEpisodes)
|
|
||||||
{
|
|
||||||
//The 'parent' is a single episode that was added to the library.
|
|
||||||
//Get the episode's parent and add it to the database.
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
|
|
||||||
|
|
||||||
children = new() { parent };
|
|
||||||
|
|
||||||
var parentAsins = parent.Relationships
|
|
||||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
|
||||||
.Select(p => p.Asin);
|
|
||||||
|
|
||||||
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
|
||||||
|
|
||||||
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
|
|
||||||
if (numSeriesParents != 1)
|
|
||||||
{
|
|
||||||
//There should only ever be 1 top-level parent per episode. If not, log
|
|
||||||
//and throw so we can figure out what to do about those special cases.
|
|
||||||
JsonSerializerSettings Settings = new()
|
|
||||||
{
|
|
||||||
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
|
|
||||||
DateParseHandling = DateParseHandling.None,
|
|
||||||
Converters =
|
|
||||||
{
|
|
||||||
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
|
|
||||||
},
|
|
||||||
};
|
|
||||||
var ex = new ApplicationException($"Found {numSeriesParents} parents for {parent.Asin}");
|
|
||||||
Serilog.Log.Logger.Error(ex, $"Episode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
|
|
||||||
throw ex;
|
|
||||||
}
|
|
||||||
|
|
||||||
var realParent = seriesParents.Single(p => p.IsSeriesParent);
|
|
||||||
realParent.PurchaseDate = parent.PurchaseDate;
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
|
|
||||||
parent = realParent;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
children = await getEpisodeChildrenAsync(parent);
|
|
||||||
if (!children.Any())
|
|
||||||
return new();
|
|
||||||
}
|
|
||||||
|
|
||||||
//A series parent will always have exactly 1 Series
|
|
||||||
parent.Series = new Series[]
|
|
||||||
{
|
|
||||||
new Series
|
|
||||||
{
|
|
||||||
Asin = parent.Asin,
|
|
||||||
Sequence = "-1",
|
|
||||||
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
|
|
||||||
|
|
||||||
private static List<IValidator> getValidators()
|
private static List<IValidator> getValidators()
|
||||||
{
|
{
|
||||||
var type = typeof(IValidator);
|
var type = typeof(IValidator);
|
||||||
@@ -339,5 +155,182 @@ namespace AudibleUtilities
|
|||||||
|
|
||||||
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
|
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region episodes and podcasts
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read get tasks from the <paramref name="channel"/> and await results. This method maintains
|
||||||
|
/// a list of up to <see cref="MaxConcurrency"/> get tasks. When any of the get tasks completes,
|
||||||
|
/// the Items are yielded, that task is removed from the list, and a new get task is read from
|
||||||
|
/// the channel.
|
||||||
|
/// </summary>
|
||||||
|
private async IAsyncEnumerable<List<Item>> getAllEpisodesAsync(ChannelReader<Task<List<Item>>> channel)
|
||||||
|
{
|
||||||
|
List<Task<List<Item>>> concurentGets = new();
|
||||||
|
|
||||||
|
for (int i = 0; i < MaxConcurrency && await channel.WaitToReadAsync(); i++)
|
||||||
|
concurentGets.Add(await channel.ReadAsync());
|
||||||
|
|
||||||
|
while (concurentGets.Count > 0)
|
||||||
|
{
|
||||||
|
var completed = await Task.WhenAny(concurentGets);
|
||||||
|
concurentGets.Remove(completed);
|
||||||
|
|
||||||
|
if (await channel.WaitToReadAsync())
|
||||||
|
concurentGets.Add(await channel.ReadAsync());
|
||||||
|
|
||||||
|
yield return completed.Result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all child episodes and episode parents belonging to <paramref name="seriesItems"/> in batches and
|
||||||
|
/// writes the get tasks to <paramref name="channel"/>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task scanAllSeries(IEnumerable<Item> seriesItems, ChannelWriter<Task<List<Item>>> channel)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<Task> episodeScanTasks = new();
|
||||||
|
|
||||||
|
foreach (var item in seriesItems)
|
||||||
|
{
|
||||||
|
if (item.IsEpisodes)
|
||||||
|
await channel.WriteAsync(getEpisodeParentAsync(item));
|
||||||
|
else if (item.IsSeriesParent)
|
||||||
|
episodeScanTasks.Add(getParentEpisodesAsync(item, channel));
|
||||||
|
}
|
||||||
|
|
||||||
|
//episodeScanTasks complete only after all episode batch 'gets' have been written to the channel
|
||||||
|
await Task.WhenAll(episodeScanTasks);
|
||||||
|
}
|
||||||
|
finally { channel.Complete(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<Item>> getEpisodeParentAsync(Item episode)
|
||||||
|
{
|
||||||
|
//Item is a single episode that was added to the library.
|
||||||
|
//Get the episode's parent and add it to the database.
|
||||||
|
|
||||||
|
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", episode);
|
||||||
|
|
||||||
|
List<Item> children = new() { episode };
|
||||||
|
|
||||||
|
var parentAsins = episode.Relationships
|
||||||
|
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
||||||
|
.Select(p => p.Asin);
|
||||||
|
|
||||||
|
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||||
|
|
||||||
|
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
|
||||||
|
if (numSeriesParents != 1)
|
||||||
|
{
|
||||||
|
//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 {episode.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(episode, Formatting.None, Settings)}");
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
|
||||||
|
var parent = seriesParents.Single(p => p.IsSeriesParent);
|
||||||
|
parent.PurchaseDate = episode.PurchaseDate;
|
||||||
|
|
||||||
|
setSeries(parent, children);
|
||||||
|
children.Add(parent);
|
||||||
|
|
||||||
|
Serilog.Log.Logger.Debug("Completed parent scan for {episode}", episode);
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all episodes belonging to <paramref name="parent"/> in batches of <see cref="BatchSize"/> and writes the batch get tasks to <paramref name="channel"/>
|
||||||
|
/// This method only completes after all episode batch 'gets' have been written to the channel
|
||||||
|
/// </summary>
|
||||||
|
private async Task getParentEpisodesAsync(Item parent, ChannelWriter<Task<List<Item>>> channel)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
|
||||||
|
|
||||||
|
var episodeIds = parent.Relationships
|
||||||
|
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
|
||||||
|
.Select(r => r.Asin);
|
||||||
|
|
||||||
|
for (int batchNum = 0; episodeIds.Any(); batchNum++)
|
||||||
|
{
|
||||||
|
var batch = episodeIds.Take(BatchSize);
|
||||||
|
|
||||||
|
await channel.WriteAsync(getEpisodeBatchAsync(batchNum, parent, batch));
|
||||||
|
|
||||||
|
episodeIds = episodeIds.Skip(BatchSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<Item>> getEpisodeBatchAsync(int batchNum, Item parent, IEnumerable<string> childrenIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<Item> episodeBatch = await Api.GetCatalogProductsAsync(childrenIds, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||||
|
|
||||||
|
setSeries(parent, episodeBatch);
|
||||||
|
|
||||||
|
if (batchNum == 0)
|
||||||
|
episodeBatch.Add(parent);
|
||||||
|
|
||||||
|
Serilog.Log.Logger.Debug($"Batch {batchNum}: {episodeBatch.Count} results\t({{parent}})", parent);
|
||||||
|
|
||||||
|
return episodeBatch;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
|
||||||
|
{
|
||||||
|
ParentId = parent.Asin,
|
||||||
|
ParentTitle = parent.Title,
|
||||||
|
BatchNumber = batchNum,
|
||||||
|
ChildIdBatch = childrenIds
|
||||||
|
});
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setSeries(Item parent, IEnumerable<Item> children)
|
||||||
|
{
|
||||||
|
//A series parent will always have exactly 1 Series
|
||||||
|
parent.Series = new[]
|
||||||
|
{
|
||||||
|
new Series
|
||||||
|
{
|
||||||
|
Asin = parent.Asin,
|
||||||
|
Sequence = "-1",
|
||||||
|
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[]
|
||||||
|
{
|
||||||
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AudibleApi" Version="4.1.0.1" />
|
<PackageReference Include="AudibleApi" Version="8.1.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
|
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -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;
|
||||||
@@ -178,7 +179,7 @@ namespace AudibleUtilities
|
|||||||
LocaleCode = account.Locale.CountryCode,
|
LocaleCode = account.Locale.CountryCode,
|
||||||
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -12,45 +13,52 @@ namespace DataLayer.Configurations
|
|||||||
|
|
||||||
entity.OwnsOne(b => b.Rating);
|
entity.OwnsOne(b => b.Rating);
|
||||||
|
|
||||||
|
entity.Property(nameof(Book._audioFormat));
|
||||||
//
|
//
|
||||||
// CRUCIAL: ignore unmapped collections, even get-only
|
// CRUCIAL: ignore unmapped collections, even get-only
|
||||||
//
|
//
|
||||||
entity.Ignore(nameof(Book.Authors));
|
entity.Ignore(nameof(Book.Authors));
|
||||||
entity.Ignore(nameof(Book.Narrators));
|
entity.Ignore(nameof(Book.Narrators));
|
||||||
//// these don't seem to matter
|
entity.Ignore(nameof(Book.AudioFormat));
|
||||||
//entity.Ignore(nameof(Book.AuthorNames));
|
//// these don't seem to matter
|
||||||
//entity.Ignore(nameof(Book.NarratorNames));
|
//entity.Ignore(nameof(Book.AuthorNames));
|
||||||
//entity.Ignore(nameof(Book.HasPdfs));
|
//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
|
entity
|
||||||
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
||||||
{
|
{
|
||||||
b_udi.WithOwner(udi => udi.Book)
|
b_udi.WithOwner(udi => udi.Book)
|
||||||
.HasForeignKey(udi => udi.BookId);
|
.HasForeignKey(udi => udi.BookId);
|
||||||
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
||||||
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
||||||
|
|
||||||
// owns it 1:1, store in same table
|
b_udi.Property(udi => udi.LastDownloaded);
|
||||||
b_udi.OwnsOne(udi => udi.Rating);
|
b_udi
|
||||||
});
|
.Property(udi => udi.LastDownloadedVersion)
|
||||||
|
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
|
||||||
|
|
||||||
entity
|
// owns it 1:1, store in same table
|
||||||
|
b_udi.OwnsOne(udi => udi.Rating);
|
||||||
|
});
|
||||||
|
|
||||||
|
entity
|
||||||
.Metadata
|
.Metadata
|
||||||
.FindNavigation(nameof(Book.ContributorsLink))
|
.FindNavigation(nameof(Book.ContributorsLink))
|
||||||
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
|
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
|
||||||
@@ -66,6 +74,6 @@ namespace DataLayer.Configurations
|
|||||||
.HasOne(b => b.Category)
|
.HasOne(b => b.Category)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(b => b.CategoryId);
|
.HasForeignKey(b => b.CategoryId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,9 @@ namespace DataLayer.Configurations
|
|||||||
{
|
{
|
||||||
entity.HasKey(c => c.CategoryId);
|
entity.HasKey(c => c.CategoryId);
|
||||||
entity.HasIndex(c => c.AudibleCategoryId);
|
entity.HasIndex(c => c.AudibleCategoryId);
|
||||||
|
|
||||||
|
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
|
||||||
|
entity.HasData(Category.GetEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,32 +1,35 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||||
<ApplicationIcon />
|
|
||||||
<OutputType>Library</OutputType>
|
<OutputType>Library</OutputType>
|
||||||
<StartupObject />
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.1.0.1" />
|
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.6">
|
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.1.1" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.3">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.6" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.3" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.6">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
|
<DebugType>embedded</DebugType>
|
||||||
</ItemGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="migrate.json">
|
<None Update="migrate.json">
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DataLayer
|
||||||
|
{
|
||||||
|
internal enum AudioFormatEnum : long
|
||||||
|
{
|
||||||
|
//Defining the enum this way ensures that when comparing:
|
||||||
|
//LC_128_44100_stereo > LC_64_44100_stereo > LC_64_22050_stereo > LC_64_22050_stereo
|
||||||
|
//This matches how audible interprets these codecs when specifying quality using AudibleApi.DownloadQuality
|
||||||
|
//I've never seen mono formats.
|
||||||
|
Unknown = 0,
|
||||||
|
LC_32_22050_stereo = (32L << 18) | (22050 << 2) | 2,
|
||||||
|
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
|
||||||
|
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
|
||||||
|
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AudioFormat : IComparable<AudioFormat>, IComparable
|
||||||
|
{
|
||||||
|
internal int AudioFormatID { get; private set; }
|
||||||
|
public int Bitrate { get; private init; }
|
||||||
|
public int SampleRate { get; private init; }
|
||||||
|
public int Channels { get; private init; }
|
||||||
|
public bool IsValid => Bitrate != 0 && SampleRate != 0 && Channels != 0;
|
||||||
|
|
||||||
|
public static AudioFormat FromString(string formatStr)
|
||||||
|
{
|
||||||
|
if (Enum.TryParse(formatStr, ignoreCase: true, out AudioFormatEnum enumVal))
|
||||||
|
return FromEnum(enumVal);
|
||||||
|
return FromEnum(AudioFormatEnum.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static AudioFormat FromEnum(AudioFormatEnum enumVal)
|
||||||
|
{
|
||||||
|
var val = (long)enumVal;
|
||||||
|
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
Bitrate = (int)(val >> 18),
|
||||||
|
SampleRate = (int)(val >> 2) & ushort.MaxValue,
|
||||||
|
Channels = (int)(val & 3)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
internal AudioFormatEnum ToEnum()
|
||||||
|
{
|
||||||
|
var val = (AudioFormatEnum)(((long)Bitrate << 18) | ((long)SampleRate << 2) | (long)Channels);
|
||||||
|
|
||||||
|
return Enum.IsDefined(val) ?
|
||||||
|
val : AudioFormatEnum.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
=> IsValid ?
|
||||||
|
$"{Bitrate} Kbps, {SampleRate / 1000d:F1} kHz, {(Channels == 2 ? "Stereo" : Channels)}" :
|
||||||
|
"Unknown";
|
||||||
|
|
||||||
|
public int CompareTo(AudioFormat other) => ToEnum().CompareTo(other.ToEnum());
|
||||||
|
|
||||||
|
public int CompareTo(object obj) => CompareTo(obj as AudioFormat);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ namespace DataLayer
|
|||||||
Parent = 4,
|
Parent = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public class Book
|
public class Book
|
||||||
{
|
{
|
||||||
// implementation detail. set by db only. only used by data layer
|
// implementation detail. set by db only. only used by data layer
|
||||||
@@ -38,6 +39,10 @@ namespace DataLayer
|
|||||||
public ContentType ContentType { get; private set; }
|
public ContentType ContentType { get; private set; }
|
||||||
public string Locale { get; private set; }
|
public string Locale { get; private set; }
|
||||||
|
|
||||||
|
internal AudioFormatEnum _audioFormat;
|
||||||
|
|
||||||
|
public AudioFormat AudioFormat { get => AudioFormat.FromEnum(_audioFormat); set => _audioFormat = value.ToEnum(); }
|
||||||
|
|
||||||
// mutable
|
// mutable
|
||||||
public string PictureId { get; set; }
|
public string PictureId { get; set; }
|
||||||
public string PictureLarge { get; set; }
|
public string PictureLarge { get; set; }
|
||||||
@@ -45,6 +50,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"
|
// non-null. use "empty pattern"
|
||||||
internal int CategoryId { get; private set; }
|
internal int CategoryId { get; private set; }
|
||||||
@@ -210,11 +216,12 @@ 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 void UpdateCategory(Category category, DbContext context = null)
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,20 +20,35 @@ 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));
|
||||||
Book = book;
|
Book = book;
|
||||||
|
|
||||||
// import previously saved tags
|
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
|
|
||||||
Tags = LibationFileManager.TagsPersistence.GetTags(book.AudibleProductId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Tags
|
#region Tags
|
||||||
@@ -55,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 "";
|
||||||
@@ -77,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
|
||||||
@@ -102,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
|
||||||
@@ -150,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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -93,10 +93,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
using DataLayer.Configurations;
|
using DataLayer.Configurations;
|
||||||
using Dinah.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
public class LibationContext : InterceptableDbContext
|
public class LibationContext : DbContext
|
||||||
{
|
{
|
||||||
// IMPORTANT: USING DbSet<>
|
// IMPORTANT: USING DbSet<>
|
||||||
// ========================
|
// ========================
|
||||||
@@ -35,14 +34,6 @@ namespace DataLayer
|
|||||||
// see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring()
|
// see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring()
|
||||||
internal LibationContext(DbContextOptions options) : base(options) { }
|
internal LibationContext(DbContextOptions options) : base(options) { }
|
||||||
|
|
||||||
// called on each instantiation
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
|
||||||
{
|
|
||||||
AddInterceptor(new TagPersistenceInterceptor());
|
|
||||||
|
|
||||||
base.OnConfiguring(optionsBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
// typically only called once per execution; NOT once per instantiation
|
// typically only called once per execution; NOT once per instantiation
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -56,15 +47,6 @@ namespace DataLayer
|
|||||||
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
|
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
|
||||||
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
||||||
|
|
||||||
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
// <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("20220624214932_AddAudioFormat")]
|
||||||
|
partial class AddAudioFormat
|
||||||
|
{
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
|
||||||
|
|
||||||
|
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.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,26 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
public partial class AddAudioFormat : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "_audioFormat",
|
||||||
|
table: "Books",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "_audioFormat",
|
||||||
|
table: "Books");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ 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.4");
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Book", b =>
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
{
|
{
|
||||||
@@ -41,6 +41,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");
|
||||||
|
|
||||||
@@ -56,6 +59,9 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.HasKey("BookId");
|
b.HasKey("BookId");
|
||||||
|
|
||||||
b.HasIndex("AudibleProductId");
|
b.HasIndex("AudibleProductId");
|
||||||
@@ -151,12 +157,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");
|
||||||
@@ -263,6 +275,12 @@ namespace DataLayer.Migrations
|
|||||||
b1.Property<int>("BookStatus")
|
b1.Property<int>("BookStatus")
|
||||||
.HasColumnType("INTEGER");
|
.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");
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ 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)
|
||||||
@@ -44,7 +58,7 @@ namespace DataLayer
|
|||||||
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
|
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
|
||||||
|
|
||||||
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
|
||||||
@@ -88,5 +102,14 @@ namespace DataLayer
|
|||||||
s.Series.AudibleSeriesId == parent.Book.AudibleProductId
|
s.Series.AudibleSeriesId == parent.Book.AudibleProductId
|
||||||
) == true
|
) == true
|
||||||
).ToList();
|
).ToList();
|
||||||
|
|
||||||
|
public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
|
||||||
|
=> bookList
|
||||||
|
.Where(
|
||||||
|
lb =>
|
||||||
|
!lb.AbsentFromLastScan &&
|
||||||
|
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||||
|
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Dinah.Core.Collections.Generic;
|
|
||||||
using Dinah.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace DataLayer
|
|
||||||
{
|
|
||||||
internal class TagPersistenceInterceptor : IDbInterceptor
|
|
||||||
{
|
|
||||||
public void Executed(DbContext context) { }
|
|
||||||
|
|
||||||
public void Executing(DbContext context)
|
|
||||||
{
|
|
||||||
var tagsCollection
|
|
||||||
= context
|
|
||||||
.ChangeTracker
|
|
||||||
.Entries()
|
|
||||||
.Where(e => e.State.In(EntityState.Modified, EntityState.Added))
|
|
||||||
.Select(e => e.Entity as UserDefinedItem)
|
|
||||||
.Where(udi => udi is not null)
|
|
||||||
// do NOT filter out entires with blank tags. blank is the valid way to show the absence of tags
|
|
||||||
.Select(t => (t.Book.AudibleProductId, t.Tags))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
LibationFileManager.TagsPersistence.Save(tagsCollection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -84,7 +84,8 @@ namespace DtoImporterService
|
|||||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||||
var authors = item
|
var authors = item
|
||||||
.Authors
|
.Authors
|
||||||
.Select(a => contributorImporter.Cache[a.Name])
|
.DistinctBy(a => a.Name)
|
||||||
|
.Select(a => contributorImporter.Cache[a.Name])
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var narrators
|
var narrators
|
||||||
@@ -94,14 +95,16 @@ namespace DtoImporterService
|
|||||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||||
: item
|
: item
|
||||||
.Narrators
|
.Narrators
|
||||||
.Select(n => contributorImporter.Cache[n.Name])
|
.DistinctBy(a => a.Name)
|
||||||
|
.Select(n => contributorImporter.Cache[n.Name])
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
||||||
// absence of categories is also possible
|
// absence of categories is also possible
|
||||||
|
|
||||||
// CATEGORY HACK: only use the 1st 2 categories
|
// CATEGORY HACK: only use the 1st 2 categories
|
||||||
// (real impl: var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";)
|
// after we support full arbitrary-depth category trees and multiple categories per book, the real impl will be something like this
|
||||||
|
// var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
|
||||||
var lastCategory
|
var lastCategory
|
||||||
= item.Categories.Length == 0 ? ""
|
= item.Categories.Length == 0 ? ""
|
||||||
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
|
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
|
||||||
@@ -149,9 +152,9 @@ namespace DtoImporterService
|
|||||||
book.ReplacePublisher(publisher);
|
book.ReplacePublisher(publisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
|
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
|
||||||
|
|
||||||
if (item.PdfUrl is not null)
|
if (item.PdfUrl is not null)
|
||||||
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||||
|
|
||||||
return book;
|
return book;
|
||||||
@@ -161,6 +164,9 @@ namespace DtoImporterService
|
|||||||
{
|
{
|
||||||
var item = importItem.DtoItem;
|
var item = importItem.DtoItem;
|
||||||
|
|
||||||
|
var codec = item.AvailableCodecs?.Max(f => AudioFormat.FromString(f.EnhancedCodec)) ?? new AudioFormat();
|
||||||
|
book.AudioFormat = codec;
|
||||||
|
|
||||||
// set/update book-specific info which may have changed
|
// set/update book-specific info which may have changed
|
||||||
if (item.PictureId is not null)
|
if (item.PictureId is not null)
|
||||||
book.PictureId = item.PictureId;
|
book.PictureId = item.PictureId;
|
||||||
@@ -168,7 +174,15 @@ namespace DtoImporterService
|
|||||||
if (item.PictureLarge is not null)
|
if (item.PictureLarge is not null)
|
||||||
book.PictureLarge = item.PictureLarge;
|
book.PictureLarge = item.PictureLarge;
|
||||||
|
|
||||||
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
// 2023-02-01
|
||||||
|
// updateBook must update language on books which were imported before the migration which added language.
|
||||||
|
// Can eventually delete this
|
||||||
|
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
|
||||||
|
|
||||||
|
book.UpdateProductRating(
|
||||||
|
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
|
||||||
|
(float)(item.Rating?.PerformanceDistribution?.AverageRating ?? 0),
|
||||||
|
(float)(item.Rating?.StoryDistribution?.AverageRating ?? 0));
|
||||||
|
|
||||||
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
|
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
|
||||||
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
|
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ namespace DtoImporterService
|
|||||||
return hash.Count;
|
return hash.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Contributor addContributor(string name, string id = null)
|
private Contributor addContributor(string name, string id = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -108,6 +108,6 @@ namespace DtoImporterService
|
|||||||
Serilog.Log.Logger.Error(ex, "Error adding contributor. {@DebugInfo}", new { name, id });
|
Serilog.Log.Logger.Error(ex, "Error adding contributor. {@DebugInfo}", new { name, id });
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ namespace DtoImporterService
|
|||||||
public Item DtoItem { get; set; }
|
public Item DtoItem { get; set; }
|
||||||
public string AccountId { get; set; }
|
public string AccountId { get; set; }
|
||||||
public string LocaleName { get; set; }
|
public string LocaleName { get; set; }
|
||||||
|
public override string ToString()
|
||||||
|
=> DtoItem is null ? base.ToString() : $"[{DtoItem.ProductId}] {DtoItem.Title}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ namespace DtoImporterService
|
|||||||
protected ItemsImporterBase(LibationContext context) : base(context) { }
|
protected ItemsImporterBase(LibationContext context) : base(context) { }
|
||||||
|
|
||||||
protected abstract IValidator Validator { get; }
|
protected abstract IValidator Validator { get; }
|
||||||
public sealed override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems)
|
public sealed override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems)
|
||||||
=> Validator.Validate(importItems.Select(i => i.DtoItem));
|
=> Validator.Validate(importItems.Select(i => i.DtoItem));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using AudibleUtilities;
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
|
using Dinah.Core;
|
||||||
using Dinah.Core.Collections.Generic;
|
using Dinah.Core.Collections.Generic;
|
||||||
|
|
||||||
namespace DtoImporterService
|
namespace DtoImporterService
|
||||||
@@ -40,22 +41,25 @@ namespace DtoImporterService
|
|||||||
//
|
//
|
||||||
// CURRENT SOLUTION: don't re-insert
|
// CURRENT SOLUTION: don't re-insert
|
||||||
|
|
||||||
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
|
|
||||||
var newItems = importItems
|
var newItems = importItems
|
||||||
.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId))
|
.ExceptBy(DbContext.LibraryBooks.Select(lb => lb.Book.AudibleProductId), imp => imp.DtoItem.ProductId)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin.
|
// if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin.
|
||||||
// just use the first
|
// just use the first
|
||||||
var hash = newItems.ToDictionarySafe(dto => dto.DtoItem.ProductId);
|
var hash = newItems.ToDictionarySafe(dto => dto.DtoItem.ProductId);
|
||||||
foreach (var kvp in hash)
|
foreach (var kvp in hash)
|
||||||
{
|
{
|
||||||
var newItem = kvp.Value;
|
var newItem = kvp.Value;
|
||||||
|
|
||||||
var libraryBook = new LibraryBook(
|
var libraryBook = new LibraryBook(
|
||||||
bookImporter.Cache[newItem.DtoItem.ProductId],
|
bookImporter.Cache[newItem.DtoItem.ProductId],
|
||||||
newItem.DtoItem.DateAdded,
|
newItem.DtoItem.DateAdded,
|
||||||
newItem.AccountId);
|
newItem.AccountId)
|
||||||
|
{
|
||||||
|
AbsentFromLastScan = isPlusTitleUnavailable(newItem)
|
||||||
|
};
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DbContext.LibraryBooks.Add(libraryBook);
|
DbContext.LibraryBooks.Add(libraryBook);
|
||||||
@@ -66,8 +70,50 @@ namespace DtoImporterService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList();
|
||||||
|
|
||||||
|
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
|
||||||
|
//Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned.
|
||||||
|
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts)))
|
||||||
|
nullBook.AbsentFromLastScan = true;
|
||||||
|
|
||||||
|
//Join importItems on LibraryBooks before iterating over LibraryBooks to avoid
|
||||||
|
//quadratic complexity caused by searching all of importItems for each LibraryBook.
|
||||||
|
//Join uses hashing, so complexity should approach O(N) instead of O(N^2).
|
||||||
|
var items_lbs
|
||||||
|
= importItems
|
||||||
|
.Join(DbContext.LibraryBooks, o => (o.AccountId, o.DtoItem.ProductId), i => (i.Account, i.Book?.AudibleProductId), (o, i) => (o, i));
|
||||||
|
|
||||||
|
foreach ((ImportItem item, LibraryBook lb) in items_lbs)
|
||||||
|
lb.AbsentFromLastScan = isPlusTitleUnavailable(item);
|
||||||
|
|
||||||
var qtyNew = hash.Count;
|
var qtyNew = hash.Count;
|
||||||
return qtyNew;
|
return qtyNew;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Subscription Plan Names:
|
||||||
|
*
|
||||||
|
* US: "SpecialBenefit"
|
||||||
|
* IT: "Rodizio"
|
||||||
|
*
|
||||||
|
* Audible Plus Plan Names:
|
||||||
|
*
|
||||||
|
* US: "US Minerva"
|
||||||
|
* IT: "Audible-AYCL"
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
//This SEEMS to work to detect plus titles which are no longer available.
|
||||||
|
//I have my doubts it won't yield false negatives, but I have more
|
||||||
|
//confidence that it won't yield many/any false positives.
|
||||||
|
private static bool isPlusTitleUnavailable(ImportItem item)
|
||||||
|
=> item.DtoItem.IsAyce is true
|
||||||
|
&& item.DtoItem.Plans?.Any(p =>
|
||||||
|
p.PlanName.ContainsInsensitive("Minerva") ||
|
||||||
|
p.PlanName.ContainsInsensitive("AYCL") ||
|
||||||
|
p.PlanName.ContainsInsensitive("Free")
|
||||||
|
) is not true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,10 +17,14 @@ namespace FileLiberator
|
|||||||
|
|
||||||
protected LameConfig GetLameOptions(Configuration config)
|
protected LameConfig GetLameOptions(Configuration config)
|
||||||
{
|
{
|
||||||
LameConfig lameConfig = new();
|
LameConfig lameConfig = new()
|
||||||
lameConfig.Mode = MPEGMode.Mono;
|
{
|
||||||
|
Mode = MPEGMode.Mono,
|
||||||
|
Quality = config.LameEncoderQuality,
|
||||||
|
OutputSampleRate = (int)config.MaxSampleRate
|
||||||
|
};
|
||||||
|
|
||||||
if (config.LameTargetBitrate)
|
if (config.LameTargetBitrate)
|
||||||
{
|
{
|
||||||
if (config.LameConstantBitrate)
|
if (config.LameConstantBitrate)
|
||||||
lameConfig.BitRate = config.LameBitrate;
|
lameConfig.BitRate = config.LameBitrate;
|
||||||
|
|||||||
@@ -25,13 +25,12 @@ namespace FileLiberator
|
|||||||
|
|
||||||
if (seriesParent is not null)
|
if (seriesParent is not null)
|
||||||
{
|
{
|
||||||
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto());
|
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
||||||
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir);
|
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
||||||
return Templates.Folder.GetFilename(libraryBook.ToDto());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ namespace FileLiberator
|
|||||||
public class ConvertToMp3 : AudioDecodable
|
public class ConvertToMp3 : AudioDecodable
|
||||||
{
|
{
|
||||||
public override string Name => "Convert to Mp3";
|
public override string Name => "Convert to Mp3";
|
||||||
private Mp4File m4bBook;
|
private Mp4Operation Mp4Operation;
|
||||||
|
private readonly AaxDecrypter.AverageSpeed averageSpeed = new();
|
||||||
private long fileSize;
|
|
||||||
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
||||||
|
|
||||||
public override Task CancelAsync() => m4bBook?.CancelAsync() ?? Task.CompletedTask;
|
public override Task CancelAsync() => Mp4Operation?.CancelAsync() ?? Task.CompletedTask;
|
||||||
|
|
||||||
public static bool ValidateMp3(LibraryBook libraryBook)
|
public static bool ValidateMp3(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
@@ -43,61 +42,81 @@ namespace FileLiberator
|
|||||||
var proposedMp3Path = Mp3FileName(m4bPath);
|
var proposedMp3Path = Mp3FileName(m4bPath);
|
||||||
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
|
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
|
||||||
|
|
||||||
m4bBook = new Mp4File(m4bPath, FileAccess.Read);
|
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
||||||
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
|
||||||
|
|
||||||
fileSize = m4bBook.InputStream.Length;
|
|
||||||
|
|
||||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||||
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||||
|
|
||||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
var config = Configuration.Instance;
|
||||||
var lameConfig = GetLameOptions(Configuration.Instance);
|
var lameConfig = GetLameOptions(config);
|
||||||
var result = await Task.Run(() => m4bBook.ConvertToMp3(mp3File, lameConfig));
|
|
||||||
m4bBook.InputStream.Close();
|
|
||||||
mp3File.Close();
|
|
||||||
|
|
||||||
if (result == ConversionResult.Failed)
|
//Finishing configuring lame encoder.
|
||||||
|
AaxDecrypter.MpegUtil.ConfigureLameOptions(
|
||||||
|
m4bBook,
|
||||||
|
lameConfig,
|
||||||
|
config.LameDownsampleMono,
|
||||||
|
config.LameMatchSourceBR);
|
||||||
|
|
||||||
|
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
||||||
|
try
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(mp3File.Name);
|
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig);
|
||||||
|
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||||
|
await Mp4Operation;
|
||||||
|
|
||||||
|
if (Mp4Operation.IsCanceled)
|
||||||
|
{
|
||||||
|
FileUtility.SaferDelete(mp3File.Name);
|
||||||
|
return new StatusHandler { "Cancelled" };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters, "mp3");
|
||||||
|
OnFileCreated(libraryBook, realMp3Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "AAXClean error");
|
||||||
return new StatusHandler { "Conversion failed" };
|
return new StatusHandler { "Conversion failed" };
|
||||||
}
|
}
|
||||||
else if (result == ConversionResult.Cancelled)
|
finally
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(mp3File.Name);
|
if (Mp4Operation is not null)
|
||||||
return new StatusHandler { "Cancelled" };
|
Mp4Operation.ConversionProgressUpdate -= M4bBook_ConversionProgressUpdate;
|
||||||
}
|
|
||||||
|
|
||||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path);
|
m4bBook.InputStream.Close();
|
||||||
OnFileCreated(libraryBook, realMp3Path);
|
mp3File.Close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return new StatusHandler();
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
OnCompleted(libraryBook);
|
OnCompleted(libraryBook);
|
||||||
}
|
}
|
||||||
|
return new StatusHandler();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||||
{
|
{
|
||||||
var duration = m4bBook.Duration;
|
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
|
||||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
|
||||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
|
||||||
|
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
|
||||||
|
|
||||||
if (double.IsNormal(estTimeRemaining))
|
if (double.IsNormal(estTimeRemaining))
|
||||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||||
|
|
||||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
double progressPercent = 100 * e.FractionCompleted;
|
||||||
|
|
||||||
OnStreamingProgressChanged(
|
OnStreamingProgressChanged(
|
||||||
new DownloadProgress
|
new DownloadProgress
|
||||||
{
|
{
|
||||||
ProgressPercentage = progressPercent,
|
ProgressPercentage = progressPercent,
|
||||||
BytesReceived = (long)(fileSize * progressPercent),
|
BytesReceived = (long)(e.ProcessPosition - e.StartTime).TotalSeconds,
|
||||||
TotalBytesToReceive = fileSize
|
TotalBytesToReceive = (long)(e.EndTime - e.StartTime).TotalSeconds
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AaxDecrypter;
|
using AaxDecrypter;
|
||||||
using ApplicationServices;
|
using ApplicationServices;
|
||||||
using AudibleApi;
|
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Dinah.Core.ErrorHandling;
|
using Dinah.Core.ErrorHandling;
|
||||||
@@ -14,293 +13,385 @@ using LibationFileManager;
|
|||||||
|
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public class DownloadDecryptBook : AudioDecodable
|
public class DownloadDecryptBook : AudioDecodable
|
||||||
{
|
{
|
||||||
public override string Name => "Download & Decrypt";
|
public override string Name => "Download & Decrypt";
|
||||||
private AudiobookDownloadBase abDownloader;
|
private AudiobookDownloadBase abDownloader;
|
||||||
|
|
||||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
||||||
|
|
||||||
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
|
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
|
||||||
|
|
||||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
var entries = new List<FilePathCache.CacheEntry>();
|
var entries = new List<FilePathCache.CacheEntry>();
|
||||||
// these only work so minimally b/c CacheEntry is a record.
|
// these only work so minimally b/c CacheEntry is a record.
|
||||||
// in case of parallel decrypts, only capture the ones for this book id.
|
// in case of parallel decrypts, only capture the ones for this book id.
|
||||||
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
|
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
|
||||||
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
|
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
|
||||||
{
|
{
|
||||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||||
entries.Add(e);
|
entries.Add(e);
|
||||||
}
|
}
|
||||||
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
|
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
|
||||||
{
|
{
|
||||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||||
entries.Remove(e);
|
entries.Remove(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
OnBegin(libraryBook);
|
OnBegin(libraryBook);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (libraryBook.Book.Audio_Exists())
|
if (libraryBook.Book.Audio_Exists())
|
||||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||||
|
|
||||||
bool success = false;
|
bool success = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
FilePathCache.Inserted += FilePathCache_Inserted;
|
||||||
FilePathCache.Removed += FilePathCache_Removed;
|
FilePathCache.Removed += FilePathCache_Removed;
|
||||||
|
|
||||||
success = await downloadAudiobookAsync(libraryBook);
|
success = await downloadAudiobookAsync(libraryBook);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
FilePathCache.Inserted -= FilePathCache_Inserted;
|
FilePathCache.Inserted -= FilePathCache_Inserted;
|
||||||
FilePathCache.Removed -= FilePathCache_Removed;
|
FilePathCache.Removed -= FilePathCache_Removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// decrypt failed
|
// decrypt failed
|
||||||
if (!success)
|
if (!success || getFirstAudioFile(entries) == default)
|
||||||
{
|
{
|
||||||
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
|
await Task.WhenAll(
|
||||||
FileUtility.SaferDelete(tmpFile.Path);
|
entries
|
||||||
|
.Where(f => f.FileType != FileType.AAXC)
|
||||||
|
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
|
||||||
|
|
||||||
return abDownloader?.IsCanceled == true ?
|
return
|
||||||
new StatusHandler { "Cancelled" } :
|
abDownloader?.IsCanceled is true
|
||||||
new StatusHandler { "Decrypt failed" };
|
? new StatusHandler { "Cancelled" }
|
||||||
}
|
: new StatusHandler { "Decrypt failed" };
|
||||||
|
}
|
||||||
|
|
||||||
// moves new files from temp dir to final dest.
|
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||||
// This could take a few seconds if moving hundreds of files.
|
|
||||||
var movedAudioFile = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
|
||||||
|
|
||||||
// decrypt failed
|
Task[] finalTasks = new[]
|
||||||
if (!movedAudioFile)
|
{
|
||||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
Task.Run(() => downloadCoverArt(libraryBook)),
|
||||||
|
Task.Run(() => moveFilesToBooksDir(libraryBook, entries)),
|
||||||
if (Configuration.Instance.DownloadCoverArt)
|
Task.Run(() => libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion)),
|
||||||
DownloadCoverArt(libraryBook);
|
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
|
||||||
|
|
||||||
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
|
||||||
|
|
||||||
return new StatusHandler();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
OnCompleted(libraryBook);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
|
||||||
{
|
|
||||||
var config = Configuration.Instance;
|
|
||||||
|
|
||||||
downloadValidation(libraryBook);
|
|
||||||
|
|
||||||
var api = await libraryBook.GetApiAsync();
|
|
||||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
|
||||||
var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
|
||||||
|
|
||||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
|
||||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
|
||||||
|
|
||||||
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
|
||||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AaxcDownloadConvertBase converter
|
|
||||||
= config.SplitFilesByChapter ?
|
|
||||||
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
|
||||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
|
||||||
|
|
||||||
if (config.AllowLibationFixup)
|
|
||||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
|
||||||
|
|
||||||
abDownloader = converter;
|
|
||||||
}
|
|
||||||
|
|
||||||
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
|
||||||
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
|
||||||
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
|
||||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
|
||||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
|
||||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
|
||||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
|
||||||
|
|
||||||
// REAL WORK DONE HERE
|
|
||||||
var success = await Task.Run(abDownloader.Run);
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
|
||||||
{
|
|
||||||
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
|
|
||||||
//I also assume that if DrmType != Adrm, the file will be an mp3.
|
|
||||||
//These assumptions may be wrong, and only time and bug reports will tell.
|
|
||||||
|
|
||||||
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
|
|
||||||
|
|
||||||
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
|
|
||||||
OutputFormat.Mp3 : OutputFormat.M4b;
|
|
||||||
|
|
||||||
var dlOptions = new DownloadOptions
|
|
||||||
(
|
|
||||||
libraryBook,
|
|
||||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
|
||||||
Resources.USER_AGENT
|
|
||||||
)
|
|
||||||
{
|
|
||||||
AudibleKey = contentLic?.Voucher?.Key,
|
|
||||||
AudibleIV = contentLic?.Voucher?.Iv,
|
|
||||||
OutputFormat = outputFormat,
|
|
||||||
TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio,
|
|
||||||
RetainEncryptedFile = config.RetainAaxFile && encrypted,
|
|
||||||
StripUnabridged = config.AllowLibationFixup && config.StripUnabridged,
|
|
||||||
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
|
|
||||||
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
|
|
||||||
CreateCueSheet = config.CreateCueSheet,
|
|
||||||
LameConfig = GetLameOptions(config)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var chapters = getChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
await Task.WhenAll(finalTasks);
|
||||||
|
|
||||||
if (config.AllowLibationFixup || outputFormat == OutputFormat.Mp3)
|
return new StatusHandler();
|
||||||
{
|
}
|
||||||
long startMs = dlOptions.TrimOutputToChapterLength ?
|
finally
|
||||||
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
|
{
|
||||||
|
OnCompleted(libraryBook);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dlOptions.ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(startMs));
|
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
||||||
|
{
|
||||||
|
var config = Configuration.Instance;
|
||||||
|
|
||||||
for (int i = 0; i < chapters.Count; i++)
|
downloadValidation(libraryBook);
|
||||||
{
|
|
||||||
var chapter = chapters[i];
|
|
||||||
long chapLenMs = chapter.LengthMs;
|
|
||||||
|
|
||||||
if (i == 0)
|
var api = await libraryBook.GetApiAsync();
|
||||||
chapLenMs -= startMs;
|
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||||
|
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
||||||
|
|
||||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
||||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||||
|
|
||||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
||||||
}
|
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
||||||
}
|
else
|
||||||
|
{
|
||||||
|
AaxcDownloadConvertBase converter
|
||||||
|
= config.SplitFilesByChapter ?
|
||||||
|
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
||||||
|
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
||||||
|
|
||||||
return dlOptions;
|
if (config.AllowLibationFixup)
|
||||||
}
|
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
||||||
|
|
||||||
private List<AudibleApi.Common.Chapter> getChapters(IEnumerable<AudibleApi.Common.Chapter> chapters)
|
abDownloader = converter;
|
||||||
{
|
}
|
||||||
List<AudibleApi.Common.Chapter> chaps = chapters.ToList();
|
|
||||||
|
|
||||||
foreach (var c in chapters)
|
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||||
{
|
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||||
if (c.Chapters is not null)
|
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||||
{
|
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||||
var children = getChapters(c.Chapters);
|
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||||
|
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||||
|
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||||
|
|
||||||
foreach (var child in children)
|
// REAL WORK DONE HERE
|
||||||
child.Title = string.IsNullOrEmpty(c.Title) ? child.Title : $"{c.Title}: {child.Title}";
|
return await abDownloader.RunAsync();
|
||||||
|
}
|
||||||
|
|
||||||
chaps.AddRange(children);
|
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
||||||
}
|
{
|
||||||
}
|
//If DrmType != Adrm the delivered file is an unencrypted mp3.
|
||||||
return chaps;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void downloadValidation(LibraryBook libraryBook)
|
var outputFormat
|
||||||
{
|
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
|
||||||
string errorString(string field)
|
? OutputFormat.Mp3
|
||||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
: OutputFormat.M4b;
|
||||||
|
|
||||||
string errorTitle()
|
long chapterStartMs
|
||||||
{
|
= config.StripAudibleBrandAudio
|
||||||
var title
|
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||||
= (libraryBook.Book.Title.Length > 53)
|
: 0;
|
||||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
|
||||||
: libraryBook.Book.Title;
|
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
|
||||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
{
|
||||||
return errorBookTitle;
|
AudibleKey = contentLic?.Voucher?.Key,
|
||||||
|
AudibleIV = contentLic?.Voucher?.Iv,
|
||||||
|
OutputFormat = outputFormat,
|
||||||
|
LameConfig = GetLameOptions(config),
|
||||||
|
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||||
|
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
||||||
throw new Exception(errorString("Account"));
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
if (config.MergeOpeningAndEndCredits)
|
||||||
throw new Exception(errorString("Locale"));
|
combineCredits(chapters);
|
||||||
}
|
|
||||||
|
|
||||||
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
for (int i = 0; i < chapters.Count; i++)
|
||||||
{
|
{
|
||||||
if (e is not null)
|
var chapter = chapters[i];
|
||||||
OnCoverImageDiscovered(e);
|
long chapLenMs = chapter.LengthMs;
|
||||||
else if (Configuration.Instance.AllowLibationFixup)
|
|
||||||
abDownloader.SetCoverArt(OnRequestCoverArt());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Move new files to 'Books' directory</summary>
|
if (i == 0)
|
||||||
/// <returns>True if audiobook file(s) were successfully created and can be located on disk. Else false.</returns>
|
chapLenMs -= chapterStartMs;
|
||||||
private static bool moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
|
||||||
{
|
|
||||||
// create final directory. move each file into it
|
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
|
||||||
Directory.CreateDirectory(destinationDir);
|
|
||||||
|
|
||||||
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||||
|
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||||
|
|
||||||
if (getFirstAudio() == default)
|
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||||
return false;
|
}
|
||||||
|
|
||||||
|
return dlOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
Flatten Audible's new hierarchical chapters, combining children into parents.
|
||||||
|
|
||||||
|
Audible may deliver chapters like this:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 00:12 Book 1
|
||||||
|
00:12 - 00:14 | Part 1
|
||||||
|
00:14 - 01:40 | | Chapter 1
|
||||||
|
01:40 - 03:20 | | Chapter 2
|
||||||
|
03:20 - 03:22 | Part 2
|
||||||
|
03:22 - 05:00 | | Chapter 3
|
||||||
|
05:00 - 06:40 | | Chapter 4
|
||||||
|
06:40 - 06:42 Book 2
|
||||||
|
06:42 - 06:44 | Part 3
|
||||||
|
06:44 - 08:20 | | Chapter 5
|
||||||
|
08:20 - 10:00 | | Chapter 6
|
||||||
|
10:00 - 10:02 | Part 4
|
||||||
|
10:02 - 11:40 | | Chapter 7
|
||||||
|
11:40 - 13:20 | | Chapter 8
|
||||||
|
13:20 - 13:30 End Credits
|
||||||
|
|
||||||
|
And flattenChapters will combine them into this:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 01:40 Book 1: Part 1: Chapter 1
|
||||||
|
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||||
|
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||||
|
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||||
|
06:40 - 08:20 Book 2: Part 3: Chapter 5
|
||||||
|
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||||
|
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||||
|
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||||
|
13:20 - 13:40 End Credits
|
||||||
|
|
||||||
|
However, if one of the parent chapters is longer than 10000 milliseconds, it's kept as its own
|
||||||
|
chapter. A duration longer than a few seconds implies that the chapter contains more than just
|
||||||
|
the narrator saying the chapter title, so it should probably be preserved as a separate chapter.
|
||||||
|
Using the example above, if "Book 1" was 15 seconds long and "Part 3" was 20 seconds long:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 00:25 Book 1
|
||||||
|
00:25 - 00:27 | Part 1
|
||||||
|
00:27 - 01:40 | | Chapter 1
|
||||||
|
01:40 - 03:20 | | Chapter 2
|
||||||
|
03:20 - 03:22 | Part 2
|
||||||
|
03:22 - 05:00 | | Chapter 3
|
||||||
|
05:00 - 06:40 | | Chapter 4
|
||||||
|
06:40 - 06:42 Book 2
|
||||||
|
06:42 - 07:02 | Part 3
|
||||||
|
07:02 - 08:20 | | Chapter 5
|
||||||
|
08:20 - 10:00 | | Chapter 6
|
||||||
|
10:00 - 10:02 | Part 4
|
||||||
|
10:02 - 11:40 | | Chapter 7
|
||||||
|
11:40 - 13:20 | | Chapter 8
|
||||||
|
13:20 - 13:30 End Credits
|
||||||
|
|
||||||
|
then flattenChapters will combine them into this:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 00:25 Book 1
|
||||||
|
00:25 - 01:40 Book 1: Part 1: Chapter 1
|
||||||
|
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||||
|
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||||
|
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||||
|
06:40 - 07:02 Book 2: Part 3
|
||||||
|
07:02 - 08:20 Book 2: Part 3: Chapter 5
|
||||||
|
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||||
|
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||||
|
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||||
|
13:20 - 13:40 End Credits
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
public static List<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
|
||||||
|
{
|
||||||
|
List<AudibleApi.Common.Chapter> chaps = new();
|
||||||
|
|
||||||
|
foreach (var c in chapters)
|
||||||
|
{
|
||||||
|
if (c.Chapters is null)
|
||||||
|
chaps.Add(c);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (c.LengthMs < 10000)
|
||||||
|
{
|
||||||
|
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
||||||
|
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
||||||
|
c.Chapters[0].LengthMs += c.LengthMs;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
chaps.Add(c);
|
||||||
|
|
||||||
|
var children = flattenChapters(c.Chapters);
|
||||||
|
|
||||||
|
foreach (var child in children)
|
||||||
|
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||||
|
|
||||||
|
chaps.AddRange(children);
|
||||||
|
c.Chapters = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void combineCredits(IList<AudibleApi.Common.Chapter> chapters)
|
||||||
|
{
|
||||||
|
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
||||||
|
{
|
||||||
|
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
||||||
|
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
||||||
|
chapters[1].LengthMs += chapters[0].LengthMs;
|
||||||
|
chapters.RemoveAt(0);
|
||||||
|
}
|
||||||
|
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
||||||
|
{
|
||||||
|
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
||||||
|
chapters.Remove(chapters[^1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void downloadValidation(LibraryBook libraryBook)
|
||||||
|
{
|
||||||
|
string errorString(string field)
|
||||||
|
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||||
|
|
||||||
|
string errorTitle()
|
||||||
|
{
|
||||||
|
var title
|
||||||
|
= (libraryBook.Book.Title.Length > 53)
|
||||||
|
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||||
|
: libraryBook.Book.Title;
|
||||||
|
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||||
|
return errorBookTitle;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||||
|
throw new Exception(errorString("Account"));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||||
|
throw new Exception(errorString("Locale"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
||||||
|
{
|
||||||
|
if (e is not null)
|
||||||
|
OnCoverImageDiscovered(e);
|
||||||
|
else if (Configuration.Instance.AllowLibationFixup)
|
||||||
|
abDownloader.SetCoverArt(OnRequestCoverArt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Move new files to 'Books' directory</summary>
|
||||||
|
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||||
|
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||||
|
{
|
||||||
|
// create final directory. move each file into it
|
||||||
|
var destinationDir = getDestinationDirectory(libraryBook);
|
||||||
|
|
||||||
for (var i = 0; i < entries.Count; i++)
|
for (var i = 0; i < entries.Count; i++)
|
||||||
{
|
{
|
||||||
var entry = entries[i];
|
var entry = entries[i];
|
||||||
|
|
||||||
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)));
|
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
|
||||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||||
|
|
||||||
// propogate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||||
entries[i] = entry with { Path = realDest };
|
entries[i] = entry with { Path = realDest };
|
||||||
}
|
}
|
||||||
|
|
||||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||||
if (cue != default)
|
if (cue != default)
|
||||||
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path);
|
||||||
|
|
||||||
AudibleFileStorage.Audio.Refresh();
|
AudibleFileStorage.Audio.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
private static string getDestinationDirectory(LibraryBook libraryBook)
|
||||||
}
|
{
|
||||||
|
|
||||||
private void DownloadCoverArt(LibraryBook libraryBook)
|
|
||||||
{
|
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||||
var coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
if (!Directory.Exists(destinationDir))
|
||||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
Directory.CreateDirectory(destinationDir);
|
||||||
|
return destinationDir;
|
||||||
try
|
|
||||||
{
|
|
||||||
if (File.Exists(coverPath))
|
|
||||||
FileUtility.SaferDelete(coverPath);
|
|
||||||
|
|
||||||
(string picId, PictureSize size) = libraryBook.Book.PictureLarge is null ?
|
|
||||||
(libraryBook.Book.PictureId, PictureSize.Native) :
|
|
||||||
(libraryBook.Book.PictureLarge, PictureSize.Native);
|
|
||||||
|
|
||||||
var picBytes = PictureStorage.GetPictureSynchronously(new PictureDefinition(picId, size));
|
|
||||||
|
|
||||||
if (picBytes.Length > 0)
|
|
||||||
File.WriteAllBytes(coverPath, picBytes);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
//Failure to download cover art should not be
|
|
||||||
//considered a failure to download the book
|
|
||||||
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
|
||||||
|
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||||
|
|
||||||
|
private static void downloadCoverArt(LibraryBook libraryBook)
|
||||||
|
{
|
||||||
|
if (!Configuration.Instance.DownloadCoverArt) return;
|
||||||
|
|
||||||
|
var coverPath = "[null]";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var destinationDir = getDestinationDirectory(libraryBook);
|
||||||
|
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
||||||
|
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
||||||
|
|
||||||
|
if (File.Exists(coverPath))
|
||||||
|
FileUtility.SaferDelete(coverPath);
|
||||||
|
|
||||||
|
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
|
||||||
|
if (picBytes.Length > 0)
|
||||||
|
File.WriteAllBytes(coverPath, picBytes);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
//Failure to download cover art should not be considered a failure to download the book
|
||||||
|
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,42 +3,94 @@ using AAXClean;
|
|||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using ApplicationServices;
|
||||||
|
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public class DownloadOptions : IDownloadOptions
|
public class DownloadOptions : IDownloadOptions, IDisposable
|
||||||
{
|
{
|
||||||
public LibraryBook LibraryBook { get; }
|
public event EventHandler<long> DownloadSpeedChanged;
|
||||||
public LibraryBookDto LibraryBookDto { get; }
|
public LibraryBook LibraryBook { get; }
|
||||||
public string DownloadUrl { get; }
|
public LibraryBookDto LibraryBookDto { get; }
|
||||||
public string UserAgent { get; }
|
public string DownloadUrl { get; }
|
||||||
public string AudibleKey { get; init; }
|
public string AudibleKey { get; init; }
|
||||||
public string AudibleIV { get; init; }
|
public string AudibleIV { get; init; }
|
||||||
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
|
public TimeSpan RuntimeLength { get; init; }
|
||||||
public bool TrimOutputToChapterLength { get; init; }
|
public OutputFormat OutputFormat { get; init; }
|
||||||
public bool RetainEncryptedFile { get; init; }
|
public ChapterInfo ChapterInfo { get; init; }
|
||||||
public bool StripUnabridged { get; init; }
|
public NAudio.Lame.LameConfig LameConfig { get; init; }
|
||||||
public bool CreateCueSheet { get; init; }
|
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
||||||
public ChapterInfo ChapterInfo { get; set; }
|
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
|
||||||
public NAudio.Lame.LameConfig LameConfig { get; set; }
|
public bool StripUnabridged => config.AllowLibationFixup && config.StripUnabridged;
|
||||||
public bool Downsample { get; set; }
|
public bool CreateCueSheet => config.CreateCueSheet;
|
||||||
public bool MatchSourceBitrate { get; set; }
|
public bool DownloadClipsBookmarks => config.DownloadClipsBookmarks;
|
||||||
|
public long DownloadSpeedBps => config.DownloadSpeedLimit;
|
||||||
|
public bool RetainEncryptedFile => config.RetainAaxFile;
|
||||||
|
public bool FixupFile => config.AllowLibationFixup;
|
||||||
|
public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono;
|
||||||
|
public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate;
|
||||||
|
public bool MoveMoovToBeginning => config.MoveMoovToBeginning;
|
||||||
|
|
||||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
public string GetMultipartFileName(MultiConvertFileProperties props)
|
||||||
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
|
{
|
||||||
|
var baseDir = Path.GetDirectoryName(props.OutputFileName);
|
||||||
|
var extension = Path.GetExtension(props.OutputFileName);
|
||||||
|
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir, extension);
|
||||||
|
}
|
||||||
|
|
||||||
public string GetMultipartTitleName(MultiConvertFileProperties props)
|
public string GetMultipartTitle(MultiConvertFileProperties props)
|
||||||
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
|
=> Templates.ChapterTitle.GetName(LibraryBookDto, props);
|
||||||
|
|
||||||
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
|
public async Task<string> SaveClipsAndBookmarksAsync(string fileName)
|
||||||
{
|
{
|
||||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
if (DownloadClipsBookmarks)
|
||||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
{
|
||||||
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
var format = config.ClipsBookmarksFileFormat;
|
||||||
|
|
||||||
LibraryBookDto = LibraryBook.ToDto();
|
var formatExtension = format.ToString().ToLowerInvariant();
|
||||||
|
var filePath = Path.ChangeExtension(fileName, formatExtension);
|
||||||
|
|
||||||
// no null/empty check for key/iv. unencrypted files do not have them
|
var api = await LibraryBook.GetApiAsync();
|
||||||
}
|
var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId);
|
||||||
}
|
|
||||||
|
switch(format)
|
||||||
|
{
|
||||||
|
case Configuration.ClipBookmarkFormat.CSV:
|
||||||
|
RecordExporter.ToCsv(filePath, records);
|
||||||
|
break;
|
||||||
|
case Configuration.ClipBookmarkFormat.Xlsx:
|
||||||
|
RecordExporter.ToXlsx(filePath, records);
|
||||||
|
break;
|
||||||
|
case Configuration.ClipBookmarkFormat.Json:
|
||||||
|
RecordExporter.ToJson(filePath, LibraryBook, records);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Configuration config;
|
||||||
|
private readonly IDisposable cancellation;
|
||||||
|
public void Dispose() => cancellation?.Dispose();
|
||||||
|
|
||||||
|
public DownloadOptions(Configuration config, LibraryBook libraryBook, string downloadUrl)
|
||||||
|
{
|
||||||
|
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
||||||
|
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||||
|
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||||
|
// no null/empty check for key/iv. unencrypted files do not have them
|
||||||
|
|
||||||
|
LibraryBookDto = LibraryBook.ToDto();
|
||||||
|
|
||||||
|
cancellation =
|
||||||
|
config
|
||||||
|
.ObservePropertyChanged<long>(
|
||||||
|
nameof(Configuration.DownloadSpeedLimit),
|
||||||
|
newVal => DownloadSpeedChanged?.Invoke(this, newVal));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ namespace FileLiberator
|
|||||||
{
|
{
|
||||||
OnBegin(libraryBook);
|
OnBegin(libraryBook);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
|
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
|
||||||
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||||
@@ -32,13 +32,22 @@ namespace FileLiberator
|
|||||||
|
|
||||||
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
|
||||||
|
|
||||||
|
var result = new StatusHandler();
|
||||||
|
result.AddError($"Error downloading PDF. See log for details. Error summary: {ex.Message}");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
OnCompleted(libraryBook);
|
OnCompleted(libraryBook);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
|
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -11,4 +11,13 @@
|
|||||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -54,6 +54,8 @@ namespace FileLiberator
|
|||||||
= (await ProcessAsync(libraryBook))
|
= (await ProcessAsync(libraryBook))
|
||||||
?? new StatusHandler { "Processable should never return a null status" };
|
?? new StatusHandler { "Processable should never return a null status" };
|
||||||
|
|
||||||
|
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,17 +27,26 @@ namespace FileLiberator
|
|||||||
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
|
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
|
||||||
{
|
{
|
||||||
Account = libraryBook.Account,
|
Account = libraryBook.Account,
|
||||||
|
DateAdded = libraryBook.DateAdded,
|
||||||
|
|
||||||
AudibleProductId = libraryBook.Book.AudibleProductId,
|
AudibleProductId = libraryBook.Book.AudibleProductId,
|
||||||
Title = libraryBook.Book.Title ?? "",
|
Title = libraryBook.Book.Title ?? "",
|
||||||
Locale = libraryBook.Book.Locale,
|
Locale = libraryBook.Book.Locale,
|
||||||
|
YearPublished = libraryBook.Book.DatePublished?.Year,
|
||||||
|
DatePublished = libraryBook.Book.DatePublished,
|
||||||
|
|
||||||
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
||||||
|
|
||||||
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
||||||
|
|
||||||
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
||||||
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order
|
SeriesNumber = (int?)libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
|
||||||
|
IsPodcast = libraryBook.Book.IsEpisodeChild(),
|
||||||
|
|
||||||
|
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
||||||
|
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
||||||
|
Channels = libraryBook.Book.AudioFormat.Channels,
|
||||||
|
Language = libraryBook.Book.Language
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileLiberator) + ".Tests")]
|
|
||||||
@@ -79,8 +79,13 @@ namespace FileManager
|
|||||||
//Stop raising events
|
//Stop raising events
|
||||||
fileSystemWatcher?.Dispose();
|
fileSystemWatcher?.Dispose();
|
||||||
|
|
||||||
//Calling CompleteAdding() will cause background scanner to terminate.
|
try
|
||||||
directoryChangesEvents?.CompleteAdding();
|
{
|
||||||
|
//Calling CompleteAdding() will cause background scanner to terminate.
|
||||||
|
directoryChangesEvents?.CompleteAdding();
|
||||||
|
}
|
||||||
|
// if directoryChangesEvents is non-null and isDisposed, this exception is thrown. there's no other way to check >:(
|
||||||
|
catch (ObjectDisposedException) { }
|
||||||
|
|
||||||
//Wait for background scanner to terminate before reinitializing.
|
//Wait for background scanner to terminate before reinitializing.
|
||||||
backgroundScanner?.Wait();
|
backgroundScanner?.Wait();
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="4.4.0.1" />
|
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
|
||||||
<PackageReference Include="Polly" Version="7.2.3" />
|
<PackageReference Include="Polly" Version="7.2.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace FileManager
|
|
||||||
{
|
|
||||||
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
|
||||||
public class FileNamingTemplate : NamingTemplate
|
|
||||||
{
|
|
||||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
|
||||||
public FileNamingTemplate(string template) : base(template) { }
|
|
||||||
|
|
||||||
/// <summary>Optional step 2: Replace all illegal characters with this. Default=<see cref="string.Empty"/></summary>
|
|
||||||
public string IllegalCharacterReplacements { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Generate a valid path for this file or directory</summary>
|
|
||||||
public LongPath GetFilePath(bool returnFirstExisting = false)
|
|
||||||
{
|
|
||||||
|
|
||||||
string fileName = Template.EndsWith(Path.DirectorySeparatorChar) ? Template[..^1] : Template;
|
|
||||||
List<string> pathParts = new();
|
|
||||||
|
|
||||||
var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value));
|
|
||||||
|
|
||||||
while (!string.IsNullOrEmpty(fileName))
|
|
||||||
{
|
|
||||||
var file = Path.GetFileName(fileName);
|
|
||||||
|
|
||||||
if (Path.IsPathRooted(Template) && file == string.Empty)
|
|
||||||
{
|
|
||||||
pathParts.Add(fileName);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
file = replaceFileName(file, paramReplacements);
|
|
||||||
fileName = Path.GetDirectoryName(fileName);
|
|
||||||
pathParts.Add(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pathParts.Reverse();
|
|
||||||
|
|
||||||
return FileUtility.GetValidFilename(Path.Join(pathParts.ToArray()), IllegalCharacterReplacements, returnFirstExisting);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string replaceFileName(string filename, Dictionary<string,string> paramReplacements)
|
|
||||||
{
|
|
||||||
List<StringBuilder> filenameParts = new();
|
|
||||||
//Build the filename in parts, replacing replacement parameters with
|
|
||||||
//their values, and storing the parts in a list.
|
|
||||||
while (!string.IsNullOrEmpty(filename))
|
|
||||||
{
|
|
||||||
int openIndex = filename.IndexOf('<');
|
|
||||||
int closeIndex = filename.IndexOf('>');
|
|
||||||
|
|
||||||
if (openIndex == 0 && closeIndex > 0)
|
|
||||||
{
|
|
||||||
var key = filename[..(closeIndex + 1)];
|
|
||||||
|
|
||||||
if (paramReplacements.ContainsKey(key))
|
|
||||||
filenameParts.Add(new StringBuilder(paramReplacements[key]));
|
|
||||||
else
|
|
||||||
filenameParts.Add(new StringBuilder(key));
|
|
||||||
|
|
||||||
filename = filename[(closeIndex + 1)..];
|
|
||||||
}
|
|
||||||
else if (openIndex > 0 && closeIndex > openIndex)
|
|
||||||
{
|
|
||||||
var other = filename[..openIndex];
|
|
||||||
filenameParts.Add(new StringBuilder(other));
|
|
||||||
filename = filename[openIndex..];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
filenameParts.Add(new StringBuilder(filename));
|
|
||||||
filename = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//Remove 1 character from the end of the longest filename part until
|
|
||||||
//the total filename is less than max filename length
|
|
||||||
while (filenameParts.Sum(p => p.Length) > LongPath.MaxFilenameLength)
|
|
||||||
{
|
|
||||||
int maxLength = filenameParts.Max(p => p.Length);
|
|
||||||
var maxEntry = filenameParts.First(p => p.Length == maxLength);
|
|
||||||
|
|
||||||
maxEntry.Remove(maxLength - 1, 1);
|
|
||||||
}
|
|
||||||
return string.Join("", filenameParts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string formatValue(object value)
|
|
||||||
{
|
|
||||||
if (value is null)
|
|
||||||
return "";
|
|
||||||
|
|
||||||
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
|
|
||||||
// Esp important for file templates.
|
|
||||||
return value
|
|
||||||
.ToString()
|
|
||||||
.Replace($"{System.IO.Path.DirectorySeparatorChar}", IllegalCharacterReplacements)
|
|
||||||
.Replace($"{System.IO.Path.AltDirectorySeparatorChar}", IllegalCharacterReplacements);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loaded 100 of 495 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user