Compare commits
1260 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55fa82f92e | ||
|
|
4a0c2b2180 | ||
|
|
c77fe5d561 | ||
|
|
359d082ffd | ||
|
|
017bdba404 | ||
|
|
d4bf13b3fd | ||
|
|
87b695b2de | ||
|
|
222b16113e | ||
|
|
75c07c3209 | ||
|
|
e640edee7f | ||
|
|
6c48fc1f5e | ||
|
|
e5708a382b | ||
|
|
da9cb3371f | ||
|
|
91d0f8020e | ||
|
|
156726ca95 | ||
|
|
3dad4c194b | ||
|
|
6025a7538a | ||
|
|
824f65baae | ||
|
|
9372a7318b | ||
|
|
ddd032c16d | ||
|
|
9aaf523240 | ||
|
|
8cbdeb38fa | ||
|
|
a9258a1811 | ||
|
|
0dbc42c407 | ||
|
|
2c91de1b3b | ||
|
|
607cd07b74 | ||
|
|
64d080336c | ||
|
|
fd510861c6 | ||
|
|
3fdfbb9e26 | ||
|
|
3e74898dac | ||
|
|
d6fe3013ab | ||
|
|
265794bae0 | ||
|
|
7586f7a159 | ||
|
|
5dfddfb549 | ||
|
|
98bb06378a | ||
|
|
429367d21c | ||
|
|
ea9e36fd76 | ||
|
|
fe534b335b | ||
|
|
6db3a8fbf3 | ||
|
|
48c69a1339 | ||
|
|
1ab882f327 | ||
|
|
019b110a8a | ||
|
|
9e14169e15 | ||
|
|
e08a68219d | ||
|
|
af24c6e07b | ||
|
|
e31847e669 | ||
|
|
c4f55d2ad1 | ||
|
|
1439e38cb0 | ||
|
|
4456432116 | ||
|
|
df2936e0b6 | ||
|
|
53b5c1b902 | ||
|
|
82fba7e752 | ||
|
|
1a95f2923b | ||
|
|
1939aae81c | ||
|
|
9a663fda15 | ||
|
|
84b2996102 | ||
|
|
af8e1cd5ef | ||
|
|
8a1b375f0d | ||
|
|
6800986f25 | ||
|
|
6110b08d16 | ||
|
|
666b5d83df | ||
|
|
7db5a34f1b | ||
|
|
e52772826a | ||
|
|
8ea9b2abc6 | ||
|
|
c10bb276f5 | ||
|
|
9dcb3b3a25 | ||
|
|
d857882220 | ||
|
|
d731db4036 | ||
|
|
ca5b40b176 | ||
|
|
b29ec26f63 | ||
|
|
7569b01bd0 | ||
|
|
6465b0a885 | ||
|
|
5e99cb6f02 | ||
|
|
d737cd2199 | ||
|
|
2d2907e076 | ||
|
|
05c454dce4 | ||
|
|
e64a9d2adf | ||
|
|
6252f015b3 | ||
|
|
7ada0082a9 | ||
|
|
826e53c9cb | ||
|
|
2248d7b24e | ||
|
|
69918c2587 | ||
|
|
1991bf5b4d | ||
|
|
756d387238 | ||
|
|
8d73f5cc7e | ||
|
|
4a65d6bbd3 | ||
|
|
10a1b56b3c | ||
|
|
66fb392b7f | ||
|
|
49ef96055c | ||
|
|
cb4a209f69 | ||
|
|
255e18eb5e | ||
|
|
7e1ec47b46 | ||
|
|
40c725b8c2 | ||
|
|
5d0937dc48 | ||
|
|
bff81bfc4b | ||
|
|
aa7c159985 | ||
|
|
012d94a146 | ||
|
|
22bd1ed121 | ||
|
|
c832f26b08 | ||
|
|
efd73d334e | ||
|
|
0db3ee6fd7 | ||
|
|
6aaf4f63d1 | ||
|
|
ab392a9285 | ||
|
|
efc9ff4bd8 | ||
|
|
a52b466c85 | ||
|
|
5611431abf | ||
|
|
a75932d1f4 | ||
|
|
6c8464b650 | ||
|
|
ba4a1c5a51 | ||
|
|
3681c0f18f | ||
|
|
e365ba7296 | ||
|
|
2afb5365dd | ||
|
|
00cf7693d5 | ||
|
|
dac6877a06 | ||
|
|
36005508a1 | ||
|
|
d9e27fd32e | ||
|
|
d86bcbb414 | ||
|
|
00cbab5b58 | ||
|
|
807725f6ff | ||
|
|
ec9356b36e | ||
|
|
add31024da | ||
|
|
27d2ada5a4 | ||
|
|
702219ee69 | ||
|
|
cdf1a01457 | ||
|
|
a71ccbac6e | ||
|
|
f8c6b836c3 | ||
|
|
090871f50d | ||
|
|
e62f01d2a3 | ||
|
|
68af6a5ebb | ||
|
|
8bba8538d5 | ||
|
|
2cd9b86930 | ||
|
|
b876d90964 | ||
|
|
49c91c273b | ||
|
|
c07bc88493 | ||
|
|
397a516dc1 | ||
|
|
1c2b51aa83 | ||
|
|
fc6f494f0d | ||
|
|
7289459170 | ||
|
|
ed6f741a65 | ||
|
|
1783da3e2d | ||
|
|
e7eac7bed3 | ||
|
|
9ae1f0399b | ||
|
|
784ab73a36 | ||
|
|
99687e968e | ||
|
|
565c84c4ab | ||
|
|
18cf20ecad | ||
|
|
2725340994 | ||
|
|
56de1e7659 | ||
|
|
fd16e97632 | ||
|
|
36076242a7 | ||
|
|
718e6c14d0 | ||
|
|
eb61ba3d69 | ||
|
|
defabf7356 | ||
|
|
1149c10cf1 | ||
|
|
ec7dd1b54a | ||
|
|
bb900b31ef | ||
|
|
eed42bd108 | ||
|
|
3f0e6b9ee5 | ||
|
|
5ec01913d5 | ||
|
|
245e55782e | ||
|
|
cc306e0e19 | ||
|
|
26a9bc6bbf | ||
|
|
fb9d062545 | ||
|
|
49c6b391fd | ||
|
|
e1cd8b8f94 | ||
|
|
ef1edf1136 | ||
|
|
0def1b426a | ||
|
|
230e014bb1 | ||
|
|
34f56d2fd7 | ||
|
|
c45ffaf4a6 | ||
|
|
ae43ab103e | ||
|
|
559977ce0b | ||
|
|
ccd4d3e26d | ||
|
|
e76f99ff28 | ||
|
|
d3607583ab | ||
|
|
3ebd4ce243 | ||
|
|
f6dcc0db1d | ||
|
|
bd49db83e4 | ||
|
|
4140722a6d | ||
|
|
da36f9414d | ||
|
|
1510f71ca6 | ||
|
|
cdb27ef712 | ||
|
|
790319ed98 | ||
|
|
1b0fb2b316 | ||
|
|
02371f2221 | ||
|
|
2b672f86be | ||
|
|
36176bff33 | ||
|
|
174b0c26b8 | ||
|
|
26c60e8e79 | ||
|
|
d94759d868 | ||
|
|
bd7e45ca3c | ||
|
|
52a863c62a | ||
|
|
fe55b90ee3 | ||
|
|
df224cc7f3 | ||
|
|
2a59329350 | ||
|
|
abdf0e7261 | ||
|
|
b9c2a1cce3 | ||
|
|
aa86fca08f | ||
|
|
cf9ec9facf | ||
|
|
f6084ef10c | ||
|
|
740b73beb7 | ||
|
|
5c45802391 | ||
|
|
429aa603f5 | ||
|
|
80ea394934 | ||
|
|
bce4437c79 | ||
|
|
b6ad1a289b | ||
|
|
2a22d05f37 | ||
|
|
d787843fd2 | ||
|
|
ded58f687d | ||
|
|
1f1f34b6ce | ||
|
|
ffadf90f4f | ||
|
|
67807efacf | ||
|
|
980f5afa54 | ||
|
|
b2f68760b2 | ||
|
|
faf86711a5 | ||
|
|
4a78b9d28f | ||
|
|
1b0a7f5062 | ||
|
|
49982043e0 | ||
|
|
378cf7057e | ||
|
|
abdc0f018e | ||
|
|
c65f61b92e | ||
|
|
c12805c8ce | ||
|
|
67f9a6db78 | ||
|
|
bb6336ce2a | ||
|
|
af7a4a6acf | ||
|
|
21d18aa565 | ||
|
|
c96875ba5d | ||
|
|
6ebbfb8e59 | ||
|
|
1e6e28cd57 | ||
|
|
defed72862 | ||
|
|
71503b34b5 | ||
|
|
a00849fb6f | ||
|
|
14b63c0883 | ||
|
|
59d556733e | ||
|
|
a99a175683 | ||
|
|
26fedcfb60 | ||
|
|
dde8024506 | ||
|
|
25f7c29380 | ||
|
|
2f347e83e8 | ||
|
|
080a74884d | ||
|
|
2dbeb64c38 | ||
|
|
bb508c0718 | ||
|
|
9a450b0d63 | ||
|
|
c1de0e60d2 | ||
|
|
dc7c03661d | ||
|
|
952eee6d32 | ||
|
|
472a0f30b9 | ||
|
|
73533c58a8 | ||
|
|
65ef018719 | ||
|
|
f0ca349539 | ||
|
|
500b287721 | ||
|
|
21f3ae45d3 | ||
|
|
d496564f0d | ||
|
|
6fdd6293ce | ||
|
|
3bca495521 | ||
|
|
0fb580f1a5 | ||
|
|
a7cd47e0b1 | ||
|
|
30aecedfae | ||
|
|
e72799efe5 | ||
|
|
ee8c0ae27b | ||
|
|
5b4a4341ad | ||
|
|
56823c1105 | ||
|
|
1f4ada604a | ||
|
|
3a4ab80892 | ||
|
|
bba9c2ba7b | ||
|
|
c4acd5d208 | ||
|
|
381440db4c | ||
|
|
00c8be1f7e | ||
|
|
d665122aa2 | ||
|
|
bb40df5fa3 | ||
|
|
e3c9f70dff | ||
|
|
b351033cec | ||
|
|
18f69bc73d | ||
|
|
39fe7b79d2 | ||
|
|
85769d797b | ||
|
|
9a80f18e1c | ||
|
|
aec8305e52 | ||
|
|
a672174a9b | ||
|
|
6f490b4491 | ||
|
|
5917d059e4 | ||
|
|
40602c7626 | ||
|
|
7d5ee2afa8 | ||
|
|
08b6f8fa11 | ||
|
|
5f9699aa3b | ||
|
|
70607aaaf4 | ||
|
|
1d96d39af7 | ||
|
|
5557772957 | ||
|
|
5c7db6cd23 | ||
|
|
c72b64d74c | ||
|
|
20474e0b3c | ||
|
|
867085600c | ||
|
|
74290ec609 | ||
|
|
5ee555e60c | ||
|
|
a36c28d48f | ||
|
|
0877f2c042 | ||
|
|
2baf5243ea | ||
|
|
b7e71f5812 | ||
|
|
2ed1076fab | ||
|
|
0b20aa751f | ||
|
|
05a4ece8d1 | ||
|
|
25b37c6266 | ||
|
|
b668cff0ac | ||
|
|
4d6c742ae9 | ||
|
|
933f663d22 | ||
|
|
0c55f278a4 | ||
|
|
3f567ee82e | ||
|
|
8dc912c11d | ||
|
|
f1b4e2a17d | ||
|
|
630cfdeab3 | ||
|
|
7029409792 | ||
|
|
d0727b5a85 | ||
|
|
9f52ad5e0a | ||
|
|
501ae643f7 | ||
|
|
400074170e | ||
|
|
17103ed066 | ||
|
|
b6b29309c9 | ||
|
|
a04538710f | ||
|
|
01f6f5c137 | ||
|
|
b1a37cbd8c | ||
|
|
8c59e1280b | ||
|
|
00339127aa | ||
|
|
5935b40b60 | ||
|
|
6cfd2dea96 | ||
|
|
7d3a39c693 | ||
|
|
6e7a4ea475 | ||
|
|
3479dbc3f0 | ||
|
|
9309aea6d9 | ||
|
|
f72551fa9a | ||
|
|
e3b237b75f | ||
|
|
b1ddf18f73 | ||
|
|
13f522abb8 | ||
|
|
3c3d956bf3 | ||
|
|
8160547c11 | ||
|
|
ef71d36dee | ||
|
|
b0d8434455 | ||
|
|
9be0d58461 | ||
|
|
1addcc8211 | ||
|
|
38c75dc8c5 | ||
|
|
89c3ea8311 | ||
|
|
18ff799fb1 | ||
|
|
67b6aaed99 | ||
|
|
08bb463560 | ||
|
|
97767dcabb | ||
|
|
fb18940a5c | ||
|
|
b823f5fa00 | ||
|
|
d64fb081a0 | ||
|
|
09118b1ddf | ||
|
|
de20590fd5 | ||
|
|
1050ffdb24 | ||
|
|
2e49c7f697 | ||
|
|
708cdcc24c | ||
|
|
c89eafd568 | ||
|
|
10de241d53 | ||
|
|
e58952035f | ||
|
|
50a8c7508a | ||
|
|
2b243a6934 | ||
|
|
ece93cb4d7 | ||
|
|
5b3ca0ed32 | ||
|
|
7474f1221a | ||
|
|
d023a943c1 | ||
|
|
4e80af5c53 | ||
|
|
eee785377f | ||
|
|
915906e6ed | ||
|
|
358c8b577e | ||
|
|
5c450a01a4 | ||
|
|
36264c6c6e | ||
|
|
fca946bf15 | ||
|
|
452ceef285 | ||
|
|
7fafee804d | ||
|
|
3a48479435 | ||
|
|
cab8555ab5 | ||
|
|
e3b7cbcc2a | ||
|
|
ed15614288 | ||
|
|
acb6d1b335 | ||
|
|
fe804796ab | ||
|
|
4725fe36d1 | ||
|
|
5c73beff4b | ||
|
|
1f7000c2c9 | ||
|
|
f09baa1318 | ||
|
|
7eaa03e43c | ||
|
|
26099303fa | ||
|
|
6417aee780 | ||
|
|
f9deaba4c5 | ||
|
|
ddd6a3b279 | ||
|
|
9359950666 | ||
|
|
d31b2a1b65 | ||
|
|
b89b4e0af4 | ||
|
|
cbcde027b3 | ||
|
|
d306e6bd22 | ||
|
|
9ec877999e | ||
|
|
f4189bf409 | ||
|
|
0ed5062683 | ||
|
|
7ef666dc91 | ||
|
|
1ac825919a | ||
|
|
a7bf30954d | ||
|
|
613cfdd903 | ||
|
|
28802c8279 | ||
|
|
6d7b3bd5f0 | ||
|
|
b97d8e9403 | ||
|
|
b4838d364e | ||
|
|
05ac5c63e1 | ||
|
|
874bf9e7c0 | ||
|
|
c9497ef39e | ||
|
|
496830d01d | ||
|
|
ccebcdd4c7 | ||
|
|
c900fe8461 | ||
|
|
a0158db37e | ||
|
|
b8c26b01ad | ||
|
|
3a44bef0d9 | ||
|
|
57a4ee781b | ||
|
|
e12f475850 | ||
|
|
f822a23daa | ||
|
|
6901b8be35 | ||
|
|
83fb2cd1d0 | ||
|
|
c98664d584 | ||
|
|
d098be8b03 | ||
|
|
3f6689d032 | ||
|
|
b4206fc203 | ||
|
|
cfa4a0c07f | ||
|
|
357b220ace | ||
|
|
47968304c9 | ||
|
|
2024d5e116 | ||
|
|
5ae2a99c14 | ||
|
|
7fd002d2c9 | ||
|
|
b7b7038244 | ||
|
|
b5519c4875 | ||
|
|
44feab9eb2 | ||
|
|
96c45c33e5 | ||
|
|
36efbcb812 | ||
|
|
03f44b4e9c | ||
|
|
19860e9f09 | ||
|
|
0701cb3970 | ||
|
|
7d6000e3b6 | ||
|
|
ef973ac56a | ||
|
|
91a1033c52 | ||
|
|
4197db6af9 | ||
|
|
210ab065c2 | ||
|
|
9cd10eca58 | ||
|
|
ba676be46d | ||
|
|
665a2e1866 | ||
|
|
94469cae3d | ||
|
|
a0dd2ccad6 | ||
|
|
b2cf837de7 | ||
|
|
80bcf60b5b | ||
|
|
7ad0ab566a | ||
|
|
2b16e86c7b | ||
|
|
f2ea02ae0b | ||
|
|
f65cd39040 | ||
|
|
5ca0d2a399 | ||
|
|
d1528a095b | ||
|
|
749173a463 | ||
|
|
6fbd90a6b3 | ||
|
|
f39d272e6a | ||
|
|
bb3854f512 | ||
|
|
e40daecfb8 | ||
|
|
3716ab9cb5 | ||
|
|
0cc6d6337a | ||
|
|
ce711a36ba | ||
|
|
451af7bea9 | ||
|
|
63200592bf | ||
|
|
d165dfbeb5 | ||
|
|
eed3d84517 | ||
|
|
ba7d890966 | ||
|
|
5140fc63d9 | ||
|
|
78509c07e0 | ||
|
|
5084141215 | ||
|
|
3f2ac83474 | ||
|
|
58a0468728 | ||
|
|
8e13aa7513 | ||
|
|
48e2d91fc8 | ||
|
|
a7f119217f | ||
|
|
865f2261fe | ||
|
|
dfedb23efd | ||
|
|
c01e1c3e4b | ||
|
|
ad8dac5fb0 | ||
|
|
84e81b6218 | ||
|
|
86efe631fe | ||
|
|
f5f1dc483b | ||
|
|
8aa4328c6c | ||
|
|
a01a8c4b19 | ||
|
|
4b2387b621 | ||
|
|
74d16d8ef9 | ||
|
|
b1ea8f9fa7 | ||
|
|
c666fdeaff | ||
|
|
7068782975 | ||
|
|
c4cebbebe7 | ||
|
|
53d43d9fa9 | ||
|
|
11d59beeed | ||
|
|
ef71e297f4 | ||
|
|
1e4d1d1973 | ||
|
|
893d99854b | ||
|
|
db93980cd5 | ||
|
|
34fac30b2b | ||
|
|
2fa0bcb765 | ||
|
|
78fd09aa91 | ||
|
|
a54516b4f5 | ||
|
|
f193d6f376 | ||
|
|
8a82c294a1 | ||
|
|
9392cf4bf0 | ||
|
|
ec4deb9099 | ||
|
|
cf0548aab9 | ||
|
|
064801380b | ||
|
|
9dc2a7424a | ||
|
|
4c8a56a5b9 | ||
|
|
9aad263996 | ||
|
|
ce1ab7c20d | ||
|
|
c9217990cd | ||
|
|
90cbf3b7a6 | ||
|
|
c4f1b22ddf | ||
|
|
fb612ea6ab | ||
|
|
bce44b6f6d | ||
|
|
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 | ||
|
|
b7fd87b09c | ||
|
|
ab82a1656d | ||
|
|
71387e94d8 | ||
|
|
503379079b | ||
|
|
1ae767087f | ||
|
|
cfd2b7b7aa | ||
|
|
2c42b4c585 | ||
|
|
d3a9ff539e | ||
|
|
58f01bd642 | ||
|
|
38806740e1 | ||
|
|
df583e73c2 | ||
|
|
e787d33e5a | ||
|
|
91db665428 | ||
|
|
94d155cff2 | ||
|
|
ad79075fd7 | ||
|
|
7baefe2f44 | ||
|
|
141a4c29bb | ||
|
|
b2992da370 | ||
|
|
fdee254020 | ||
|
|
c51489ac74 | ||
|
|
3cd394ec10 | ||
|
|
8374fea776 | ||
|
|
733ca891de | ||
|
|
490d121db3 | ||
|
|
45c5efffbd | ||
|
|
a24c929acf | ||
|
|
86a39f10d1 | ||
|
|
4658afdc20 | ||
|
|
ae6c2afb30 | ||
|
|
a3844a3535 | ||
|
|
b710075544 | ||
|
|
c4c9786050 | ||
|
|
b4cc81139a | ||
|
|
fb20eb9162 | ||
|
|
263987d2c9 | ||
|
|
0b30a35383 | ||
|
|
47df1fc602 | ||
|
|
d8375454b9 | ||
|
|
ad535501c4 | ||
|
|
159f5cbd00 | ||
|
|
2bc74d5378 | ||
|
|
eb513f563e | ||
|
|
09dc5e9846 | ||
|
|
cf35a87d85 | ||
|
|
9f25f619a8 | ||
|
|
7e989c730c | ||
|
|
0926e86956 | ||
|
|
75967730fd | ||
|
|
a3be3e354f | ||
|
|
58c52196f1 | ||
|
|
b7b49a60cf | ||
|
|
fa195483d6 | ||
|
|
2341f6ea3b | ||
|
|
ffe0f0730d | ||
|
|
23b512910e | ||
|
|
b1c624b104 | ||
|
|
fe35be6682 | ||
|
|
2d3eb29bd5 | ||
|
|
26f0ff62df | ||
|
|
5e145846bd | ||
|
|
1ae5f99bf0 | ||
|
|
984119c7ee | ||
|
|
f8f5eac109 | ||
|
|
4111d5fa48 | ||
|
|
2eca9056b9 | ||
|
|
60e96572ff | ||
|
|
52193933b2 | ||
|
|
7bcabdda38 | ||
|
|
d993941c4d | ||
|
|
b447bff9a6 | ||
|
|
73cb5ffba4 | ||
|
|
7d694229c1 | ||
|
|
cdb6c9a1a4 | ||
|
|
cc1d2b423f | ||
|
|
508e031143 | ||
|
|
5a093a9a04 | ||
|
|
074d647d19 | ||
|
|
6cb98f99c5 | ||
|
|
7d28681b23 | ||
|
|
859a8e933c | ||
|
|
a476d5986d | ||
|
|
31812bc2d9 | ||
|
|
30ba69eca7 | ||
|
|
cf1bc1c252 | ||
|
|
ee109ba67d | ||
|
|
9c6211e8e0 | ||
|
|
0729e4ab09 | ||
|
|
5cbe728631 | ||
|
|
920f4df213 | ||
|
|
c48eacd9af | ||
|
|
30e6deeeaa | ||
|
|
5bc76a3160 | ||
|
|
114925ebce | ||
|
|
5a80a0cc06 | ||
|
|
aebefac7e6 | ||
|
|
b2d0ee41f2 | ||
|
|
9c20250b0a | ||
|
|
b196836fca | ||
|
|
d9fbcc615a | ||
|
|
fb247fb33f | ||
|
|
61f4dbd896 | ||
|
|
2c86571818 | ||
|
|
1b2ec67726 | ||
|
|
845af854bd | ||
|
|
15b6a66d98 | ||
|
|
c95ba0764b | ||
|
|
42c0648ba7 | ||
|
|
0a6e55dcb7 | ||
|
|
99b77decff | ||
|
|
9e2ca4e586 | ||
|
|
2e8acfdeef | ||
|
|
630096e06d | ||
|
|
d92d892dc7 | ||
|
|
a8f41841bd | ||
|
|
76954b5a0a | ||
|
|
c57b184a09 | ||
|
|
20ca4e0739 | ||
|
|
a972ed5e2e | ||
|
|
2b15bc6ebb | ||
|
|
f7a482659c | ||
|
|
99527453a7 | ||
|
|
3408b4637c | ||
|
|
3f2899e97e | ||
|
|
562496cfaa | ||
|
|
8283f19d6b | ||
|
|
242909b542 | ||
|
|
a7b83ad5e0 | ||
|
|
ed66019d9a | ||
|
|
bc0009be6c | ||
|
|
c88f47eed4 | ||
|
|
59de048ced | ||
|
|
7987dfb819 | ||
|
|
1b101106e7 | ||
|
|
7b75955aec | ||
|
|
8f5467e6ca | ||
|
|
28764f92b9 | ||
|
|
777dfe4c62 | ||
|
|
0878a704d9 | ||
|
|
f880897542 | ||
|
|
b37472a954 | ||
|
|
68735a45dd | ||
|
|
e26deb9092 | ||
|
|
43d6ea82cd | ||
|
|
db1aa495ac | ||
|
|
ee62d9ae8d | ||
|
|
4001124cfa | ||
|
|
43a4d0d1d7 | ||
|
|
632b432b7c | ||
|
|
e778c7a59d | ||
|
|
d71cdecd35 | ||
|
|
4a82541ffd | ||
|
|
f29dff3386 | ||
|
|
718d21f6cb | ||
|
|
440550ded9 | ||
|
|
593fe57ea1 | ||
|
|
e8a320dac9 | ||
|
|
3cb43e5d3e | ||
|
|
f86bdba3c3 | ||
|
|
98c3940297 | ||
|
|
b9e789bbcf | ||
|
|
a108846731 | ||
|
|
0b4ce8d6e7 | ||
|
|
42df61b7dd | ||
|
|
6b46fa4cbc | ||
|
|
c0762eba18 | ||
|
|
036fb848e1 | ||
|
|
7198ae9025 | ||
|
|
d2822b06aa | ||
|
|
17feca28b9 | ||
|
|
898d38cb6a | ||
|
|
95a99a2f0b | ||
|
|
29a1e8ad34 | ||
|
|
19f3a4f266 | ||
|
|
12ddbc308a | ||
|
|
999bc7604e | ||
|
|
3648de3a8d | ||
|
|
051fa0a28f | ||
|
|
72e667e825 | ||
|
|
5ed59b41b5 | ||
|
|
c7c0d1632e | ||
|
|
2dc73acd20 | ||
|
|
ed71668c48 | ||
|
|
801e154d15 | ||
|
|
a89b07394f | ||
|
|
982f9b7c58 | ||
|
|
789b9207b5 | ||
|
|
133dbb7471 | ||
|
|
5d3ec493cd | ||
|
|
6d7f234497 | ||
|
|
29a50bb640 | ||
|
|
843fddabde | ||
|
|
109ce0dd1f | ||
|
|
42508a82a0 | ||
|
|
d860d39f5f | ||
|
|
15396c611a | ||
|
|
41c4b12ae1 | ||
|
|
e51c30462f | ||
|
|
9b5df99a61 | ||
|
|
3535156ea5 | ||
|
|
577145096d | ||
|
|
89059510fd | ||
|
|
aabc14c639 | ||
|
|
c28872544c | ||
|
|
7b8a4e4d72 | ||
|
|
5dcdf670be | ||
|
|
9721890a3c | ||
|
|
1b9c4cfc23 | ||
|
|
98a552e9af | ||
|
|
e1e265a101 | ||
|
|
b60a854de0 | ||
|
|
d1bddeccc8 | ||
|
|
0a106e64d8 | ||
|
|
91d6181aec | ||
|
|
255c0a3359 | ||
|
|
3a5ef999f0 | ||
|
|
983aa845d6 | ||
|
|
d1779726e6 | ||
|
|
8e23062d0e | ||
|
|
7efbfffd99 | ||
|
|
ff4b2d2ecc | ||
|
|
e079be0ad7 | ||
|
|
a8a54aa443 | ||
|
|
88cbcf6baf | ||
|
|
8d6d26c9d2 | ||
|
|
a490df0f7e | ||
|
|
a46041c958 | ||
|
|
0a6a78bc58 | ||
|
|
c9e850515e | ||
|
|
0ff8da2cf0 | ||
|
|
c0ef3ccbea | ||
|
|
1ab628dee8 | ||
|
|
b24df24b10 | ||
|
|
341678d979 | ||
|
|
49d10273a6 | ||
|
|
5b05c018d5 | ||
|
|
d18d8c0ba4 | ||
|
|
84a8fb0074 | ||
|
|
a40fb7f4bd | ||
|
|
84eb3a3508 | ||
|
|
73a5d76503 | ||
|
|
50c35ed519 | ||
|
|
a7b7e3efea | ||
|
|
88e892196f | ||
|
|
7f08da96bb | ||
|
|
193f24768e | ||
|
|
a8bca3de98 | ||
|
|
9692a802d0 | ||
|
|
28a8b2e685 | ||
|
|
3c9121b4af | ||
|
|
dec1035258 | ||
|
|
9d81c86c1b | ||
|
|
eeb4f4681a | ||
|
|
676af0210b | ||
|
|
77c6a2890b | ||
|
|
c39e748749 | ||
|
|
36e5a6ac8d | ||
|
|
9bdcaa5eaa | ||
|
|
5511004db8 | ||
|
|
0e46cdb514 | ||
|
|
b028899949 | ||
|
|
55285427f1 | ||
|
|
763a6cb31a | ||
|
|
24cb1aa84f | ||
|
|
886aa4938d | ||
|
|
8871651549 | ||
|
|
2ae8ef87d9 | ||
|
|
de4fbe05f7 | ||
|
|
b8abed37c2 | ||
|
|
255e26435c | ||
|
|
9e0550619b | ||
|
|
5c171fd0f0 | ||
|
|
3dd3b710b7 | ||
|
|
bce3bdba7e | ||
|
|
360f077da3 | ||
|
|
75c5f662dc | ||
|
|
3c0485cfa9 | ||
|
|
d5ba405de0 | ||
|
|
71b8bca86d | ||
|
|
c53b9eabd6 | ||
|
|
6d6434b4d4 | ||
|
|
a447e88b86 | ||
|
|
e2d2e00913 | ||
|
|
cbfea37b3a | ||
|
|
d6de647974 | ||
|
|
b784bd6b8d | ||
|
|
00df6da366 | ||
|
|
dad36c73e5 | ||
|
|
936a1d60a0 | ||
|
|
e0248c2d8e | ||
|
|
b12731e3d5 | ||
|
|
9636aca47c | ||
|
|
4138183352 | ||
|
|
c3871d3bca | ||
|
|
dd8b0783a9 | ||
|
|
9a50aa4c7c | ||
|
|
c40185030f | ||
|
|
7cba28019c | ||
|
|
926f8a957e | ||
|
|
59aeaf24e4 | ||
|
|
64eaa157e5 | ||
|
|
9a5d9f3867 | ||
|
|
e368e4669b | ||
|
|
c6ce814e1c | ||
|
|
dd5e162c10 | ||
|
|
7af890d897 | ||
|
|
0faeeea25f | ||
|
|
de9b3fd6ec | ||
|
|
22e5c8746c | ||
|
|
0091245734 | ||
|
|
448c231cfa | ||
|
|
b0d1f692a3 | ||
|
|
a5ff890ea1 | ||
|
|
df4739cbf4 | ||
|
|
9559109aa8 | ||
|
|
d848c1a499 | ||
|
|
48ffc40abb | ||
|
|
82b5daa809 | ||
|
|
b320276926 | ||
|
|
6ccb8d612f | ||
|
|
23460e0137 | ||
|
|
7723de7284 | ||
|
|
138f94594f | ||
|
|
81c152ddcb | ||
|
|
04665fea36 | ||
|
|
803eef3825 | ||
|
|
e2a05761a6 | ||
|
|
b1968caa0f | ||
|
|
6474ef98f5 | ||
|
|
8763d63a93 | ||
|
|
201ecebda9 | ||
|
|
1c9ea0a710 | ||
|
|
30feb42ed8 | ||
|
|
cfe2eac351 | ||
|
|
725979afb0 | ||
|
|
19262bceac | ||
|
|
6254585ae2 | ||
|
|
5ad1e45c65 | ||
|
|
9fe95bbddc | ||
|
|
aecc54401d | ||
|
|
4332732cc4 | ||
|
|
15b6b673d7 | ||
|
|
cb8b5d74d7 | ||
|
|
535dc7a038 | ||
|
|
c6f7b142ee | ||
|
|
4853b26cd3 | ||
|
|
c99ee56f24 | ||
|
|
f6a22db188 | ||
|
|
c26b31f548 | ||
|
|
96654c599b | ||
|
|
57be386ac7 | ||
|
|
09f782bfbd | ||
|
|
cb673e34c1 | ||
|
|
62af7ab70c | ||
|
|
71f2f656e8 | ||
|
|
fddf096de6 | ||
|
|
9b8cf69148 | ||
|
|
628c80048b | ||
|
|
9daa264af4 | ||
|
|
8f8a8e7340 | ||
|
|
389fbb2371 | ||
|
|
1ee73fa1a7 | ||
|
|
adbbff368f | ||
|
|
ee9d30bd56 | ||
|
|
5a822809a9 | ||
|
|
4e587e0429 | ||
|
|
9a619186fd | ||
|
|
eab6f71a4c | ||
|
|
f68bf2d6b3 | ||
|
|
2afcaebb78 | ||
|
|
458ea6a377 | ||
|
|
0e2997d309 | ||
|
|
420f4b9d5d | ||
|
|
bcfa97219f | ||
|
|
4c66010afe | ||
|
|
05f25a88c6 | ||
|
|
2c6c08fbb5 | ||
|
|
8af60b56b6 | ||
|
|
c0516772a7 | ||
|
|
77de70762c | ||
|
|
7164100cb1 | ||
|
|
9292a62015 | ||
|
|
5280e68da9 | ||
|
|
0f6b0bf9fe | ||
|
|
510ed95590 | ||
|
|
9862593f4a | ||
|
|
d595b62f13 | ||
|
|
12abbb79b1 | ||
|
|
ecaa3b9aab | ||
|
|
ded175f2d2 | ||
|
|
128facec21 | ||
|
|
0bde86ebfd | ||
|
|
28625029cd | ||
|
|
1816bd721c | ||
|
|
68ad627159 | ||
|
|
878a5dd36c | ||
|
|
7c144b8277 | ||
|
|
bca8c3865b | ||
|
|
58102acd35 | ||
|
|
5e577843f7 | ||
|
|
e1d549cead | ||
|
|
323b8f2fb9 | ||
|
|
3dcbcf42ed | ||
|
|
825078abc6 | ||
|
|
6be44966ad | ||
|
|
66da138556 | ||
|
|
e5dd4b856e | ||
|
|
5caa9c5687 | ||
|
|
c8c0ffeb0d | ||
|
|
bfceb58d6b | ||
|
|
2e4c4cf5f7 | ||
|
|
23966c9b00 | ||
|
|
ef73d2243d | ||
|
|
c95feebd39 | ||
|
|
d6601fed83 | ||
|
|
962e379642 | ||
|
|
cbc61f5a2d | ||
|
|
2eaac6acc2 | ||
|
|
03b458765c | ||
|
|
c8b4bc6361 | ||
|
|
d9b5725ff1 | ||
|
|
0a0f60192b | ||
|
|
424d939c15 | ||
|
|
87f13ff8ed | ||
|
|
1e24df626a | ||
|
|
0312786721 | ||
|
|
1f8a5b256e | ||
|
|
426391f01c | ||
|
|
c296bff47f | ||
|
|
6b649cf4ca | ||
|
|
5103240a76 | ||
|
|
c2418b10f6 | ||
|
|
d705c23472 | ||
|
|
de45d008c7 | ||
|
|
c267332027 | ||
|
|
4829e85faf | ||
|
|
2acb9ca7e5 | ||
|
|
b260554a2a | ||
|
|
41a4055cd9 | ||
|
|
c6e9ba9bf9 | ||
|
|
5059333b38 | ||
|
|
b4015030cf | ||
|
|
7f5cf8f018 | ||
|
|
2c9ccd9c78 | ||
|
|
cebf218db4 | ||
|
|
530b44a0e6 | ||
|
|
b3dc5a7054 | ||
|
|
2567ccb44c | ||
|
|
e67eac92fd | ||
|
|
6e84fd97f1 | ||
|
|
9a458bf3dc | ||
|
|
283a46e1e2 | ||
|
|
6ff2859c39 | ||
|
|
e8df4952fc | ||
|
|
b19e1e8a30 | ||
|
|
a3cf6ac40d | ||
|
|
ab450c37c4 | ||
|
|
c837fefbdd | ||
|
|
46b120ee41 | ||
|
|
cae8ca7ef3 | ||
|
|
904665da7f |
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -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.
|
||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -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.
|
||||
8
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain dependencies for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
132
.github/workflows/build-linux.yml
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
# build-linux.yml
|
||||
# Reusable workflow that builds the Linux and MacOS (x64 and arm64) versions of Libation.
|
||||
---
|
||||
name: build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_override:
|
||||
type: string
|
||||
description: 'Version number override'
|
||||
required: false
|
||||
run_unit_tests:
|
||||
type: boolean
|
||||
description: 'Skip running unit tests'
|
||||
required: false
|
||||
default: true
|
||||
runs_on:
|
||||
type: string
|
||||
description: 'The GitHub hosted runner to use'
|
||||
required: true
|
||||
OS:
|
||||
type: string
|
||||
description: >
|
||||
The operating system targeted by the build.
|
||||
|
||||
There must be a corresponding Bundle_$OS.sh script file in ./Scripts
|
||||
required: true
|
||||
architecture:
|
||||
type: string
|
||||
description: 'CPU architecture targeted by the build.'
|
||||
required: true
|
||||
|
||||
env:
|
||||
DOTNET_CONFIGURATION: 'Release'
|
||||
DOTNET_VERSION: '7.0.x'
|
||||
RELEASE_NAME: 'chardonnay'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: '${{ inputs.OS }}-${{ inputs.architecture }}'
|
||||
runs-on: ${{ inputs.runs_on }}
|
||||
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: |
|
||||
if [[ "${{ inputs.OS }}" == "MacOS" ]]
|
||||
then
|
||||
display_os="macOS"
|
||||
RUNTIME_ID="osx-${{ inputs.architecture }}"
|
||||
else
|
||||
display_os="Linux"
|
||||
RUNTIME_ID="linux-${{ inputs.architecture }}"
|
||||
fi
|
||||
|
||||
OUTPUT="bin/Publish/${display_os}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}"
|
||||
|
||||
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
|
||||
echo "Runtime Identifier: $RUNTIME_ID"
|
||||
echo "Output Directory: $OUTPUT"
|
||||
|
||||
dotnet publish \
|
||||
LibationAvalonia/LibationAvalonia.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||
dotnet publish \
|
||||
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
|
||||
dotnet publish \
|
||||
LibationCli/LibationCli.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||
dotnet publish \
|
||||
HangoverAvalonia/HangoverAvalonia.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||
|
||||
- name: Build bundle
|
||||
id: bundle
|
||||
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}
|
||||
run: |
|
||||
BUNDLE_DIR=$(pwd)
|
||||
echo "Bundle dir: ${BUNDLE_DIR}"
|
||||
cd ..
|
||||
SCRIPT=../../../Scripts/Bundle_${{ inputs.OS }}.sh
|
||||
chmod +rx ${SCRIPT}
|
||||
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}"
|
||||
artifact=$(ls ./bundle)
|
||||
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Publish bundle
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ steps.bundle.outputs.artifact }}
|
||||
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
114
.github/workflows/build-windows.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
# 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:
|
||||
name: '${{ matrix.os }}-${{ matrix.release_name }}'
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [Windows]
|
||||
ui: [Avalonia]
|
||||
release_name: [chardonnay]
|
||||
include:
|
||||
- os: Windows
|
||||
ui: WinForms
|
||||
release_name: classic
|
||||
prefix: Classic-
|
||||
steps:
|
||||
- uses: actions/checkout@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/${{ 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
|
||||
retention-days: 7
|
||||
50
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# build.yml
|
||||
# Reusable workflow that builds Libation for all platforms.
|
||||
---
|
||||
name: build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_override:
|
||||
type: string
|
||||
description: 'Version number override'
|
||||
required: false
|
||||
run_unit_tests:
|
||||
type: boolean
|
||||
description: 'Skip running unit tests'
|
||||
required: false
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
|
||||
windows:
|
||||
uses: ./.github/workflows/build-windows.yml
|
||||
with:
|
||||
version_override: ${{ inputs.version_override }}
|
||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||
|
||||
linux:
|
||||
strategy:
|
||||
matrix:
|
||||
OS: [Redhat, Debian]
|
||||
architecture: [x64, arm64]
|
||||
uses: ./.github/workflows/build-linux.yml
|
||||
with:
|
||||
version_override: ${{ inputs.version_override }}
|
||||
runs_on: ubuntu-latest
|
||||
OS: ${{ matrix.OS }}
|
||||
architecture: ${{ matrix.architecture }}
|
||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||
|
||||
macos:
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [x64, arm64]
|
||||
uses: ./.github/workflows/build-linux.yml
|
||||
with:
|
||||
version_override: ${{ inputs.version_override }}
|
||||
runs_on: macos-latest
|
||||
OS: MacOS
|
||||
architecture: ${{ matrix.architecture }}
|
||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||
46
.github/workflows/docker.yml
vendored
Normal file
@@ -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 }}
|
||||
61
.github/workflows/release.yml
vendored
Normal file
@@ -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
|
||||
14
.github/workflows/validate.yml
vendored
Normal file
@@ -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
|
||||
3
.gitignore
vendored
@@ -184,7 +184,7 @@ publish/
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
#*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
@@ -370,3 +370,4 @@ FodyWeavers.xsd
|
||||
|
||||
/__TODO.txt
|
||||
/DataLayer/LibationContext.db
|
||||
*/bin-Avalonia
|
||||
10
.releaseindex.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
||||
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
|
||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
|
||||
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.rpm",
|
||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz",
|
||||
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb",
|
||||
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.rpm",
|
||||
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz"
|
||||
}
|
||||
21
.vscode/launch.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
42
.vscode/tasks.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean" Version="0.1.10" />
|
||||
<PackageReference Include="Dinah.Core" Version="2.0.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,91 +0,0 @@
|
||||
using System;
|
||||
using AAXClean;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
||||
{
|
||||
protected OutputFormat OutputFormat { get; }
|
||||
|
||||
protected AaxFile AaxFile;
|
||||
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
{
|
||||
OutputFormat = outputFormat;
|
||||
}
|
||||
|
||||
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
||||
public override void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
base.SetCoverArt(coverArt);
|
||||
if (coverArt is not null)
|
||||
AaxFile?.AppleTags.SetCoverArt(coverArt);
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
{
|
||||
AaxFile = new AaxFile(InputFileStream);
|
||||
|
||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
||||
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
protected DownloadProgress Step_DownloadAudiobook_Start()
|
||||
{
|
||||
var zeroProgress = new DownloadProgress
|
||||
{
|
||||
BytesReceived = 0,
|
||||
ProgressPercentage = 0,
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
};
|
||||
|
||||
OnDecryptProgressUpdate(zeroProgress);
|
||||
|
||||
AaxFile.SetDecryptionKey(DownloadLicense.AudibleKey, DownloadLicense.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.TotalSeconds / duration.TotalSeconds;
|
||||
|
||||
OnDecryptProgressUpdate(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = 100 * progressPercent,
|
||||
BytesReceived = (long)(InputFileStream.Length * progressPercent),
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
});
|
||||
}
|
||||
|
||||
public override void Cancel()
|
||||
{
|
||||
IsCanceled = true;
|
||||
AaxFile?.Cancel();
|
||||
AaxFile?.Dispose();
|
||||
CloseInputFileStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AAXClean;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
|
||||
private Func<string, int, int, NewSplitCallback, string> multipartFileNameCallback { get; }
|
||||
private static string DefaultMultipartFilename(string outputFileName, int partsPosition, int partsTotal, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
var template = Path.ChangeExtension(outputFileName, null) + " - <ch# 0> - <title>" + Path.GetExtension(outputFileName);
|
||||
|
||||
var fileTemplate = new FileTemplate(template) { IllegalCharacterReplacements = " " };
|
||||
fileTemplate.AddParameterReplacement("ch# 0", FileUtility.GetSequenceFormatted(partsPosition, partsTotal));
|
||||
fileTemplate.AddParameterReplacement("title", newSplitCallback?.Chapter?.Title ?? "");
|
||||
|
||||
return fileTemplate.GetFilePath();
|
||||
}
|
||||
|
||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
||||
private List<string> multiPartFilePaths { get; } = new List<string>();
|
||||
|
||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat,
|
||||
Func<string, int, int, NewSplitCallback, string> multipartFileNameCallback = null)
|
||||
: base(outFileName, cacheDirectory, dlLic, outputFormat)
|
||||
{
|
||||
Steps = new StepSequence
|
||||
{
|
||||
Name = "Download and Convert Aaxc To " + OutputFormat,
|
||||
|
||||
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
|
||||
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsMultipleFilesPerChapter,
|
||||
["Step 3: Cleanup"] = Step_Cleanup,
|
||||
};
|
||||
this.multipartFileNameCallback = multipartFileNameCallback ?? DefaultMultipartFilename;
|
||||
}
|
||||
|
||||
/*
|
||||
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
|
||||
|
||||
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
|
||||
If the chapter is shorter than 3 seconds long but still has some audio frames, those frames are combined with the following chapter and not split into a new file.
|
||||
|
||||
I also implemented file naming by chapter title. When 2 or more consecutive chapters are combined, the first of the combined chapter's title is used in the file name. For example, given an audiobook with the following chapters:
|
||||
|
||||
00:00:00 - 00:00:02 | Part 1
|
||||
00:00:02 - 00:35:00 | Chapter 1
|
||||
00:35:02 - 01:02:00 | Chapter 2
|
||||
01:02:00 - 01:02:02 | Part 2
|
||||
01:02:02 - 01:41:00 | Chapter 3
|
||||
01:41:00 - 02:05:00 | Chapter 4
|
||||
|
||||
The book will be split into the following files:
|
||||
|
||||
00:00:00 - 00:35:00 | Book - 01 - Part 1.m4b
|
||||
00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b
|
||||
01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b
|
||||
01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b
|
||||
|
||||
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
|
||||
*/
|
||||
private bool Step_DownloadAudiobookAsMultipleFilesPerChapter()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
|
||||
var chapters = DownloadLicense.ChapterInfo.Chapters.ToList();
|
||||
|
||||
// Ensure split files are at least minChapterLength in duration.
|
||||
var splitChapters = new ChapterInfo();
|
||||
|
||||
var runningTotal = TimeSpan.Zero;
|
||||
string title = "";
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
if (runningTotal == TimeSpan.Zero)
|
||||
title = chapters[i].Title;
|
||||
|
||||
runningTotal += chapters[i].Duration;
|
||||
|
||||
if (runningTotal >= minChapterLength)
|
||||
{
|
||||
splitChapters.AddChapter(title, runningTotal);
|
||||
runningTotal = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
// reset, just in case
|
||||
multiPartFilePaths.Clear();
|
||||
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
if (OutputFormat == OutputFormat.M4b)
|
||||
ConvertToMultiMp4a(splitChapters);
|
||||
else
|
||||
ConvertToMultiMp3(splitChapters);
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
|
||||
var success = !IsCanceled;
|
||||
|
||||
if (success)
|
||||
foreach (var path in multiPartFilePaths)
|
||||
OnFileCreated(path);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private void ConvertToMultiMp4a(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
AaxFile.ConvertToMultiMp4a(splitChapters, newSplitCallback =>
|
||||
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback)
|
||||
);
|
||||
}
|
||||
|
||||
private void ConvertToMultiMp3(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
AaxFile.ConvertToMultiMp3(splitChapters, newSplitCallback =>
|
||||
{
|
||||
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback);
|
||||
newSplitCallback.LameConfig.ID3.Track = chapterCount.ToString();
|
||||
});
|
||||
}
|
||||
|
||||
private void createOutputFileStream(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
var fileName = multipartFileNameCallback(OutputFileName, currentChapter, splitChapters.Count, newSplitCallback);
|
||||
fileName = FileUtility.GetValidFilename(fileName);
|
||||
|
||||
multiPartFilePaths.Add(fileName);
|
||||
|
||||
FileUtility.SaferDelete(fileName);
|
||||
|
||||
newSplitCallback.OutputFile = File.Open(fileName, FileMode.OpenOrCreate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AAXClean;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
|
||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat)
|
||||
: base(outFileName, cacheDirectory, dlLic, outputFormat)
|
||||
{
|
||||
Steps = new StepSequence
|
||||
{
|
||||
Name = "Download and Convert Aaxc To " + OutputFormat,
|
||||
|
||||
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
|
||||
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsSingleFile,
|
||||
["Step 3: Create Cue"] = Step_CreateCue,
|
||||
["Step 4: Cleanup"] = Step_Cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
private bool Step_DownloadAudiobookAsSingleFile()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
|
||||
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
var decryptionResult
|
||||
= OutputFormat == OutputFormat.M4b
|
||||
? AaxFile.ConvertToMp4a(outputFile, DownloadLicense.ChapterInfo)
|
||||
: AaxFile.ConvertToMp3(outputFile);
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
DownloadLicense.ChapterInfo = AaxFile.Chapters;
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
|
||||
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
|
||||
if (success)
|
||||
base.OnFileCreated(OutputFileName);
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Net.Http;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public enum OutputFormat { M4b, Mp3 }
|
||||
|
||||
public abstract class AudiobookDownloadBase
|
||||
{
|
||||
public event EventHandler<string> RetrievedTitle;
|
||||
public event EventHandler<string> RetrievedAuthors;
|
||||
public event EventHandler<string> RetrievedNarrators;
|
||||
public event EventHandler<byte[]> RetrievedCoverArt;
|
||||
public event EventHandler<DownloadProgress> DecryptProgressUpdate;
|
||||
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
||||
public event EventHandler<string> FileCreated;
|
||||
|
||||
protected bool IsCanceled { get; set; }
|
||||
protected string OutputFileName { get; private set; }
|
||||
protected DownloadLicense DownloadLicense { get; }
|
||||
protected NetworkFileStream InputFileStream => (nfsPersister ??= OpenNetworkFileStream()).NetworkFileStream;
|
||||
|
||||
// Don't give the property a 'set'. This should have to be an obvious choice; not accidental
|
||||
protected void SetOutputFileName(string newOutputFileName) => OutputFileName = newOutputFileName;
|
||||
|
||||
protected abstract StepSequence Steps { get; }
|
||||
private NetworkFileStreamPersister nfsPersister;
|
||||
|
||||
private string jsonDownloadState { get; }
|
||||
private string tempFile => Path.ChangeExtension(jsonDownloadState, ".tmp");
|
||||
|
||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, DownloadLicense dlLic)
|
||||
{
|
||||
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
|
||||
|
||||
var outDir = Path.GetDirectoryName(OutputFileName);
|
||||
if (!Directory.Exists(outDir))
|
||||
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(outDir)}");
|
||||
|
||||
if (!Directory.Exists(cacheDirectory))
|
||||
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(cacheDirectory)}");
|
||||
jsonDownloadState = Path.Combine(cacheDirectory, Path.ChangeExtension(OutputFileName, ".json"));
|
||||
|
||||
DownloadLicense = ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
|
||||
|
||||
// delete file after validation is complete
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
}
|
||||
|
||||
public abstract void Cancel();
|
||||
|
||||
public virtual void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
if (coverArt is not null)
|
||||
OnRetrievedCoverArt(coverArt);
|
||||
}
|
||||
|
||||
public bool Run()
|
||||
{
|
||||
var (IsSuccess, Elapsed) = Steps.Run();
|
||||
|
||||
if (!IsSuccess)
|
||||
Serilog.Log.Logger.Error("Conversion failed");
|
||||
|
||||
return IsSuccess;
|
||||
}
|
||||
|
||||
protected void OnRetrievedTitle(string title)
|
||||
=> RetrievedTitle?.Invoke(this, title);
|
||||
protected void OnRetrievedAuthors(string authors)
|
||||
=> RetrievedAuthors?.Invoke(this, authors);
|
||||
protected void OnRetrievedNarrators(string narrators)
|
||||
=> RetrievedNarrators?.Invoke(this, narrators);
|
||||
protected void OnRetrievedCoverArt(byte[] coverArt)
|
||||
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
||||
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
||||
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
||||
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
||||
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
||||
protected void OnFileCreated(string path)
|
||||
=> FileCreated?.Invoke(this, path);
|
||||
|
||||
protected void CloseInputFileStream()
|
||||
{
|
||||
nfsPersister?.NetworkFileStream?.Close();
|
||||
nfsPersister?.Dispose();
|
||||
}
|
||||
|
||||
protected bool Step_CreateCue()
|
||||
{
|
||||
// not a critical step. its failure should not prevent future steps from running
|
||||
try
|
||||
{
|
||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
||||
path = FileUtility.GetValidFilename(path);
|
||||
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadLicense.ChapterInfo));
|
||||
OnFileCreated(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCue)}. FAILED");
|
||||
}
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
protected bool Step_Cleanup()
|
||||
{
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
FileUtility.SaferDelete(tempFile);
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
private NetworkFileStreamPersister OpenNetworkFileStream()
|
||||
{
|
||||
if (!File.Exists(jsonDownloadState))
|
||||
return NewNetworkFilePersister();
|
||||
|
||||
try
|
||||
{
|
||||
var nfsp = new NetworkFileStreamPersister(jsonDownloadState);
|
||||
// If More than ~1 hour has elapsed since getting the download url, it will expire.
|
||||
// The new url will be to the same file.
|
||||
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadLicense.DownloadUrl));
|
||||
return nfsp;
|
||||
}
|
||||
catch
|
||||
{
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
FileUtility.SaferDelete(tempFile);
|
||||
return NewNetworkFilePersister();
|
||||
}
|
||||
}
|
||||
|
||||
private NetworkFileStreamPersister NewNetworkFilePersister()
|
||||
{
|
||||
var headers = new System.Net.WebHeaderCollection
|
||||
{
|
||||
{ "User-Agent", DownloadLicense.UserAgent }
|
||||
};
|
||||
|
||||
var networkFileStream = new NetworkFileStream(tempFile, new Uri(DownloadLicense.DownloadUrl), 0, headers);
|
||||
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using AAXClean;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class DownloadLicense
|
||||
{
|
||||
public string DownloadUrl { get; }
|
||||
public string AudibleKey { get; }
|
||||
public string AudibleIV { get; }
|
||||
public string UserAgent { get; }
|
||||
public ChapterInfo ChapterInfo { get; set; }
|
||||
|
||||
public DownloadLicense(string downloadUrl, string audibleKey, string audibleIV, string userAgent)
|
||||
{
|
||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
||||
|
||||
// no null/empty check. unencrypted files do not have these
|
||||
AudibleKey = audibleKey;
|
||||
AudibleIV = audibleIV;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
/// <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 event EventHandler Updated;
|
||||
|
||||
#region Public Properties
|
||||
|
||||
/// <summary>
|
||||
/// Location to save the downloaded data.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public string SaveFilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Http(s) address of the file to download.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public Uri Uri { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// All cookies set by caller or by the remote server.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public SingleUriCookieContainer CookieContainer { get; }
|
||||
|
||||
/// <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)]
|
||||
public long WritePosition { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The total length of the <see cref="Uri"/> file to download.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public long ContentLength { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Properties
|
||||
private HttpWebRequest HttpRequest { get; set; }
|
||||
private FileStream _writeFile { get; }
|
||||
private FileStream _readFile { get; }
|
||||
private Stream _networkStream { get; set; }
|
||||
private bool hasBegunDownloading { get; set; }
|
||||
public bool IsCancelled { get; private set; }
|
||||
private EventWaitHandle downloadEnded { get; set; }
|
||||
private EventWaitHandle downloadedPiece { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constants
|
||||
|
||||
//Download buffer size
|
||||
private const int DOWNLOAD_BUFF_SZ = 4 * 1024;
|
||||
|
||||
//NetworkFileStream will flush all data in _writeFile to disk after every
|
||||
//DATA_FLUSH_SZ bytes are written to the file stream.
|
||||
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
|
||||
/// <summary>
|
||||
/// A resumable, simultaneous file downloader and reader.
|
||||
/// </summary>
|
||||
/// <param name="saveFilePath">Path to a location on disk to save the downloaded data from <paramref name="uri"/></param>
|
||||
/// <param name="uri">Http(s) address of the file to download.</param>
|
||||
/// <param name="writePosition">The position in <paramref name="uri"/> to begin downloading.</param>
|
||||
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
|
||||
/// <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, WebHeaderCollection requestHeaders = null, SingleUriCookieContainer cookies = null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri));
|
||||
ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
|
||||
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
|
||||
|
||||
SaveFilePath = saveFilePath;
|
||||
Uri = uri;
|
||||
WritePosition = writePosition;
|
||||
RequestHeaders = requestHeaders ?? new WebHeaderCollection();
|
||||
CookieContainer = cookies ?? new SingleUriCookieContainer { Uri = uri };
|
||||
|
||||
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
|
||||
{
|
||||
Position = WritePosition
|
||||
};
|
||||
|
||||
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
SetUriForSameFile(uri);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Downloader
|
||||
|
||||
/// <summary>
|
||||
/// Update the <see cref="JsonFilePersister"/>.
|
||||
/// </summary>
|
||||
private void Update()
|
||||
{
|
||||
RequestHeaders = HttpRequest.Headers;
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/>
|
||||
/// </summary>
|
||||
/// <param name="uriToSameFile">New <see cref="System.Uri"/> host must match existing host.</param>
|
||||
public void SetUriForSameFile(Uri uriToSameFile)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile));
|
||||
|
||||
if (uriToSameFile.Host != Uri.Host)
|
||||
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
|
||||
if (hasBegunDownloading)
|
||||
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
||||
|
||||
Uri = uriToSameFile;
|
||||
HttpRequest = WebRequest.CreateHttp(Uri);
|
||||
|
||||
HttpRequest.CookieContainer = CookieContainer;
|
||||
HttpRequest.Headers = RequestHeaders;
|
||||
//If NetworkFileStream is resuming, Header will already contain a range.
|
||||
HttpRequest.Headers.Remove("Range");
|
||||
HttpRequest.AddRange(WritePosition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread.
|
||||
/// </summary>
|
||||
private void BeginDownloading()
|
||||
{
|
||||
downloadEnded = new EventWaitHandle(false, EventResetMode.ManualReset);
|
||||
|
||||
if (ContentLength != 0 && WritePosition == ContentLength)
|
||||
{
|
||||
hasBegunDownloading = true;
|
||||
downloadEnded.Set();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ContentLength != 0 && WritePosition > ContentLength)
|
||||
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
var response = HttpRequest.GetResponse() as HttpWebResponse;
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
||||
|
||||
if (response.Headers.GetValues("Accept-Ranges").FirstOrDefault(r => r.EqualsInsensitive("bytes")) is null)
|
||||
throw new WebException($"Server at {Uri.Host} does not support Http ranges");
|
||||
|
||||
//Content length is the length of the range request, and it is only equal
|
||||
//to the complete file length if requesting Range: bytes=0-
|
||||
if (WritePosition == 0)
|
||||
ContentLength = response.ContentLength;
|
||||
|
||||
_networkStream = response.GetResponseStream();
|
||||
downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||
|
||||
//Download the file in the background.
|
||||
new Thread(() => DownloadFile())
|
||||
{ IsBackground = true }
|
||||
.Start();
|
||||
|
||||
hasBegunDownloading = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downlod <see cref="Uri"/> to <see cref="SaveFilePath"/>.
|
||||
/// </summary>
|
||||
private void DownloadFile()
|
||||
{
|
||||
var downloadPosition = WritePosition;
|
||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
|
||||
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||
do
|
||||
{
|
||||
var bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
|
||||
_writeFile.Write(buff, 0, bytesRead);
|
||||
|
||||
downloadPosition += bytesRead;
|
||||
|
||||
if (downloadPosition > nextFlush)
|
||||
{
|
||||
_writeFile.Flush();
|
||||
WritePosition = downloadPosition;
|
||||
Update();
|
||||
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
downloadedPiece.Set();
|
||||
}
|
||||
|
||||
} while (downloadPosition < ContentLength && !IsCancelled);
|
||||
|
||||
_writeFile.Close();
|
||||
_networkStream.Close();
|
||||
WritePosition = downloadPosition;
|
||||
Update();
|
||||
|
||||
downloadedPiece.Set();
|
||||
downloadEnded.Set();
|
||||
|
||||
if (!IsCancelled && WritePosition < ContentLength)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
if (WritePosition > ContentLength)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Download Stream Reader
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanRead => true;
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanSeek => true;
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanWrite => false;
|
||||
|
||||
[JsonIgnore]
|
||||
public override long Length
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!hasBegunDownloading)
|
||||
BeginDownloading();
|
||||
return ContentLength;
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public override long Position { get => _readFile.Position; set => Seek(value, SeekOrigin.Begin); }
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanTimeout => false;
|
||||
|
||||
[JsonIgnore]
|
||||
public override int ReadTimeout { get => base.ReadTimeout; set => base.ReadTimeout = value; }
|
||||
|
||||
[JsonIgnore]
|
||||
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
|
||||
|
||||
public override void Flush() => throw new NotImplementedException();
|
||||
public override void SetLength(long value) => throw new NotImplementedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException();
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (!hasBegunDownloading)
|
||||
BeginDownloading();
|
||||
|
||||
var toRead = Math.Min(count, Length - Position);
|
||||
WaitToPosition(Position + toRead);
|
||||
return _readFile.Read(buffer, offset, count);
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
var newPosition = origin switch
|
||||
{
|
||||
SeekOrigin.Current => Position + offset,
|
||||
SeekOrigin.End => ContentLength + offset,
|
||||
_ => offset,
|
||||
};
|
||||
|
||||
WaitToPosition(newPosition);
|
||||
return _readFile.Position = newPosition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns.
|
||||
/// </summary>
|
||||
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
|
||||
private void WaitToPosition(long requiredPosition)
|
||||
{
|
||||
while (requiredPosition > WritePosition && !IsCancelled && hasBegunDownloading && !downloadedPiece.WaitOne(1000)) ;
|
||||
}
|
||||
|
||||
public override void Close()
|
||||
{
|
||||
IsCancelled = true;
|
||||
|
||||
while (downloadEnded is not null && !downloadEnded.WaitOne(1000)) ;
|
||||
|
||||
_readFile.Close();
|
||||
_writeFile.Close();
|
||||
_networkStream?.Close();
|
||||
Update();
|
||||
}
|
||||
|
||||
#endregion
|
||||
~NetworkFileStream()
|
||||
{
|
||||
downloadEnded?.Close();
|
||||
downloadedPiece?.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Dinah.Core.Net.Http;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
|
||||
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, DownloadLicense dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
{
|
||||
Steps = new StepSequence
|
||||
{
|
||||
Name = "Download Mp3 Audiobook",
|
||||
|
||||
["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 void Cancel()
|
||||
{
|
||||
IsCanceled = true;
|
||||
CloseInputFileStream();
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
{
|
||||
OnRetrievedCoverArt(null);
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
private bool Step_DownloadAudiobookAsSingleFile()
|
||||
{
|
||||
DateTime startTime = DateTime.Now;
|
||||
|
||||
// MUST put InputFileStream.Length first, because it starts background downloader.
|
||||
|
||||
while (InputFileStream.Length > InputFileStream.WritePosition && !InputFileStream.IsCancelled)
|
||||
{
|
||||
var rate = InputFileStream.WritePosition / (DateTime.Now - startTime).TotalSeconds;
|
||||
|
||||
var estTimeRemaining = (InputFileStream.Length - InputFileStream.WritePosition) / rate;
|
||||
|
||||
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,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<Version>6.3.2.1</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSBump" Version="2.3.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Octokit" Version="0.50.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,363 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using AudibleUtilities;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.IO;
|
||||
using Dinah.Core.Logging;
|
||||
using LibationFileManager;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog;
|
||||
|
||||
namespace AppScaffolding
|
||||
{
|
||||
public static class LibationScaffolding
|
||||
{
|
||||
// AppScaffolding
|
||||
private static Assembly _executingAssembly;
|
||||
private static Assembly ExecutingAssembly
|
||||
=> _executingAssembly ??= Assembly.GetExecutingAssembly();
|
||||
|
||||
// LibationWinForms or LibationCli
|
||||
private static Assembly _entryAssembly;
|
||||
private static Assembly EntryAssembly
|
||||
=> _entryAssembly ??= Assembly.GetEntryAssembly();
|
||||
|
||||
// previously: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
||||
private static Version _buildVersion;
|
||||
public static Version BuildVersion
|
||||
=> _buildVersion
|
||||
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
||||
.Max(a => a.Version);
|
||||
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
{
|
||||
// must occur before access to Configuration instance
|
||||
Migrations.migrate_to_v5_2_0__pre_config();
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
// do not use Configuration before this line //
|
||||
// //
|
||||
//***********************************************//
|
||||
return Configuration.Instance;
|
||||
}
|
||||
|
||||
/// <summary>most migrations go in here</summary>
|
||||
public static void RunPostConfigMigrations(Configuration config)
|
||||
{
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
|
||||
//
|
||||
// migrations go below here
|
||||
//
|
||||
|
||||
Migrations.migrate_to_v5_2_0__post_config(config);
|
||||
Migrations.migrate_to_v5_7_1(config);
|
||||
Migrations.migrate_to_v6_1_2(config);
|
||||
Migrations.migrate_to_v6_2_0(config);
|
||||
Migrations.migrate_to_v6_2_9(config);
|
||||
}
|
||||
|
||||
/// <summary>Initialize logging. Run after migration</summary>
|
||||
public static void RunPostMigrationScaffolding(Configuration config)
|
||||
{
|
||||
ensureSerilogConfig(config);
|
||||
configureLogging(config);
|
||||
logStartupState(config);
|
||||
}
|
||||
|
||||
private static void ensureSerilogConfig(Configuration config)
|
||||
{
|
||||
if (config.GetObject("Serilog") != null)
|
||||
return;
|
||||
|
||||
// "Serilog": {
|
||||
// "MinimumLevel": "Information"
|
||||
// "WriteTo": [
|
||||
// {
|
||||
// "Name": "Console"
|
||||
// },
|
||||
// {
|
||||
// "Name": "File",
|
||||
// "Args": {
|
||||
// "rollingInterval": "Day",
|
||||
// "outputTemplate": ...
|
||||
// }
|
||||
// }
|
||||
// ],
|
||||
// "Using": [ "Dinah.Core" ],
|
||||
// "Enrich": [ "WithCaller" ]
|
||||
// }
|
||||
var serilogObj = new JObject
|
||||
{
|
||||
{ "MinimumLevel", "Information" },
|
||||
{ "WriteTo", new JArray
|
||||
{
|
||||
new JObject { {"Name", "Console" } },
|
||||
new JObject
|
||||
{
|
||||
{ "Name", "File" },
|
||||
{ "Args",
|
||||
new JObject
|
||||
{
|
||||
// for this sink to work, a path must be provided. we override this below
|
||||
{ "path", Path.Combine(config.LibationFiles, "_Log.log") },
|
||||
{ "rollingInterval", "Month" },
|
||||
// Serilog template formatting examples
|
||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
|
||||
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "Using", new JArray{ "Dinah.Core" } }, // dll's name, NOT namespace
|
||||
{ "Enrich", new JArray{ "WithCaller" } },
|
||||
};
|
||||
config.SetObject("Serilog", serilogObj);
|
||||
}
|
||||
|
||||
// to restore original: Console.SetOut(origOut);
|
||||
private static TextWriter origOut { get; } = Console.Out;
|
||||
|
||||
private static void configureLogging(Configuration config)
|
||||
{
|
||||
config.ConfigureLogging();
|
||||
|
||||
// capture most Console.WriteLine() and write to serilog. See below tests for details.
|
||||
// Some dependencies print helpful info via Console.WriteLine. We'd like to log it.
|
||||
//
|
||||
// Serilog also writes to Console so this might be asking for trouble. ie: infinite loops.
|
||||
// SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
|
||||
// Empirical testing so far has shown no issues.
|
||||
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
||||
|
||||
#region Console => Serilog tests
|
||||
/*
|
||||
// all below apply to "Console." and "Console.Out."
|
||||
|
||||
// captured
|
||||
Console.WriteLine("str");
|
||||
Console.WriteLine(new { a = "anon" });
|
||||
Console.WriteLine("{0}", "format");
|
||||
Console.WriteLine("{0}{1}", "zero|", "one");
|
||||
Console.WriteLine("{0}{1}{2}", "zero|", "one|", "two");
|
||||
Console.WriteLine("{0}", new object[] { "arr" });
|
||||
|
||||
// not captured
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(true);
|
||||
Console.WriteLine('0');
|
||||
Console.WriteLine(1);
|
||||
Console.WriteLine(2m);
|
||||
Console.WriteLine(3f);
|
||||
Console.WriteLine(4d);
|
||||
Console.WriteLine(5L);
|
||||
Console.WriteLine((uint)6);
|
||||
Console.WriteLine((ulong)7);
|
||||
|
||||
Console.Write("str");
|
||||
Console.Write(true);
|
||||
Console.Write('0');
|
||||
Console.Write(1);
|
||||
Console.Write(2m);
|
||||
Console.Write(3f);
|
||||
Console.Write(4d);
|
||||
Console.Write(5L);
|
||||
Console.Write((uint)6);
|
||||
Console.Write((ulong)7);
|
||||
Console.Write(new { a = "anon" });
|
||||
Console.Write("{0}", "format");
|
||||
Console.Write("{0}{1}", "zero|", "one");
|
||||
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
|
||||
Console.Write("{0}", new object[] { "arr" });
|
||||
*/
|
||||
#endregion
|
||||
|
||||
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
||||
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
||||
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
||||
}
|
||||
|
||||
private static void logStartupState(Configuration config)
|
||||
{
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||
{
|
||||
AppName = EntryAssembly.GetName().Name,
|
||||
Version = BuildVersion.ToString(),
|
||||
#if DEBUG
|
||||
Mode = "Debug",
|
||||
#else
|
||||
Mode = "Release",
|
||||
#endif
|
||||
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
|
||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
||||
|
||||
config.LibationFiles,
|
||||
AudibleFileStorage.BooksDirectory,
|
||||
|
||||
config.InProgress,
|
||||
|
||||
AudibleFileStorage.DownloadsInProgressDirectory,
|
||||
DownloadsInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DownloadsInProgressDirectory).Count(),
|
||||
|
||||
AudibleFileStorage.DecryptInProgressDirectory,
|
||||
DecryptInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
||||
});
|
||||
}
|
||||
|
||||
public static (bool hasUpgrade, string zipUrl, string htmlUrl, string zipName) GetLatestRelease()
|
||||
{
|
||||
(bool, string, string, string) isFalse = (false, null, null, null);
|
||||
|
||||
// timed out
|
||||
var latest = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||
if (latest is null)
|
||||
return isFalse;
|
||||
|
||||
var latestVersionString = latest.TagName.Trim('v');
|
||||
if (!Version.TryParse(latestVersionString, out var latestRelease))
|
||||
return isFalse;
|
||||
|
||||
// we're up to date
|
||||
if (latestRelease <= BuildVersion)
|
||||
return isFalse;
|
||||
|
||||
// we have an update
|
||||
var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip"));
|
||||
var zipUrl = zip?.BrowserDownloadUrl;
|
||||
|
||||
Log.Logger.Information("Update available: {@DebugInfo}", new
|
||||
{
|
||||
latestRelease = latestRelease.ToString(),
|
||||
latest.HtmlUrl,
|
||||
zipUrl
|
||||
});
|
||||
|
||||
return (true, zipUrl, latest.HtmlUrl, zip.Name);
|
||||
}
|
||||
private static Octokit.Release getLatestRelease(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
var task = System.Threading.Tasks.Task.Run(() => getLatestRelease());
|
||||
if (task.Wait(timeout))
|
||||
return task.Result;
|
||||
|
||||
Log.Logger.Information("Timed out");
|
||||
}
|
||||
catch (AggregateException aggEx)
|
||||
{
|
||||
Log.Logger.Error(aggEx, "Checking for new version too often");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static Octokit.Release getLatestRelease()
|
||||
{
|
||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("Libation"));
|
||||
|
||||
// https://octokitnet.readthedocs.io/en/latest/releases/
|
||||
var releases = gitHubClient.Repository.Release.GetAll("rmcrackan", "Libation").GetAwaiter().GetResult();
|
||||
var latest = releases.First(r => !r.Draft && !r.Prerelease);
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Migrations
|
||||
{
|
||||
#region migrate to v5.2.0
|
||||
// get rid of meta-directories, combine DownloadsInProgressEnum and DecryptInProgressEnum => InProgress
|
||||
public static void migrate_to_v5_2_0__pre_config()
|
||||
{
|
||||
{
|
||||
var settingsKey = "DownloadsInProgressEnum";
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGet(settingsKey, out var value))
|
||||
{
|
||||
UNSAFE_MigrationHelper.Settings_Delete(settingsKey);
|
||||
UNSAFE_MigrationHelper.Settings_Insert("InProgress", translatePath(value));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
UNSAFE_MigrationHelper.Settings_Delete("DecryptInProgressEnum");
|
||||
}
|
||||
|
||||
{ // appsettings.json
|
||||
var appSettingsKey = UNSAFE_MigrationHelper.LIBATION_FILES_KEY;
|
||||
if (UNSAFE_MigrationHelper.APPSETTINGS_TryGet(appSettingsKey, out var value))
|
||||
UNSAFE_MigrationHelper.APPSETTINGS_Update(appSettingsKey, translatePath(value));
|
||||
}
|
||||
}
|
||||
|
||||
private static string translatePath(string path)
|
||||
=> path switch
|
||||
{
|
||||
"AppDir" => @".\LibationFiles",
|
||||
"MyDocs" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "LibationFiles")),
|
||||
"UserProfile" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")),
|
||||
"WinTemp" => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")),
|
||||
_ => path
|
||||
};
|
||||
|
||||
public static void migrate_to_v5_2_0__post_config(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
||||
config.AllowLibationFixup = true;
|
||||
|
||||
if (!config.Exists(nameof(config.DecryptToLossy)))
|
||||
config.DecryptToLossy = false;
|
||||
}
|
||||
#endregion
|
||||
|
||||
// add config.BadBook
|
||||
public static void migrate_to_v5_7_1(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.BadBook)))
|
||||
config.BadBook = Configuration.BadBookAction.Ask;
|
||||
}
|
||||
|
||||
// add config.DownloadEpisodes , config.ImportEpisodes
|
||||
public static void migrate_to_v6_1_2(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.DownloadEpisodes)))
|
||||
config.DownloadEpisodes = true;
|
||||
|
||||
if (!config.Exists(nameof(config.ImportEpisodes)))
|
||||
config.ImportEpisodes = true;
|
||||
}
|
||||
|
||||
// add config.SplitFilesByChapter
|
||||
public static void migrate_to_v6_2_0(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.SplitFilesByChapter)))
|
||||
config.SplitFilesByChapter = false;
|
||||
}
|
||||
|
||||
// add file naming templates
|
||||
public static void migrate_to_v6_2_9(Configuration config)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="27.1.1" />
|
||||
<PackageReference Include="NPOI" Version="2.5.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DtoImporterService\DtoImporterService.csproj" />
|
||||
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,286 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using DtoImporterService;
|
||||
using LibationFileManager;
|
||||
using Serilog;
|
||||
using static DtoImporterService.PerfLogger;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class LibraryCommands
|
||||
{
|
||||
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, List<LibraryBook> existingLibrary, params Account[] accounts)
|
||||
{
|
||||
logRestart();
|
||||
|
||||
//These are the minimum response groups required for the
|
||||
//library scanner to pass all validation and filtering.
|
||||
var libraryResponseGroups =
|
||||
LibraryOptions.ResponseGroupOptions.ProductAttrs |
|
||||
LibraryOptions.ResponseGroupOptions.ProductDesc |
|
||||
LibraryOptions.ResponseGroupOptions.Relationships;
|
||||
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return new List<LibraryBook>();
|
||||
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryResponseGroups);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = libraryItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||
|
||||
return missingBookList;
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
#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
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, LibraryOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
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}");
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions.ResponseGroupOptions libraryResponseGroups)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||
var apiExtended = await apiExtendedfunc(account);
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryResponseGroups));
|
||||
}
|
||||
|
||||
// import library in parallel
|
||||
var arrayOfLists = await Task.WhenAll(tasks);
|
||||
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
||||
return importItems;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions.ResponseGroupOptions libraryResponseGroups)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
||||
{
|
||||
Account = account?.MaskedLogEntry ?? "[null]"
|
||||
});
|
||||
|
||||
logTime($"pre scanAccountAsync {account.AccountName}");
|
||||
|
||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryResponseGroups, Configuration.Instance.ImportEpisodes);
|
||||
|
||||
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
||||
|
||||
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");
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryImporter = new LibraryBookImporter(context);
|
||||
var newCount = await Task.Run(() => libraryImporter.Import(importItems));
|
||||
logTime("importIntoDbAsync -- post Import()");
|
||||
var qtyChanges = context.SaveChanges();
|
||||
logTime("importIntoDbAsync -- post SaveChanges");
|
||||
|
||||
if (qtyChanges > 0)
|
||||
await Task.Run(() => finalizeLibrarySizeChange());
|
||||
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
||||
|
||||
return newCount;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region remove books
|
||||
public static Task<List<LibraryBook>> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||
private static List<LibraryBook> removeBooks(List<string> idsToRemove)
|
||||
{
|
||||
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()
|
||||
{
|
||||
SearchEngineCommands.FullReIndex();
|
||||
LibrarySizeChanged?.Invoke(null, null);
|
||||
}
|
||||
|
||||
/// <summary>Occurs when books are added or removed from library</summary>
|
||||
public static event EventHandler LibrarySizeChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/>
|
||||
/// changed values are successfully persisted.
|
||||
/// </summary>
|
||||
public static event EventHandler<string> BookUserDefinedItemCommitted;
|
||||
|
||||
#region Update book details
|
||||
public static int UpdateUserDefinedItem(Book book)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
// Attach() NoTracking entities before SaveChanges()
|
||||
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
var qtyChanges = context.SaveChanges();
|
||||
if (qtyChanges > 0)
|
||||
{
|
||||
SearchEngineCommands.UpdateLiberatedStatus(book);
|
||||
SearchEngineCommands.UpdateBookTags(book);
|
||||
BookUserDefinedItemCommitted?.Invoke(null, book.AudibleProductId);
|
||||
}
|
||||
|
||||
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 pdfsDownloaded, int pdfsNotDownloaded) { }
|
||||
public static LibraryStats GetCounts()
|
||||
{
|
||||
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
|
||||
var results = libraryBooks
|
||||
.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 });
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class SearchEngineCommands
|
||||
{
|
||||
public static void FullReIndex(SearchEngine engine = null)
|
||||
{
|
||||
engine ??= new SearchEngine();
|
||||
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
engine.CreateNewIndex(library);
|
||||
}
|
||||
|
||||
public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e =>
|
||||
e.Search(searchString)
|
||||
);
|
||||
|
||||
public static void UpdateBookTags(Book book) => performSearchEngineAction_safe(e =>
|
||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
|
||||
);
|
||||
|
||||
public static void UpdateLiberatedStatus(Book book) => performSearchEngineAction_safe(e =>
|
||||
e.UpdateLiberatedStatus(book)
|
||||
);
|
||||
|
||||
private static void performSearchEngineAction_safe(Action<SearchEngine> action)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
try
|
||||
{
|
||||
action(engine);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
FullReIndex(engine);
|
||||
action(engine);
|
||||
}
|
||||
}
|
||||
|
||||
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> func)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
try
|
||||
{
|
||||
return func(engine);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
FullReIndex(engine);
|
||||
return func(engine);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Common;
|
||||
using Dinah.Core;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace AudibleUtilities
|
||||
{
|
||||
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
||||
public class ApiExtended
|
||||
{
|
||||
public Api Api { get; private set; }
|
||||
|
||||
private ApiExtended(Api api) => Api = api;
|
||||
|
||||
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, ILoginChoiceEager loginChoiceEager)
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account?.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = account?.Locale?.Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
loginChoiceEager,
|
||||
account.Locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
|
||||
/// <summary>Get api from existing tokens 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>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
|
||||
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
AccountMaskedLogEntry = account.MaskedLogEntry
|
||||
});
|
||||
|
||||
return await CreateAsync(account.AccountId, account.Locale.Name);
|
||||
}
|
||||
|
||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(string username, string localeName)
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
Username = username.ToMask(),
|
||||
LocaleName = localeName,
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
Localization.Get(localeName),
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName));
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
|
||||
private static AsyncRetryPolicy policy { get; }
|
||||
= Policy.Handle<Exception>()
|
||||
// 2 retries == 3 total
|
||||
.RetryAsync(2);
|
||||
|
||||
public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions.ResponseGroupOptions responseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS, bool importEpisodes = true)
|
||||
{
|
||||
// bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or not tokens are refreshed
|
||||
// worse, this 1st dummy call doesn't seem to help:
|
||||
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
|
||||
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
|
||||
return policy.ExecuteAsync(() => getItemsAsync(responseGroups, importEpisodes));
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getItemsAsync(LibraryOptions.ResponseGroupOptions responseGroups, bool importEpisodes)
|
||||
{
|
||||
var items = new List<Item>();
|
||||
#if DEBUG
|
||||
//// this will not work for multi accounts
|
||||
//var library_json = "library.json";
|
||||
//library_json = System.IO.Path.GetFullPath(library_json);
|
||||
//if (System.IO.File.Exists(library_json))
|
||||
//{
|
||||
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
|
||||
//}
|
||||
#endif
|
||||
if (!items.Any())
|
||||
items = await Api.GetAllLibraryItemsAsync(responseGroups);
|
||||
|
||||
await manageEpisodesAsync(items, importEpisodes);
|
||||
|
||||
#if DEBUG
|
||||
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
|
||||
#endif
|
||||
|
||||
var validators = new List<IValidator>();
|
||||
validators.AddRange(getValidators());
|
||||
foreach (var v in validators)
|
||||
{
|
||||
var exceptions = v.Validate(items);
|
||||
if (exceptions != null && exceptions.Any())
|
||||
throw new AggregateException(exceptions);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
#region episodes and podcasts
|
||||
private async Task manageEpisodesAsync(List<Item> items, bool importEpisodes)
|
||||
{
|
||||
// add podcasts and episodes to list. If fail, don't let it de-rail the rest of the import
|
||||
try
|
||||
{
|
||||
// get parents
|
||||
var parents = items.Where(i => i.IsEpisodes).ToList();
|
||||
#if DEBUG
|
||||
//var parentsDebug = parents.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
|
||||
//System.IO.File.WriteAllText("parents.json", parentsDebug);
|
||||
#endif
|
||||
|
||||
if (!parents.Any())
|
||||
return;
|
||||
|
||||
Serilog.Log.Logger.Information($"{parents.Count} series of shows/podcasts found");
|
||||
|
||||
// remove episode parents. even if the following stuff fails, these will still be removed from the collection.
|
||||
// also must happen before processing children because children abuses this flag
|
||||
items.RemoveAll(i => i.IsEpisodes);
|
||||
|
||||
if (importEpisodes)
|
||||
{
|
||||
// add children
|
||||
var children = await getEpisodesAsync(parents);
|
||||
Serilog.Log.Logger.Information($"{children.Count} episodes of shows/podcasts found");
|
||||
items.AddRange(children);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error adding podcasts and episodes");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getEpisodesAsync(List<Item> parents)
|
||||
{
|
||||
var results = new List<Item>();
|
||||
|
||||
foreach (var parent in parents)
|
||||
{
|
||||
var children = await getEpisodeChildrenAsync(parent);
|
||||
|
||||
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,
|
||||
Sequence = parent.Relationships.Single(r => r.Asin == child.Asin).Sort.ToString(),
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
// overload (read: abuse) IsEpisodes flag
|
||||
child.Relationships = new Relationship[]
|
||||
{
|
||||
new Relationship
|
||||
{
|
||||
RelationshipToProduct = RelationshipToProduct.Child,
|
||||
RelationshipType = RelationshipType.Episode
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
results.AddRange(children);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
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");
|
||||
// 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, "Quantity of series episodes defined by parent does not match quantity returned by batch fetching.");
|
||||
throw ex;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
#endregion
|
||||
|
||||
private static List<IValidator> getValidators()
|
||||
{
|
||||
var type = typeof(IValidator);
|
||||
var types = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(s => s.GetTypes())
|
||||
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
|
||||
|
||||
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="2.3.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,71 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DataLayer.Configurations
|
||||
{
|
||||
internal class BookConfig : IEntityTypeConfiguration<Book>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Book> entity)
|
||||
{
|
||||
entity.HasKey(b => b.BookId);
|
||||
entity.HasIndex(b => b.AudibleProductId);
|
||||
|
||||
entity.OwnsOne(b => b.Rating);
|
||||
|
||||
//
|
||||
// CRUCIAL: ignore unmapped collections, even get-only
|
||||
//
|
||||
entity.Ignore(nameof(Book.Authors));
|
||||
entity.Ignore(nameof(Book.Narrators));
|
||||
//// these don't seem to matter
|
||||
//entity.Ignore(nameof(Book.AuthorNames));
|
||||
//entity.Ignore(nameof(Book.NarratorNames));
|
||||
//entity.Ignore(nameof(Book.HasPdfs));
|
||||
|
||||
// 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."
|
||||
entity
|
||||
.OwnsMany(b => b.Supplements, b_s =>
|
||||
{
|
||||
b_s.WithOwner(s => s.Book)
|
||||
.HasForeignKey(s => s.BookId);
|
||||
b_s.HasKey(s => s.SupplementId);
|
||||
});
|
||||
// even though it's owned, we need to map its backing field
|
||||
entity
|
||||
.Metadata
|
||||
.FindNavigation(nameof(Book.Supplements))
|
||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
// owns it 1:1, store in separate table
|
||||
entity
|
||||
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
||||
{
|
||||
b_udi.WithOwner(udi => udi.Book)
|
||||
.HasForeignKey(udi => udi.BookId);
|
||||
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
||||
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
||||
|
||||
// owns it 1:1, store in same table
|
||||
b_udi.OwnsOne(udi => udi.Rating);
|
||||
});
|
||||
|
||||
entity
|
||||
.Metadata
|
||||
.FindNavigation(nameof(Book.ContributorsLink))
|
||||
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
|
||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
entity
|
||||
.Metadata
|
||||
.FindNavigation(nameof(Book.SeriesLink))
|
||||
// PropertyAccessMode.Field : Series is a get-only property, not a field, so use its backing field
|
||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
entity
|
||||
.HasOne(b => b.Category)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.CategoryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
// only library importing should use tracking. All else should be NoTracking.
|
||||
// only library importing should directly query Book. All else should use LibraryBook
|
||||
public static class LibraryBookQueries
|
||||
{
|
||||
//// tracking is a bad idea for main grid. it prevents anything else from updating entities unless getting them from the grid
|
||||
//public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context)
|
||||
// => context
|
||||
// .Library
|
||||
// .GetLibrary()
|
||||
// .ToList();
|
||||
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
|
||||
=> context
|
||||
.LibraryBooks
|
||||
.AsNoTrackingWithIdentityResolution()
|
||||
.GetLibrary()
|
||||
.ToList();
|
||||
|
||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||
=> context
|
||||
.LibraryBooks
|
||||
.AsNoTrackingWithIdentityResolution()
|
||||
.GetLibraryBook(productId);
|
||||
|
||||
public static LibraryBook GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
|
||||
=> library
|
||||
.GetLibrary()
|
||||
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
|
||||
|
||||
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
|
||||
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
|
||||
=> library
|
||||
// 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.ContributorsLink).ThenInclude(c => c.Contributor)
|
||||
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
|
||||
}
|
||||
}
|
||||
@@ -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 != 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"// this connection string is ONLY used for DataLayer's Migrations. this appsettings.json file is NOT used at all by application; it is overwritten": "",
|
||||
"LibationContext": "Data Source=LibationContext.db;Foreign Keys=False;"
|
||||
}
|
||||
}
|
||||
68
Docker/liberate.sh
Executable file
@@ -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
Dockerfile
Normal file
@@ -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"]
|
||||
79
Documentation/Advanced.md
Normal file
@@ -0,0 +1,79 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
|
||||
# Advanced: Table of Contents
|
||||
|
||||
- [Files and folders](#files-and-folders)
|
||||
- [Settings](#settings)
|
||||
- [Custom File Naming](NamingTemplates.md)
|
||||
- [Command Line Interface](#command-line-interface)
|
||||
|
||||
|
||||
|
||||
### Files and folders
|
||||
|
||||
To make upgrades and reinstalls easier, Libation separates all of its responsibilities to a few different folders. If you don't want to mess with this stuff: ignore it. Read on if you like a little more control over your files.
|
||||
|
||||
* In Libation's initial folder are the files that make up the program. Since nothing else is here, just copy new files here to upgrade the program. Delete this folder to delete Libation.
|
||||
|
||||
* In a separate folder, Libation keeps track of all of the files it creates like settings and downloaded images. After an upgrade, Libation might think that's its being run for the first time. Just click ADVANCED SETUP and point to this folder. Libation will reload your library and settings.
|
||||
|
||||
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
|
||||
|
||||
### Settings
|
||||
|
||||
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
|
||||
|
||||
### Command Line Interface
|
||||
|
||||
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
|
||||
|
||||
Warnings about relying solely on on the CLI:
|
||||
* CLI will not perform any upgrades.
|
||||
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
|
||||
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
|
||||
|
||||
```
|
||||
help
|
||||
libationcli --help
|
||||
|
||||
verb-specific help
|
||||
libationcli scan --help
|
||||
|
||||
scan all libraries
|
||||
libationcli scan
|
||||
scan only libraries for specific accounts
|
||||
libationcli scan nickname1 nickname2
|
||||
|
||||
convert all m4b files to mp3
|
||||
libationcli convert
|
||||
|
||||
liberate all books and pdfs
|
||||
libationcli liberate
|
||||
liberate pdfs only
|
||||
libationcli liberate --pdf
|
||||
libationcli liberate -p
|
||||
|
||||
export library to file
|
||||
libationcli export --path "C:\foo\bar\my.json" --json
|
||||
libationcli export -p "C:\foo\bar\my.json" -j
|
||||
libationcli export -p "C:\foo\bar\my.csv" --csv
|
||||
libationcli export -p "C:\foo\bar\my.csv" -c
|
||||
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
|
||||
libationcli export -p "C:\foo\bar\my.xlsx" -x
|
||||
|
||||
Set download statuses throughout library based on whether each book's audio file can be found.
|
||||
Must include at least one flag: --downloaded , --not-downloaded.
|
||||
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
|
||||
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
|
||||
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
|
||||
CLI: Full library. No prompt
|
||||
|
||||
libationcli set-status -d
|
||||
libationcli set-status -n
|
||||
libationcli set-status -d -n
|
||||
```
|
||||
39
Documentation/Docker.md
Normal file
@@ -0,0 +1,39 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
### Disclaimer
|
||||
The docker image is provided as-is. We hope it can be useful to you but it is not officially supported.
|
||||
|
||||
### Setup
|
||||
In order to use the docker image, you'll need to provide it with a copy of the `AccountsSettings.json`, `Settings.json`, and `LibationContext.db` files. These files can usually be found in the Libation folder in your user's home directory. If you haven't run Libation yet, you'll need to launch it to generate these files and setup your accounts. Once you have them, copy these files to a new location, such as `/opt/libation/config`. Before using them we'll need to make a couple edits so that the filepaths referenced are correct when running from the docker image.
|
||||
|
||||
In Settings.json, make the following changes:
|
||||
* Change `Books` to `/data`
|
||||
* Change `InProgress` to `/tmp`
|
||||
|
||||
### Running
|
||||
Once the configuration files are copied and edited, the docker image can be run with the following command.
|
||||
```
|
||||
sudo docker run -d \
|
||||
-v /opt/libation/config:/config \
|
||||
-v /opt/libation/books:/data \
|
||||
--name libation \
|
||||
--restart=always \
|
||||
rmcrackan/libation
|
||||
```
|
||||
|
||||
By default the container will scan for new books every 30 minutes and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. Additionally, if you pass in `-1` it will scan and download books once and then exit.
|
||||
|
||||
```
|
||||
sudo docker run -d \
|
||||
-v /opt/libation/config:/config \
|
||||
-v /opt/libation/books:/data \
|
||||
-e SLEEP_TIME='10m' \
|
||||
--name libation \
|
||||
--restart=always \
|
||||
rmcrackan/libation
|
||||
```
|
||||
|
||||
150
Documentation/GettingStarted.md
Normal file
@@ -0,0 +1,150 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
|
||||
# Getting started: Table of Contents
|
||||
|
||||
- [Download Libation](#download-libation-1)
|
||||
- [Installation](#installation)
|
||||
- [Create Accounts](#create-accounts)
|
||||
- [Import your library](#import-your-library)
|
||||
- [Download your books -- DRM-free!](#download-your-books----drm-free)
|
||||
- [Download PDF attachments](#download-pdf-attachments)
|
||||
- [Details of downloaded files](#details-of-downloaded-files)
|
||||
- [Export your library](#export-your-library)
|
||||
|
||||
|
||||
|
||||
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
|
||||
|
||||
##### Which version? Chardonnay vs Classic
|
||||
|
||||
Nearly 100% of the difference is look and feel -- it's a matter of preference.
|
||||
|
||||
Chardonnay has an updated look and will work and look the same on Windows, Mac, and Linux.
|
||||
Classic is Windows only. It has an older look because it's built with older, duller, and more mature technology. This tech has built into it better support for things like accessibility for screen readers.
|
||||
|
||||
### Installation
|
||||
|
||||
* Windows
|
||||
|
||||
Extract the zip file to a folder and then run `Libation.exe` from inside of that folder. Do not put it in Program Files. The inability to edit files from there causes problems with configuration and updating.
|
||||
|
||||
* [Linux](InstallOnLinux.md)
|
||||
* [MacOS](InstallOnMac.md)
|
||||
|
||||
### Create Accounts
|
||||
|
||||
Create your account(s):
|
||||
|
||||

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

|
||||
|
||||
Export your library to Excel, CSV, or JSON
|
||||
30
Documentation/InstallOnLinux.md
Normal file
@@ -0,0 +1,30 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
### Install and Run Libation on Ubuntu
|
||||
|
||||
New Libation releases are automatically packed into .deb and .rpm package and are available from the Libation repository's releases page.
|
||||
|
||||
|
||||
Run this command in your terminal to dowbnload and install Libation, replacing the url with the latest Libation package url:
|
||||
|
||||
- Debian
|
||||
```Console
|
||||
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
|
||||
sudo apt install ./libation.deb
|
||||
```
|
||||
- Redhat and CentOS
|
||||
```Console
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
|
||||
sudo yum install ./libation.rpm
|
||||
```
|
||||
|
||||
|
||||
If your desktop uses gtk, you should now see Libation among your applications.
|
||||
|
||||
Additionally, you may launch Libation, LibationCli, and Hangover (the Libation recovery app) via the command line using 'libation, libationcli', and 'hangover' aliases respectively.
|
||||
|
||||
Report bugs to https://github.com/rmcrackan/Libation/issues
|
||||
45
Documentation/InstallOnMac.md
Normal file
@@ -0,0 +1,45 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
# Run Libation on MacOS
|
||||
This walkthrough should get you up and running with Libation on your Mac.
|
||||
|
||||
## Supports macOS 10.15 (Catalina) and above
|
||||
|
||||
## Install Libation
|
||||
|
||||
- Download the file from the latest release and extract it.
|
||||
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz`
|
||||
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
|
||||
- Move the extracted Libation app bundle to your applications folder.
|
||||
- Open a terminal (Go > Utilities > Terminal)
|
||||
- Copy/paste/run the following command (you'll be prompted to enter your password)
|
||||
|
||||
```Console
|
||||
sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable
|
||||
```
|
||||
- Close the terminal and use Libation!
|
||||
|
||||
## Running Hangover
|
||||
|
||||
Libation comes with a recovery app called Hangover. You can start it by running this command:
|
||||
```Console
|
||||
open /Applications/Libation.app --args hangover
|
||||
```
|
||||
|
||||
## Runnign LibationCli
|
||||
|
||||
Libation comes with a command-line interface. Unfortunately, due to the way apps are sandboxed on mac, its use is somewhat limited. To open a new sandboxed terminal in LibationCli's directory, run the following command:
|
||||
```Console
|
||||
open /Applications/Libation.app --args cli
|
||||
```
|
||||
To use LibationCli from an unsandboxed terminal, you must disable gatekeeper again and run the program directly at `/Applications/Libation.app/Contents/MacOS/LibationCli`
|
||||
|
||||
Then use `./LibationCli` to execute a command.
|
||||
|
||||
## Get Libation running on Mac
|
||||
|
||||
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/219271379-a922e4e1-48a0-48e4-bd81-48aa1226a4f5.mp4)
|
||||
119
Documentation/NamingTemplates.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 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|
|
||||
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
|
||||
|
||||
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
|
||||
|
||||
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
|
||||
|
||||
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
|
||||
|
||||
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
|
||||
|
||||
\<if podcast-\>Podcasts<-if podcast\>\<!if podcast-\>Books\<-if podcast\>\\\<title\>
|
||||
|
||||
|
||||
# Tag Formatters
|
||||
**Text**, **Name List**, **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|
|
||||
|
||||
|
||||
79
Documentation/SearchingAndFiltering.md
Normal file
@@ -0,0 +1,79 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
|
||||
# Searching and filtering: Table of Contents
|
||||
|
||||
- [Tags](#tags)
|
||||
- [Searches](#searches)
|
||||
- [Search examples](#search-examples)
|
||||
- [Filters](#filters)
|
||||
|
||||
|
||||
|
||||
### Tags
|
||||
|
||||
To add tags to a title, click the tags button
|
||||
|
||||

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

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

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

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

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

|
||||

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

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

|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 336 B After Width: | Height: | Size: 336 B |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
BIN
Documentation/images/Import2.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
BIN
Documentation/images/alt-login1.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
Documentation/images/alt-login2.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
Documentation/images/alt-login3.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
Documentation/images/alt-login4.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,173 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApi.Common;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class BookImporter : ItemsImporterBase
|
||||
{
|
||||
public BookImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new BookValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// pre-req.s
|
||||
new ContributorImporter(DbContext).Import(importItems);
|
||||
new SeriesImporter(DbContext).Import(importItems);
|
||||
new CategoryImporter(DbContext).Import(importItems);
|
||||
|
||||
// get distinct
|
||||
var productIds = importItems.Select(i => i.DtoItem.ProductId).Distinct().ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_books(productIds);
|
||||
|
||||
// upsert
|
||||
var qtyNew = upsertBooks(importItems);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_books(List<string> productIds)
|
||||
{
|
||||
// if this context has already loaded books, don't need to reload them. vestige from when context was long-lived. in practice, we now typically use a fresh context. this is quick though so no harm in leaving it.
|
||||
var localProductIds = DbContext.Books.Local.Select(b => b.AudibleProductId).ToList();
|
||||
var remainingProductIds = productIds
|
||||
.Except(localProductIds)
|
||||
.ToList();
|
||||
|
||||
#region // explanation of DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
/*
|
||||
articles suggest loading to Local with
|
||||
context.Books.Load();
|
||||
we want Books and associated fields
|
||||
context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
this is emulating Load() but with also getting associated fields
|
||||
|
||||
from: Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
|
||||
// Summary:
|
||||
// Enumerates the query. When using Entity Framework, this causes the results of
|
||||
// the query to be loaded into the associated context. This is equivalent to calling
|
||||
// ToList and then throwing away the list (without the overhead of actually creating
|
||||
// the list).
|
||||
public static void Load<TSource>([NotNullAttribute] this IQueryable<TSource> source);
|
||||
*/
|
||||
#endregion
|
||||
|
||||
// GetBooks() eager loads Series, category, et al
|
||||
if (remainingProductIds.Any())
|
||||
DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
}
|
||||
|
||||
private int upsertBooks(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var item in importItems)
|
||||
{
|
||||
var book = DbContext.Books.Local.FirstOrDefault(p => p.AudibleProductId == item.DtoItem.ProductId);
|
||||
if (book is null)
|
||||
{
|
||||
book = createNewBook(item);
|
||||
qtyNew++;
|
||||
}
|
||||
|
||||
updateBook(item, book);
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private Book createNewBook(ImportItem importItem)
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
var contentType = item.IsEpisodes ? DataLayer.ContentType.Episode : DataLayer.ContentType.Product;
|
||||
|
||||
// absence of authors is very rare, but possible
|
||||
if (!item.Authors?.Any() ?? true)
|
||||
item.Authors = new[] { new Person { Name = "", Asin = null } };
|
||||
|
||||
// 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
|
||||
.Authors
|
||||
.Select(a => DbContext.Contributors.Local.Single(c => a.Name == c.Name))
|
||||
.ToList();
|
||||
|
||||
var narrators
|
||||
= item.Narrators is null || !item.Narrators.Any()
|
||||
// if no narrators listed, author is the narrator
|
||||
? authors
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
: item
|
||||
.Narrators
|
||||
.Select(n => DbContext.Contributors.Local.Single(c => n.Name == c.Name))
|
||||
.ToList();
|
||||
|
||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
||||
// absence of categories is also possible
|
||||
|
||||
// CATEGORY HACK: only use the 1st 2 categories
|
||||
// (real impl: var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";)
|
||||
var lastCategory
|
||||
= item.Categories.Length == 0 ? ""
|
||||
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
|
||||
// 2+
|
||||
: item.Categories[1].CategoryId;
|
||||
|
||||
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == lastCategory);
|
||||
|
||||
var book = DbContext.Books.Add(new Book(
|
||||
new AudibleProductId(item.ProductId),
|
||||
item.TitleWithSubtitle,
|
||||
item.Description,
|
||||
item.LengthInMinutes,
|
||||
contentType,
|
||||
authors,
|
||||
narrators,
|
||||
category,
|
||||
importItem.LocaleName)
|
||||
).Entity;
|
||||
|
||||
var publisherName = item.Publisher;
|
||||
if (!string.IsNullOrWhiteSpace(publisherName))
|
||||
{
|
||||
var publisher = DbContext.Contributors.Local.Single(c => publisherName == c.Name);
|
||||
book.ReplacePublisher(publisher);
|
||||
}
|
||||
|
||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
|
||||
|
||||
if (item.PdfUrl is not null)
|
||||
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||
|
||||
return book;
|
||||
}
|
||||
|
||||
private void updateBook(ImportItem importItem, Book book)
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
// set/update book-specific info which may have changed
|
||||
book.PictureId = item.PictureId;
|
||||
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
||||
|
||||
// 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);
|
||||
|
||||
// update series even for existing books. these are occasionally updated
|
||||
// these will upsert over library-scraped series, but will not leave orphans
|
||||
if (item.Series != null)
|
||||
{
|
||||
foreach (var seriesEntry in item.Series)
|
||||
{
|
||||
var series = DbContext.Series.Local.FirstOrDefault(s => seriesEntry.SeriesId == s.AudibleSeriesId);
|
||||
book.UpsertSeries(series, seriesEntry.Sequence);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,53 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class LibraryBookImporter : ItemsImporterBase
|
||||
{
|
||||
public LibraryBookImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new LibraryValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
new BookImporter(DbContext).Import(importItems);
|
||||
|
||||
var qtyNew = upsertLibraryBooks(importItems);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// technically, we should be able to have duplicate books from separate accounts.
|
||||
// this would violate the current pk and would be difficult to deal with elsewhere:
|
||||
// - what to show in the grid
|
||||
// - which to consider liberated
|
||||
//
|
||||
// sqlite cannot alter pk. the work around is an extensive headache
|
||||
// - update: now possible in .net5/efcore5
|
||||
//
|
||||
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
|
||||
//
|
||||
// CURRENT SOLUTION: don't re-insert
|
||||
|
||||
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
|
||||
var newItems = importItems.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId)).ToList();
|
||||
|
||||
foreach (var newItem in newItems)
|
||||
{
|
||||
var libraryBook = new LibraryBook(
|
||||
DbContext.Books.Local.Single(b => b.AudibleProductId == newItem.DtoItem.ProductId),
|
||||
newItem.DtoItem.DateAdded,
|
||||
newItem.AccountId);
|
||||
DbContext.LibraryBooks.Add(libraryBook);
|
||||
}
|
||||
|
||||
var qtyNew = newItems.Count;
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApi.Common;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class SeriesImporter : ItemsImporterBase
|
||||
{
|
||||
public SeriesImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new SeriesValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// get distinct
|
||||
var series = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetSeriesDistinct()
|
||||
.ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_series(series);
|
||||
|
||||
// upsert
|
||||
var qtyNew = upsertSeries(series);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_series(List<AudibleApi.Common.Series> series)
|
||||
{
|
||||
var seriesIds = series.Select(s => s.SeriesId).ToList();
|
||||
var localIds = DbContext.Series.Local.Select(s => s.AudibleSeriesId).ToList();
|
||||
var remainingSeriesIds = seriesIds
|
||||
.Distinct()
|
||||
.Except(localIds)
|
||||
.ToList();
|
||||
|
||||
if (remainingSeriesIds.Any())
|
||||
DbContext.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
|
||||
}
|
||||
|
||||
private int upsertSeries(List<AudibleApi.Common.Series> requestedSeries)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var s in requestedSeries)
|
||||
{
|
||||
var series = DbContext.Series.Local.FirstOrDefault(c => c.AudibleSeriesId == s.SeriesId);
|
||||
if (series is null)
|
||||
{
|
||||
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
|
||||
qtyNew++;
|
||||
}
|
||||
series.UpdateName(s.SeriesName);
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class AudioDecodable : Processable
|
||||
{
|
||||
public event EventHandler<Action<byte[]>> RequestCoverArt;
|
||||
public event EventHandler<string> TitleDiscovered;
|
||||
public event EventHandler<string> AuthorsDiscovered;
|
||||
public event EventHandler<string> NarratorsDiscovered;
|
||||
public event EventHandler<byte[]> CoverImageDiscovered;
|
||||
public abstract void Cancel();
|
||||
|
||||
protected void OnRequestCoverArt(Action<byte[]> setCoverArtDel)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
|
||||
RequestCoverArt?.Invoke(this, setCoverArtDel);
|
||||
}
|
||||
|
||||
protected void OnTitleDiscovered(string title)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = title });
|
||||
TitleDiscovered?.Invoke(this, title);
|
||||
}
|
||||
|
||||
protected void OnAuthorsDiscovered(string authors)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = authors });
|
||||
AuthorsDiscovered?.Invoke(this, authors);
|
||||
}
|
||||
|
||||
protected void OnNarratorsDiscovered(string narrators)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = narrators });
|
||||
NarratorsDiscovered?.Invoke(this, narrators);
|
||||
}
|
||||
|
||||
protected void OnCoverImageDiscovered(byte[] coverImage)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(CoverImageDiscovered), CoverImageBytes = coverImage?.Length });
|
||||
CoverImageDiscovered?.Invoke(this, coverImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public static class AudioFileStorageExt
|
||||
{
|
||||
private static void AddParameterReplacement(this FileTemplate fileTemplate, TemplateTags templateTags, object value)
|
||||
=> fileTemplate.AddParameterReplacement(templateTags.TagName, value);
|
||||
|
||||
internal class MultipartRenamer
|
||||
{
|
||||
public LibraryBook libraryBook { get; }
|
||||
|
||||
public MultipartRenamer(LibraryBook libraryBook) => this.libraryBook = libraryBook;
|
||||
|
||||
internal string MultipartFilename(string outputFileName, int partsPosition, int partsTotal, AAXClean.NewSplitCallback newSplitCallback)
|
||||
=> MultipartFilename(Configuration.Instance.ChapterFileTemplate, AudibleFileStorage.DecryptInProgressDirectory, Path.GetExtension(outputFileName), partsPosition, partsTotal, newSplitCallback?.Chapter?.Title ?? "");
|
||||
|
||||
internal string MultipartFilename(string template, string fullDirPath, string extension, int partsPosition, int partsTotal, string chapterTitle)
|
||||
{
|
||||
var fileTemplate = GetFileTemplateSingle(template, libraryBook, fullDirPath, extension);
|
||||
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.ChCount, partsTotal);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.ChNumber, partsPosition);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(partsPosition, partsTotal));
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.ChTitle, chapterTitle);
|
||||
|
||||
return fileTemplate.GetFilePath();
|
||||
}
|
||||
}
|
||||
|
||||
public static Func<string, int, int, AAXClean.NewSplitCallback, string> CreateMultipartRenamerFunc(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
=> new MultipartRenamer(libraryBook).MultipartFilename;
|
||||
|
||||
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> GetCustomDirFilename(_, libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension);
|
||||
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> GetCustomDirFilename(_, libraryBook, AudibleFileStorage.BooksDirectory, extension);
|
||||
|
||||
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
=> GetFileTemplateSingle(Configuration.Instance.FolderTemplate, libraryBook, AudibleFileStorage.BooksDirectory, null)
|
||||
.GetFilePath();
|
||||
|
||||
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
|
||||
=> GetFileTemplateSingle(Configuration.Instance.FileTemplate, libraryBook, dirFullPath, extension)
|
||||
.GetFilePath();
|
||||
|
||||
internal static FileTemplate GetFileTemplateSingle(string template, LibraryBook libraryBook, string dirFullPath, string extension)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(dirFullPath, nameof(dirFullPath));
|
||||
|
||||
var fullfilename = Path.Combine(dirFullPath, template + FileUtility.GetStandardizedExtension(extension));
|
||||
var fileTemplate = new FileTemplate(fullfilename) { IllegalCharacterReplacements = "_" };
|
||||
|
||||
var title = libraryBook.Book.Title ?? "";
|
||||
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.Id, libraryBook.Book.AudibleProductId);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.Title, title);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.TitleShort, title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':')));
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.Author, libraryBook.Book.AuthorNames);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.FirstAuthor, libraryBook.Book.Authors.FirstOrDefault()?.Name);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.Narrator, libraryBook.Book.NarratorNames);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.FirstNarrator, libraryBook.Book.Narrators.FirstOrDefault()?.Name);
|
||||
|
||||
var seriesLink = libraryBook.Book.SeriesLink.FirstOrDefault();
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.Series, seriesLink?.Series.Name);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.SeriesNumber, seriesLink?.Order);
|
||||
|
||||
return fileTemplate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class ConvertToMp3 : AudioDecodable
|
||||
{
|
||||
private Mp4File m4bBook;
|
||||
|
||||
private long fileSize;
|
||||
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
||||
|
||||
public override void Cancel() => m4bBook?.Cancel();
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
{
|
||||
var path = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
|
||||
return path?.ToLower()?.EndsWith(".m4b") == true && !File.Exists(Mp3FileName(path));
|
||||
}
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnBegin(libraryBook);
|
||||
|
||||
OnStreamingBegin($"Begin converting {libraryBook} to mp3");
|
||||
|
||||
try
|
||||
{
|
||||
var m4bPath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
|
||||
m4bBook = new Mp4File(m4bPath, FileAccess.Read);
|
||||
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||
|
||||
fileSize = m4bBook.InputStream.Length;
|
||||
|
||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||
|
||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
||||
|
||||
var result = await Task.Run(() => m4bBook.ConvertToMp3(mp3File));
|
||||
m4bBook.InputStream.Close();
|
||||
mp3File.Close();
|
||||
|
||||
var proposedMp3Path = Mp3FileName(m4bPath);
|
||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path);
|
||||
OnFileCreated(libraryBook, realMp3Path);
|
||||
|
||||
var statusHandler = new StatusHandler();
|
||||
|
||||
if (result == ConversionResult.Failed)
|
||||
statusHandler.AddError("Conversion failed");
|
||||
|
||||
return statusHandler;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted($"Completed converting to mp3: {libraryBook.Book.Title}");
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
var duration = m4bBook.Duration;
|
||||
double remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
||||
double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
||||
|
||||
OnStreamingProgressChanged(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = progressPercent,
|
||||
BytesReceived = (long)(fileSize * progressPercent),
|
||||
TotalBytesToReceive = fileSize
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AaxDecrypter;
|
||||
using AudibleApi;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadDecryptBook : AudioDecodable
|
||||
{
|
||||
private AudiobookDownloadBase abDownloader;
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists;
|
||||
|
||||
public override void Cancel() => abDownloader?.Cancel();
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var entries = new List<FilePathCache.CacheEntry>();
|
||||
// these only work so minimally b/c CacheEntry is a record.
|
||||
// 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
|
||||
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
|
||||
{
|
||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||
entries.Add(e);
|
||||
}
|
||||
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
|
||||
{
|
||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||
entries.Remove(e);
|
||||
}
|
||||
|
||||
OnBegin(libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
if (libraryBook.Book.Audio_Exists)
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
|
||||
bool success = false;
|
||||
try
|
||||
{
|
||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
||||
FilePathCache.Removed += FilePathCache_Removed;
|
||||
|
||||
success = await downloadAudiobookAsync(libraryBook);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FilePathCache.Inserted -= FilePathCache_Inserted;
|
||||
FilePathCache.Removed -= FilePathCache_Removed;
|
||||
}
|
||||
|
||||
// decrypt failed
|
||||
if (!success)
|
||||
return new StatusHandler { "Decrypt failed" };
|
||||
|
||||
// moves new files from temp dir to final dest
|
||||
var movedAudioFile = moveFilesToBooksDir(libraryBook, entries);
|
||||
|
||||
// decrypt failed
|
||||
if (!movedAudioFile)
|
||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||
|
||||
libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnStreamingBegin($"Begin decrypting {libraryBook}");
|
||||
|
||||
try
|
||||
{
|
||||
downloadValidation(libraryBook);
|
||||
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||
|
||||
var audiobookDlLic = new DownloadLicense
|
||||
(
|
||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
||||
contentLic?.Voucher?.Key,
|
||||
contentLic?.Voucher?.Iv,
|
||||
Resources.USER_AGENT
|
||||
);
|
||||
|
||||
//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.
|
||||
var outputFormat =
|
||||
contentLic.ContentMetadata.ContentReference.ContentFormat == "MPEG" ||
|
||||
(Configuration.Instance.AllowLibationFixup && Configuration.Instance.DecryptToLossy) ?
|
||||
OutputFormat.Mp3 : OutputFormat.M4b;
|
||||
|
||||
if (Configuration.Instance.AllowLibationFixup || outputFormat == OutputFormat.Mp3)
|
||||
{
|
||||
audiobookDlLic.ChapterInfo = new AAXClean.ChapterInfo();
|
||||
|
||||
foreach (var chap in contentLic.ContentMetadata?.ChapterInfo?.Chapters)
|
||||
audiobookDlLic.ChapterInfo.AddChapter(chap.Title, TimeSpan.FromMilliseconds(chap.LengthMs));
|
||||
}
|
||||
|
||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, outputFormat.ToString().ToLower());
|
||||
|
||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||
|
||||
abDownloader
|
||||
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm ? new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic)
|
||||
: Configuration.Instance.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
|
||||
outFileName, cacheDir, audiobookDlLic, outputFormat,
|
||||
AudibleFileStorage.Audio.CreateMultipartRenamerFunc(libraryBook)
|
||||
)
|
||||
: new AaxcDownloadSingleConverter(outFileName, cacheDir, audiobookDlLic, outputFormat);
|
||||
abDownloader.DecryptProgressUpdate += (_, progress) => OnStreamingProgressChanged(progress);
|
||||
abDownloader.DecryptTimeRemaining += (_, remaining) => OnStreamingTimeRemaining(remaining);
|
||||
abDownloader.RetrievedTitle += (_, title) => OnTitleDiscovered(title);
|
||||
abDownloader.RetrievedAuthors += (_, authors) => OnAuthorsDiscovered(authors);
|
||||
abDownloader.RetrievedNarrators += (_, narrators) => OnNarratorsDiscovered(narrators);
|
||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||
|
||||
// REAL WORK DONE HERE
|
||||
var success = await Task.Run(abDownloader.Run);
|
||||
return success;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted($"Completed downloading and decrypting {libraryBook.Book.Title}");
|
||||
}
|
||||
}
|
||||
|
||||
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 sender, byte[] e)
|
||||
{
|
||||
if (e is not null)
|
||||
OnCoverImageDiscovered(e);
|
||||
else if (Configuration.Instance.AllowLibationFixup)
|
||||
OnRequestCoverArt(abDownloader.SetCoverArt);
|
||||
}
|
||||
|
||||
/// <summary>Move new files to 'Books' directory</summary>
|
||||
/// <returns>True if audiobook file(s) were successfully created and can be located on disk. Else false.</returns>
|
||||
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 (getFirstAudio() == default)
|
||||
return false;
|
||||
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var entry = entries[i];
|
||||
|
||||
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)));
|
||||
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)
|
||||
entries[i] = entry with { Path = realDest };
|
||||
}
|
||||
|
||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||
if (cue != default)
|
||||
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
||||
|
||||
AudibleFileStorage.Audio.Refresh();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
// currently only used to download the .zip flies for upgrade
|
||||
public class DownloadFile : Streamable
|
||||
{
|
||||
public async Task<string> PerformDownloadFileAsync(string downloadUrl, string proposedDownloadFilePath)
|
||||
{
|
||||
var client = new HttpClient();
|
||||
|
||||
var progress = new Progress<DownloadProgress>();
|
||||
progress.ProgressChanged += (_, e) => OnStreamingProgressChanged(e);
|
||||
|
||||
OnStreamingBegin(proposedDownloadFilePath);
|
||||
|
||||
try
|
||||
{
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
OnFileCreated("Upgrade", actualDownloadedFilePath);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted(proposedDownloadFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" />
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,45 +0,0 @@
|
||||
using System;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class Streamable
|
||||
{
|
||||
public event EventHandler<string> StreamingBegin;
|
||||
public event EventHandler<DownloadProgress> StreamingProgressChanged;
|
||||
public event EventHandler<TimeSpan> StreamingTimeRemaining;
|
||||
public event EventHandler<string> StreamingCompleted;
|
||||
/// <summary>Fired when a file is successfully saved to disk</summary>
|
||||
public event EventHandler<(string id, string path)> FileCreated;
|
||||
|
||||
protected void OnStreamingBegin(string filePath)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StreamingBegin), Message = filePath });
|
||||
StreamingBegin?.Invoke(this, filePath);
|
||||
}
|
||||
|
||||
protected void OnStreamingProgressChanged(DownloadProgress progress)
|
||||
{
|
||||
StreamingProgressChanged?.Invoke(this, progress);
|
||||
}
|
||||
|
||||
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining)
|
||||
{
|
||||
StreamingTimeRemaining?.Invoke(this, timeRemaining);
|
||||
}
|
||||
|
||||
protected void OnStreamingCompleted(string filePath)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StreamingCompleted), Message = filePath });
|
||||
StreamingCompleted?.Invoke(this, filePath);
|
||||
}
|
||||
|
||||
protected void OnFileCreated(DataLayer.LibraryBook libraryBook, string path) => OnFileCreated(libraryBook.Book.AudibleProductId, path);
|
||||
protected void OnFileCreated(string id, string path)
|
||||
{
|
||||
Serilog.Log.Logger.Information("File created {@DebugInfo}", new { Name = nameof(FileCreated), id, path });
|
||||
LibationFileManager.FilePathCache.Insert(id, path);
|
||||
FileCreated?.Invoke(this, (id, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public static class UtilityExtensions
|
||||
{
|
||||
public static (string id, string title, string locale, string account) LogFriendly(this LibraryBook libraryBook)
|
||||
=> (
|
||||
id: libraryBook.Book.AudibleProductId,
|
||||
title: libraryBook.Book.Title,
|
||||
locale: libraryBook.Book.Locale,
|
||||
account: libraryBook.Account.ToMask()
|
||||
);
|
||||
|
||||
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
||||
{
|
||||
var apiExtended = await AudibleUtilities.ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
return apiExtended.Api;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileLiberator) + ".Tests")]
|
||||
@@ -1,12 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="2.0.2.1" />
|
||||
<PackageReference Include="Polly" Version="7.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,52 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
||||
public class FileTemplate
|
||||
{
|
||||
/// <summary>Proposed full file path. May contain optional html-styled template tags. Eg: <name></summary>
|
||||
public string Template { get; }
|
||||
|
||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
||||
public FileTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
|
||||
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /<name>/ => /Bill Gates/</summary>
|
||||
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
|
||||
|
||||
/// <summary>Convenience method</summary>
|
||||
public void AddParameterReplacement(string key, object value)
|
||||
// using .Add() instead of "[key] = value" will make unintended overwriting throw exception
|
||||
=> ParameterReplacements.Add(key, value);
|
||||
|
||||
/// <summary>If set, truncate each parameter replacement to this many characters. Default 50</summary>
|
||||
public int? ParameterMaxSize { get; set; } = 50;
|
||||
|
||||
/// <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 string GetFilePath()
|
||||
{
|
||||
var filename = Template;
|
||||
|
||||
foreach (var r in ParameterReplacements)
|
||||
filename = filename.Replace($"<{formatKey(r.Key)}>", formatValue(r.Value));
|
||||
|
||||
return FileUtility.GetValidFilename(filename, IllegalCharacterReplacements);
|
||||
}
|
||||
|
||||
private static string formatKey(string key)
|
||||
=> key
|
||||
.Replace("<", "")
|
||||
.Replace(">", "");
|
||||
|
||||
private string formatValue(object value)
|
||||
=> ParameterMaxSize.HasValue && ParameterMaxSize.Value > 0
|
||||
? value?.ToString().Truncate(ParameterMaxSize.Value)
|
||||
: value?.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public static class FileUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// "txt" => ".txt"
|
||||
/// <br />".txt" => ".txt"
|
||||
/// <br />null or whitespace => ""
|
||||
/// </summary>
|
||||
public static string GetStandardizedExtension(string extension)
|
||||
=> string.IsNullOrWhiteSpace(extension)
|
||||
? (extension ?? "")?.Trim()
|
||||
: '.' + extension.Trim().Trim('.');
|
||||
|
||||
/// <summary>
|
||||
/// Return position with correct number of leading zeros.
|
||||
/// <br />- 2 of 9 => "2"
|
||||
/// <br />- 2 of 90 => "02"
|
||||
/// <br />- 2 of 900 => "002"
|
||||
/// </summary>
|
||||
/// <param name="position">position in sequence. The 'x' in 'x of y'</param>
|
||||
/// <param name="total">total qty in sequence. The 'y' in 'x of y'</param>
|
||||
public static string GetSequenceFormatted(int position, int total)
|
||||
{
|
||||
ArgumentValidator.EnsureGreaterThan(position, nameof(position), 0);
|
||||
ArgumentValidator.EnsureGreaterThan(total, nameof(total), 0);
|
||||
if (position > total)
|
||||
throw new ArgumentException($"{position} may not be greater than {total}");
|
||||
|
||||
return position.ToString().PadLeft(total.ToString().Length, '0');
|
||||
}
|
||||
|
||||
private const int MAX_FILENAME_LENGTH = 255;
|
||||
private const int MAX_DIRECTORY_LENGTH = 247;
|
||||
|
||||
/// <summary>
|
||||
/// Ensure valid file name path:
|
||||
/// <br/>- remove invalid chars
|
||||
/// <br/>- ensure uniqueness
|
||||
/// <br/>- enforce max file length
|
||||
/// </summary>
|
||||
public static string GetValidFilename(string path, string illegalCharacterReplacements = "")
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
|
||||
// remove invalid chars
|
||||
path = GetSafePath(path, illegalCharacterReplacements);
|
||||
|
||||
// ensure uniqueness and check lengths
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
dir = dir.Truncate(MAX_DIRECTORY_LENGTH);
|
||||
|
||||
var filename = Path.GetFileNameWithoutExtension(path);
|
||||
var fileStem = Path.Combine(dir, filename);
|
||||
|
||||
var extension = Path.GetExtension(path);
|
||||
|
||||
var fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - extension.Length) + extension;
|
||||
|
||||
var i = 0;
|
||||
while (File.Exists(fullfilename))
|
||||
{
|
||||
var increm = $" ({++i})";
|
||||
fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - increm.Length - extension.Length) + increm + extension;
|
||||
}
|
||||
|
||||
return fullfilename;
|
||||
}
|
||||
|
||||
// GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/'
|
||||
|
||||
/// <summary>Use with file name, not full path. Valid path charaters which are invalid file name characters will be replaced: ':', '\\', '/'</summary>
|
||||
public static string GetSafeFileName(string str, string illegalCharacterReplacements = "")
|
||||
=> string.Join(illegalCharacterReplacements ?? "", str.Split(Path.GetInvalidFileNameChars()));
|
||||
|
||||
/// <summary>Use with full path, not file name. Valid path charaters which are invalid file name characters will be retained: '\\', '/'</summary>
|
||||
public static string GetSafePath(string path, string illegalCharacterReplacements = "")
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
|
||||
var invalidChars = Path.GetInvalidPathChars().Union(new[] {
|
||||
'*', '?',
|
||||
// these are weird. If you run Path.GetInvalidPathChars() in C# interactive, these characters are included.
|
||||
// In live code, Path.GetInvalidPathChars() does not include them
|
||||
'"', '<', '>'
|
||||
}).ToArray();
|
||||
|
||||
var fixedPath = string
|
||||
.Join(illegalCharacterReplacements ?? "", path.Split(invalidChars))
|
||||
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
|
||||
// replace all colons except within the first 2 chars
|
||||
var builder = new System.Text.StringBuilder();
|
||||
for (var i = 0; i < fixedPath.Length; i++)
|
||||
{
|
||||
var c = fixedPath[i];
|
||||
if (i >= 2 && c == ':')
|
||||
builder.Append(illegalCharacterReplacements);
|
||||
else
|
||||
builder.Append(c);
|
||||
}
|
||||
fixedPath = builder.ToString();
|
||||
|
||||
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||
while (fixedPath.Contains(dblSeparator))
|
||||
fixedPath = fixedPath.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
|
||||
|
||||
return fixedPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move file.
|
||||
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length
|
||||
/// <br/>- Perform <see cref="SaferMove"/>
|
||||
/// <br/>- Return valid path
|
||||
/// </summary>
|
||||
public static string SaferMoveToValidPath(string source, string destination)
|
||||
{
|
||||
destination = GetValidFilename(destination);
|
||||
SaferMove(source, destination);
|
||||
return destination;
|
||||
}
|
||||
|
||||
private static int maxRetryAttempts { get; } = 3;
|
||||
private static TimeSpan pauseBetweenFailures { get; } = TimeSpan.FromMilliseconds(100);
|
||||
private static RetryPolicy retryPolicy { get; } =
|
||||
Policy
|
||||
.Handle<Exception>()
|
||||
.WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures);
|
||||
|
||||
/// <summary>Delete file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferDelete(string source)
|
||||
=> retryPolicy.Execute(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(source))
|
||||
{
|
||||
File.Delete(source);
|
||||
Serilog.Log.Logger.Information("File successfully deleted", new { source });
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to delete file", new { source });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferMove(string source, string destination)
|
||||
=> retryPolicy.Execute(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(source))
|
||||
{
|
||||
SaferDelete(destination);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination));
|
||||
File.Move(source, destination);
|
||||
Serilog.Log.Logger.Information("File successfully moved", new { source, destination });
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to move file", new { source, destination });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileManager) + ".Tests")]
|
||||
@@ -1,7 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,847 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Downloads, decrypts and repackages content from Hoopla
|
||||
|
||||
.DESCRIPTION
|
||||
Uses a HooplaDigital.com account to download DRM-free copies of ebooks, comics,
|
||||
and/or audiobooks available on the platform. Content that is not already borrowed
|
||||
on the account will be borrowed if slots are available. Content that is not borrowed
|
||||
cannot be downloaded.
|
||||
|
||||
* E-Books are downloaded to epub files (most) or cbz (rare, picture books).
|
||||
* Comic books are downloaded to cbz files.
|
||||
* Audiobooks are downloaded to m4a files. (single file, and very little metadata available
|
||||
from Hoopla, such as chapters)
|
||||
|
||||
.PARAMETER Credential
|
||||
Credential to use for logging into Hoopla site.
|
||||
(Cannot be used with Username and Password parameters)
|
||||
|
||||
.PARAMETER Username
|
||||
Username to use for logging into Hoopla site.
|
||||
(Cannot be used with Credential parameter)
|
||||
|
||||
.PARAMETER Password
|
||||
Password to use for logging into Hoopla site.
|
||||
(Cannot be used with Credential parameter)
|
||||
|
||||
.PARAMETER TitleId
|
||||
Specifies one or more title IDs of content to download.
|
||||
|
||||
.PARAMETER OutputFolder
|
||||
Sets the output folder for downloaded content. Defaults to current directory.
|
||||
|
||||
.PARAMETER PatronId
|
||||
Override default patron id for Hoopla. (This is rarely required as most user accounts are only tied
|
||||
to a single patron).
|
||||
|
||||
.PARAMETER EpubZipBin
|
||||
Specifies path to epubzip binary. Else look for one beside script, or in system path.
|
||||
|
||||
.PARAMETER FfmpegBin
|
||||
Specifies path to ffmpeg binary. Else look for one beside script, or in system path.
|
||||
|
||||
.PARAMETER KeepDecryptedData
|
||||
If set, don't delete the intermediary data after decryption, before final output file.
|
||||
For ebooks, this is xml, images, and the manifest. For comics, it is images. For audiobooks,
|
||||
it is mp4 ts files. This is typically only useful for development or troubleshooting.
|
||||
|
||||
.PARAMETER KeepEncryptedData
|
||||
If set, don't delete the encrypted data as downloaded from Hoopla's servers. This is typically
|
||||
only useful for development or troubleshooting.
|
||||
|
||||
.PARAMETER AllBorrowed
|
||||
This parameter is deprecated. If TitleId is not set, it is implied that all borrowed titles will
|
||||
be downloaded.
|
||||
|
||||
.PARAMETER AudioBookForceSingleFile
|
||||
If set, leave audiobook as single file, as if chapter data is not present.
|
||||
|
||||
.EXAMPLE
|
||||
.\Invoke-HooplaDownload.ps1 123456
|
||||
Downloads Hoopla content with title id 123456
|
||||
|
||||
.NOTES
|
||||
Author: kabutops728 - My Anonamouse
|
||||
Version: 2.9
|
||||
#>
|
||||
|
||||
[CmdletBinding(DefaultParameterSetName='CredentialSingleTitle')]
|
||||
param(
|
||||
[int64[]]
|
||||
$TitleId,
|
||||
|
||||
[Parameter(Mandatory,ParameterSetName='CredentialSingleTitle')]
|
||||
[Management.Automation.PSCredential]
|
||||
$Credential,
|
||||
|
||||
[Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')]
|
||||
[string]
|
||||
$Username,
|
||||
|
||||
[Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')]
|
||||
[string]
|
||||
$Password,
|
||||
|
||||
[ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Container})]
|
||||
[string]$OutputFolder = $PSScriptRoot,
|
||||
|
||||
[int64]$PatronId,
|
||||
|
||||
[string]$EpubZipBin,
|
||||
|
||||
[string]$FfmpegBin,
|
||||
|
||||
[switch]$KeepDecryptedData,
|
||||
|
||||
[switch]$KeepEncryptedData,
|
||||
|
||||
[switch]$AudioBookForceSingleFile,
|
||||
|
||||
# Deprecated
|
||||
[switch]$AllBorrowed
|
||||
)
|
||||
|
||||
$USER_AGENT = 'Hoopla Android/4.27'
|
||||
|
||||
$HEADERS = @{
|
||||
'app' = 'ANDROID'
|
||||
'app-version' = '4.27.1'
|
||||
'device-module' = 'KFKAWI'
|
||||
'device-version' = ''
|
||||
'hoopla-verson' = '4.27.1'
|
||||
'kids-mode' = 'false'
|
||||
'os' = 'ANDROID'
|
||||
'os-version' = '6.0.1'
|
||||
'ws-api' = '2.1'
|
||||
'Host' = 'hoopla-ws.hoopladigital.com'
|
||||
}
|
||||
|
||||
$URL_HOOPLA_WS_BASE = 'https://hoopla-ws.hoopladigital.com'
|
||||
$URL_HOOPLA_LIC_BASE = 'https://hoopla-license2.hoopladigital.com'
|
||||
|
||||
$COMIC_IMAGE_EXTS = @('.jpg','.png','.jpeg','.gif','.bmp','.tif','.tiff')
|
||||
|
||||
enum HooplaKind
|
||||
{
|
||||
EBOOK = 5
|
||||
MUSIC = 6
|
||||
MOVIE = 7
|
||||
AUDIOBOOK = 8
|
||||
TELEVISION = 9
|
||||
COMIC = 10
|
||||
}
|
||||
|
||||
$SUPPORTED_KINDS = @([HooplaKind]::EBOOK, [HooplaKind]::COMIC, [HooplaKind]::AUDIOBOOK)
|
||||
|
||||
Function Connect-Hoopla
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][Management.Automation.PSCredential]$Credential
|
||||
)
|
||||
|
||||
$username = $Credential.UserName
|
||||
$password = $Credential.GetNetworkCredential().Password
|
||||
|
||||
$res = Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/tokens" -Method Post -Headers $HEADERS -UserAgent $USER_AGENT -Body @{username = $username; password = $password}
|
||||
|
||||
if ($res.tokenStatus -ne 'SUCCESS')
|
||||
{
|
||||
throw $res.message
|
||||
}
|
||||
|
||||
$res.token
|
||||
}
|
||||
|
||||
Function Get-HooplaUsers
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Token
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-HooplaTitleInfo
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$TitleId
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/v2/titles/$TitleId" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-HooplaBorrowsRemaining
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$UserId,
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrows-remaining" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-HooplaBorrowedTitles
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$UserId,
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/borrowed-titles" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Invoke-HooplaBorrow
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$UserId,
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$TitleId
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrowed-titles/$TitleId" -Method Post -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Invoke-HooplaZipDownload
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$CircId,
|
||||
[Parameter(Mandatory)][ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Leaf})][string]$OutFile
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
$res = Invoke-WebRequest -Uri "$URL_HOOPLA_WS_BASE/patrons/downloads/$CircId/url" -Method Get -Headers $h -UserAgent $USER_AGENT -UseBasicParsing
|
||||
|
||||
if ($PSVersionTable.PSVersion.Major -ge 6)
|
||||
{
|
||||
Invoke-WebRequest -Uri $res.Headers['Location'][0] -Method Get -UseBasicParsing -OutFile $OutFile
|
||||
}
|
||||
else
|
||||
{
|
||||
Invoke-WebRequest -Uri $res.Headers['Location'] -Method Get -UseBasicParsing -OutFile $OutFile
|
||||
}
|
||||
}
|
||||
|
||||
Function Get-HooplaKey
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$CircId
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_LIC_BASE/downloads/$CircId/key" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-FileKeyKey
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$CircId,
|
||||
[Parameter(Mandatory)][DateTime]$Due,
|
||||
[Parameter(Mandatory)][int64]$PatronId
|
||||
)
|
||||
|
||||
$combined = '{0:yyyyMMddHHmmss}:{1}:{2}' -f $Due, $PatronId, $CircId
|
||||
|
||||
[Security.Cryptography.HashAlgorithm]::Create('SHA1').ComputeHash([Text.Encoding]::UTF8.GetBytes($combined)) | Select-Object -First 16
|
||||
}
|
||||
|
||||
Function Decrypt-FileKey
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][byte[]]$FileKeyEnc,
|
||||
[Parameter(Mandatory)][byte[]]$FileKeyKey
|
||||
)
|
||||
|
||||
$aesManaged = New-Object "System.Security.Cryptography.AesManaged"
|
||||
$aesManaged.Mode = [Security.Cryptography.CipherMode]::ECB
|
||||
$aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7
|
||||
$aesManaged.BlockSize = 128
|
||||
$aesManaged.KeySize = 128
|
||||
$aesManaged.Key = $FileKeyKey
|
||||
|
||||
$decryptor = $aesManaged.CreateDecryptor();
|
||||
|
||||
$unencryptedData = $decryptor.TransformFinalBlock($FileKeyEnc, 0, $FileKeyEnc.Length);
|
||||
$aesManaged.Dispose()
|
||||
|
||||
$unencryptedData
|
||||
}
|
||||
|
||||
Function Decrypt-File
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][byte[]]$FileKey,
|
||||
[Parameter(Mandatory)][string]$MediaKey,
|
||||
[Parameter(Mandatory)][string]$InputFileName,
|
||||
[Parameter(Mandatory)][string]$OutputFileName
|
||||
)
|
||||
|
||||
$aesManaged = New-Object "System.Security.Cryptography.AesManaged"
|
||||
$aesManaged.Mode = [Security.Cryptography.CipherMode]::CBC
|
||||
$aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7
|
||||
$aesManaged.BlockSize = 128
|
||||
$aesManaged.KeySize = 256
|
||||
$aesManaged.Key = $FileKey
|
||||
$aesManaged.IV = [Text.Encoding]::UTF8.GetBytes($MediaKey) | Select-Object -First 16
|
||||
|
||||
|
||||
$fileStreamReader = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $InputFileName, ([IO.FileMode]::Open), ([IO.FileShare]::Read)
|
||||
$fileStreamWriter = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $OutputFileName, ([IO.FileMode]::Create)
|
||||
|
||||
$FileStreamReader.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null
|
||||
|
||||
$decryptor = $aesManaged.CreateDecryptor()
|
||||
$cryptoStream = New-Object -TypeName 'System.Security.Cryptography.CryptoStream' -ArgumentList $fileStreamWriter, $decryptor, ([Security.Cryptography.CryptoStreamMode]::Write)
|
||||
$fileStreamReader.CopyTo($cryptoStream)
|
||||
|
||||
$cryptoStream.FlushFinalBlock()
|
||||
$cryptoStream.Close()
|
||||
$fileStreamReader.Close()
|
||||
$fileStreamWriter.Close()
|
||||
|
||||
$aesManaged.Dispose()
|
||||
}
|
||||
|
||||
Function Test-Mp4
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[Alias('LiteralPath')]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$fileStream = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $Path, ([IO.FileMode]::Open), ([IO.FileShare]::Read)
|
||||
$fileReader = New-Object -TypeName 'System.IO.BinaryReader' -ArgumentList $fileStream -ErrorAction Stop
|
||||
$head = $fileReader.ReadBytes(8)
|
||||
|
||||
$fileReader.Dispose()
|
||||
$fileStream.Dispose()
|
||||
|
||||
return [Text.Encoding]::ASCII.GetString(($head | Select-Object -Skip 4)) -eq 'ftyp'
|
||||
}
|
||||
|
||||
Function Remove-InvalidFileNameChars
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory,Position=0,
|
||||
ValueFromPipeline=$true,
|
||||
ValueFromPipelineByPropertyName=$true)]
|
||||
[String]$Name
|
||||
)
|
||||
|
||||
$invalidChars = [IO.Path]::GetInvalidFileNameChars() -join ''
|
||||
$re = "[{0}]" -f [RegEx]::Escape($invalidChars)
|
||||
$Name -replace $re, '_'
|
||||
}
|
||||
|
||||
Function Convert-HooplaDecryptedToEpub
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InputFolder,
|
||||
[Parameter(Mandatory)][string]$OutFolder
|
||||
)
|
||||
|
||||
$container = [xml](Get-Content -LiteralPath (Join-Path -Path $InputFolder -ChildPath 'META-INF\container.xml') -Raw)
|
||||
$rootFile = $container.container.rootfiles.rootfile | Select-Object -ExpandProperty Full-Path
|
||||
$contentFile = (Join-Path -Path $InputFolder -ChildPath $rootFile).Trim()
|
||||
$contentRoot = Get-Item -LiteralPath $contentFile | Select-Object -ExpandProperty Directory
|
||||
$content = [xml](Get-Content -LiteralPath $contentFile)
|
||||
|
||||
$fileList = $content.package.manifest.item | Select-Object -ExpandProperty href | ForEach-Object -Process { (Join-Path -Path $contentRoot -ChildPath ([Web.HttpUtility]::UrlDecode($_))).Trim() }
|
||||
$fileList += $contentFile
|
||||
$fileList = $fileList | Sort-Object -Unique
|
||||
|
||||
$title = $content.package.metadata.title | Select-Object -First 1
|
||||
if ($title.GetType() -ne [String])
|
||||
{
|
||||
$title = $content.package.metadata.title | Select-Object -First 1 | Select-Object -ExpandProperty '#text'
|
||||
}
|
||||
|
||||
$author = $content.package.metadata.creator | Select-Object -First 1
|
||||
if ($author.GetType() -ne [String])
|
||||
{
|
||||
$author = $content.package.metadata.creator | Select-Object -First 1 | Select-Object -ExpandProperty '#text'
|
||||
}
|
||||
|
||||
# Usually, content root is a subfolder of the input folder. But sometimes, they are the same. Make sure we declutter the input root if they differ, and always keep the mimetype file.
|
||||
$mimeTypeFile = Join-Path -Path $InputFolder -ChildPath 'mimetype'
|
||||
|
||||
$extra = @(Get-ChildItem -LiteralPath $contentRoot -File -Recurse | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) })
|
||||
$extra += Get-ChildItem -LiteralPath $InputFolder -File | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) }
|
||||
|
||||
$extra = $extra | Sort-Object -Property FullName -Unique
|
||||
$extra | Remove-Item
|
||||
|
||||
$containerXmlFolder = Join-Path -Path $contentRoot.FullName -ChildPath 'META-INF'
|
||||
$containerXmlPath = Join-Path -Path $containerXmlFolder -ChildPath 'container.xml'
|
||||
if (!(Test-Path -LiteralPath $containerXmlPath -PathType Leaf))
|
||||
{
|
||||
New-Item -Path $containerXmlFolder -ItemType Directory -Force | Out-Null
|
||||
$xml = @"
|
||||
<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
|
||||
"@
|
||||
$xml | Out-File -LiteralPath $containerXmlPath -Encoding ascii
|
||||
}
|
||||
|
||||
$finalFile = ('{0} - {1}.epub' -f $title, $author) | Remove-InvalidFileNameChars
|
||||
|
||||
Push-Location
|
||||
Set-Location -LiteralPath $InputFolder
|
||||
|
||||
$finalFileFullPath = (Join-Path -Path $OutFolder -ChildPath $finalFile)
|
||||
if ($VerbosePreference -eq 'Continue')
|
||||
{
|
||||
& $EpubZipBin $finalFileFullPath
|
||||
}
|
||||
else
|
||||
{
|
||||
& $EpubZipBin $finalFileFullPath >$null 2>&1
|
||||
}
|
||||
Pop-Location
|
||||
|
||||
Get-Item -LiteralPath $finalFileFullPath
|
||||
}
|
||||
|
||||
Function Convert-HooplaDecryptedToCbz
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InputFolder,
|
||||
[Parameter(Mandatory)][string]$OutFolder,
|
||||
[Parameter(Mandatory)][string]$Name
|
||||
)
|
||||
|
||||
$fileName = $Name | Remove-InvalidFileNameChars
|
||||
$tempOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.zip"
|
||||
$finalOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.cbz"
|
||||
|
||||
Compress-Archive -Path (
|
||||
Get-ChildItem -LiteralPath $InputFolder | Where-Object -FilterScript { $_.Extension -in $COMIC_IMAGE_EXTS } | Select-Object -ExpandProperty FullName
|
||||
) -CompressionLevel Fastest -DestinationPath $tempOutFile
|
||||
|
||||
Rename-Item -LiteralPath $tempOutFile -NewName $finalOutFile
|
||||
|
||||
Get-Item $finalOutFile
|
||||
}
|
||||
|
||||
Function Convert-HooplaDecryptedToM4a
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InputFolder,
|
||||
[Parameter(Mandatory)][string]$OutFolder,
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][string]$Title,
|
||||
[Parameter(Mandatory)][string]$Author,
|
||||
[Parameter(Mandatory)][int]$Year,
|
||||
[string]$Subtitle,
|
||||
[object]$ChapterData
|
||||
|
||||
)
|
||||
|
||||
if ($Author)
|
||||
{
|
||||
$baseFileName = ('{0} - {1}' -f $Name, $Author) | Remove-InvalidFileNameChars
|
||||
}
|
||||
else
|
||||
{
|
||||
$baseFileName = $Name | Remove-InvalidFileNameChars
|
||||
}
|
||||
|
||||
$finalOutFile = Join-Path -Path $OutFolder -ChildPath ('{0}.m4a' -f $baseFileName)
|
||||
$inFile = Get-ChildItem -LiteralPath $InputFolder -Filter '*.m3u8' | Select-Object -First 1 | Select-Object -ExpandProperty FullName
|
||||
|
||||
Push-Location
|
||||
Set-Location $InputFolder
|
||||
$ffArgs = @(
|
||||
'-y',
|
||||
'-i', $infile,
|
||||
'-metadata', ('title="{0}"' -f $Title),
|
||||
'-metadata', ('year="{0}"' -f $Year),
|
||||
'-metadata', ('author="{0}"' -f $Author),
|
||||
'-metadata', 'genre="Audiobook"'
|
||||
)
|
||||
|
||||
if ($Subtitle)
|
||||
{
|
||||
$ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle)
|
||||
}
|
||||
|
||||
$ffArgs += @(
|
||||
'-c:a', 'copy',
|
||||
$finalOutFile
|
||||
)
|
||||
|
||||
if ($VerbosePreference -eq 'Continue')
|
||||
{
|
||||
& $FfmpegBin @ffArgs
|
||||
#& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile
|
||||
}
|
||||
else
|
||||
{
|
||||
& $FfmpegBin @ffArgs >$null 2>&1
|
||||
#& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile >$null 2>&1
|
||||
}
|
||||
|
||||
if ($ChapterData -and (!$AudioBookForceSingleFile))
|
||||
{
|
||||
$outDir = New-Item -Path (Join-Path -Path $OutFolder -ChildPath $baseFileName) -ItemType Directory
|
||||
$chapterCount = $ChapterData | Select-Object -ExpandProperty chapter | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum
|
||||
$ChapterData | ForEach-Object -Process {
|
||||
$ffArgs = @(
|
||||
'-y',
|
||||
'-i', $finalOutFile,
|
||||
'-ss', $_.start,
|
||||
'-t', $_.duration,
|
||||
'-metadata', ('title="{0}"' -f $_.title),
|
||||
'-metadata', ('album="{0}"' -f $Title),
|
||||
'-metadata', ('year="{0}"' -f $Year),
|
||||
'-metadata', ('author="{0}"' -f $Author),
|
||||
'-metadata', 'genre="Audiobook"'
|
||||
'-metadata', ('track={0}/{1}' -f $_.ordinal, $chapterCount)
|
||||
)
|
||||
|
||||
if ($Subtitle)
|
||||
{
|
||||
$ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle)
|
||||
}
|
||||
|
||||
$ffArgs += @(
|
||||
'-c', 'copy',
|
||||
(Join-Path -Path $outDir.FullName -ChildPath ('{0} - {1} - {2}.m4a' -f $baseFileName, $_.ordinal, ($_.title | Remove-InvalidFileNameChars)))
|
||||
)
|
||||
|
||||
if ($VerbosePreference -eq 'Continue')
|
||||
{
|
||||
& $FfmpegBin @ffArgs
|
||||
}
|
||||
else
|
||||
{
|
||||
& $FfmpegBin @ffArgs >$null 2>&1
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item $finalOutFile
|
||||
$finalOutFile = $outDir
|
||||
}
|
||||
|
||||
Pop-Location
|
||||
|
||||
Get-Item $finalOutFile
|
||||
}
|
||||
|
||||
if (!$Credential)
|
||||
{
|
||||
$ssPassword = ConvertTo-SecureString $Password -AsPlainText -Force
|
||||
$Credential = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList $Username, $ssPassword
|
||||
}
|
||||
|
||||
if ((!$AllBorrowed) -and ($null -eq $TitleId))
|
||||
{
|
||||
Write-Warning 'No -TitleId specified. All currently-borrowed titles will be downloaded.'
|
||||
$AllBorrowed = $true
|
||||
}
|
||||
|
||||
$AppExtension = ''
|
||||
if (($PSVersionTable.PSVersion -lt '6.0') -or $IsWindows)
|
||||
{
|
||||
$AppExtension = '.exe'
|
||||
}
|
||||
|
||||
$cmd = ''
|
||||
if ($EpubZipBin)
|
||||
{
|
||||
$cmd = Get-Command -Name $EpubZipBin -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "Epubzip binary specified was not found ($EpubZipBin). Will try to use alternate version if available."
|
||||
}
|
||||
}
|
||||
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "epubzip$AppExtension") -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name "epubzip$AppExtension" -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "Epubzip binary not found ($EpubZipBin). If you are downloading ebooks (rather than comics or audiobooks), you may wish to download the binary from https://github.com/dino-/epub-tools/releases, specify a different path with -EpubZipBin, or specify -KeepDecryptedData so that you can manually pack afterward."
|
||||
}
|
||||
}
|
||||
|
||||
$EpubZipBin = $cmd.Source
|
||||
}
|
||||
|
||||
Write-Verbose ('Using epubzip bin: "{0}"' -f $EpubZipBin)
|
||||
|
||||
$cmd = ''
|
||||
if ($FfmpegBin)
|
||||
{
|
||||
$cmd = Get-Command -Name $FfmpegBin -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "FFMpeg binary specified was not found ($FfmpegBin). Will try to use alternate version if available."
|
||||
}
|
||||
}
|
||||
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "ffmpeg$AppExtension") -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name "ffmpeg$AppExtension" -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "FFmpeg binary not found. If you are downloading audiobooks (rather than ebooks or comics), you may wish to download the binary from https://ffmpeg.zeranoe.com/builds/, specify a different path with -FfmpegBin, or specify -KeepDecryptedData so that you can manually convert afterward."
|
||||
}
|
||||
}
|
||||
|
||||
$FfmpegBin = $cmd.Source
|
||||
}
|
||||
|
||||
Write-Verbose ('Using ffpmeg bin: "{0}"' -f $FfmpegBin)
|
||||
|
||||
if (!(Test-Path -LiteralPath $OutputFolder -PathType Container))
|
||||
{
|
||||
Write-Warning "Output folder doesn't exist. Creating."
|
||||
New-Item -Path $OutputFolder -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$OutputFolder = Get-Item -LiteralPath $OutputFolder | Select-Object -ExpandProperty $_.FullName
|
||||
|
||||
$token = Connect-Hoopla -Credential $Credential
|
||||
Write-Verbose "Logged in. Received token $($token -replace '\-.*', '-****-****-****-************')"
|
||||
|
||||
$users = Get-HooplaUsers $token
|
||||
Write-Verbose "Found $($users.patrons.Count) patrons"
|
||||
|
||||
$userId = $users.id
|
||||
if (!$PatronId)
|
||||
{
|
||||
if ($users.patrons.Count -eq 0)
|
||||
{
|
||||
throw "No patrons found on account. Account may not be correctly set up with library."
|
||||
}
|
||||
elseif ($users.patrons.Count -gt 1)
|
||||
{
|
||||
Write-Warning (
|
||||
"Multiple patrons found on account. Using first one, {0} ({1}). You can specify -PatronId to override" -f $users.patrons[0].id, $users.patrons[0].libraryName
|
||||
)
|
||||
}
|
||||
|
||||
$PatronId = $users.patrons[0].id
|
||||
Write-Verbose "Using PatronId $PatronId"
|
||||
}
|
||||
|
||||
$borrowedRaw = Get-HooplaBorrowedTitles -Token $token -UserId $userId -PatronId $PatronId
|
||||
$borrowed = $borrowedRaw | Where-Object -FilterScript { $_.kind.id -in $SUPPORTED_KINDS }
|
||||
Write-Verbose "Found $($borrowed.Count) ($($borrowedRaw.Count)) titles already borrowed"
|
||||
$toDownload = @()
|
||||
|
||||
if ($AllBorrowed)
|
||||
{
|
||||
$toDownload = $borrowed
|
||||
}
|
||||
else
|
||||
{
|
||||
$toDownload = $borrowed | Where-Object -FilterScript { $_.id -in $TitleId }
|
||||
|
||||
$allBorrowedTitles = $borrowed | Select-Object -ExpandProperty id
|
||||
$toBorrow = $TitleId | Where-Object -FilterScript { $_ -notin $allBorrowedTitles }
|
||||
|
||||
if ($toBorrow)
|
||||
{
|
||||
$borrowsRemainingData = Get-HooplaBorrowsRemaining -UserId $userId -PatronId $PatronId -Token $token
|
||||
Write-Host $borrowsRemainingData.borrowsRemainingMessage
|
||||
|
||||
$borrowsRemaining = $borrowsRemainingData.borrowsRemaining
|
||||
|
||||
$toBorrow | ForEach-Object -Process {
|
||||
Write-Host "Title $_ is not already borrowed or is not a supported kind. Looking up data about it."
|
||||
$titleInfo = Get-HooplaTitleInfo -PatronId $PatronId -Token $token -TitleId $_
|
||||
if ($titleInfo.kind.id -in $SUPPORTED_KINDS)
|
||||
{
|
||||
if ((--$borrowsRemaining) -le 0)
|
||||
{
|
||||
Write-Warning "Title $_ ($($titleInfo.Title)) not borrowed already, but we're out of remaining borrows allowed. Skipping..."
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host "Borrowing title $_ ($($titleInfo.Title))..."
|
||||
$res = Invoke-HooplaBorrow -UserId $userId -PatronId $PatronId -Token $token -TitleId $titleInfo.id
|
||||
Write-Host "Response: $($res.message)"
|
||||
$newToDownload = $res.titles | Where-Object -FilterScript { $_.id -eq $titleInfo.id }
|
||||
if ($newToDownload)
|
||||
{
|
||||
$toDownload += $newToDownload
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Warning "Failed to borrow title $_ ($($titleInfo.Title))..."
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Warning "Title $_ is not a supported kind ($($titleInfo.kind.name)). Skipping..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tempFolder = [IO.Path]::GetTempPath()
|
||||
|
||||
$now = Get-Date
|
||||
|
||||
$toDownload | ForEach-Object -Process {
|
||||
$info = $_
|
||||
$contentKind = [HooplaKind]$_.kind.id
|
||||
if ($_.contents.mediaType)
|
||||
{
|
||||
$contentKind = [HooplaKind]$_.contents.mediaType
|
||||
}
|
||||
$contents = $info.contents
|
||||
$circId = $contents.circId
|
||||
$mediaKey = $contents.mediaKey
|
||||
$dueUnix = [Math]::Truncate($info.contents.due / 1000)
|
||||
$due = (New-Object DateTime 1970, 1, 1, 0, 0, 0, ([DateTimeKind]::Utc)).AddSeconds($dueUnix)
|
||||
|
||||
$circFileName = (Join-Path -Path $tempFolder -ChildPath "$($circId).zip")
|
||||
|
||||
Invoke-HooplaZipDownload -PatronId $patronId -Token $token -CircId $circId -OutFile $circFileName
|
||||
$keyData = Get-HooplaKey -PatronId $patronId -Token $token -CircId $circId
|
||||
|
||||
$fileKeyKey = Get-FileKeyKey -CircId $circId -Due $due -PatronId $patronId
|
||||
$fileKey = Decrypt-FileKey -FileKeyEnc ([Convert]::FromBase64String($keyData."$mediaKey")) -FileKeyKey $fileKeyKey
|
||||
|
||||
$encDir = Join-Path -Path $tempFolder -ChildPath ('enc-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now)
|
||||
New-Item -Path $encDir -ItemType Directory | Out-Null
|
||||
Expand-Archive -LiteralPath $circFileName -DestinationPath $encDir
|
||||
|
||||
Remove-Item -LiteralPath $circFileName
|
||||
|
||||
$decDir = Join-Path -Path $tempFolder -ChildPath ('dec-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now)
|
||||
New-Item -Path $decDir -ItemType Directory | Out-Null
|
||||
|
||||
$activity = 'Decrypting Content ({0})' -f $_.title
|
||||
Write-Progress -Activity $activity -PercentComplete 0
|
||||
$zipFiles = Get-ChildItem $encDir -Recurse -File
|
||||
$decDone = 0
|
||||
$decTotal = $zipFiles.Count
|
||||
$zipFiles | ForEach-Object -Process {
|
||||
$outFile = $_.FullName.Replace($encDir, $decDir)
|
||||
$outDir = $_.DirectoryName.Replace($encDir, $decDir)
|
||||
|
||||
if (!(Test-Path -LiteralPath $outDir))
|
||||
{
|
||||
New-Item -Path $outDir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
if (($contentKind -eq [HooplaKind]::AUDIOBOOK) -and ($_.Extension -eq '.m3u8'))
|
||||
{
|
||||
$lines = Get-Content -LiteralPath $_.FullName | Where-Object -FilterScript {$_ -notmatch '^#EXT-X-KEY'}
|
||||
# Out-File doesn't support utf8 w/o BOM
|
||||
[IO.File]::WriteAllLines($outFile, $lines)
|
||||
return
|
||||
}
|
||||
|
||||
if ($_.Length)
|
||||
{
|
||||
# Hack. Some ebooks contain audio files that download as unencrypted
|
||||
if (($_.Extension -eq '.m4a') -and (Test-Mp4 -LiteralPath $_.FullName))
|
||||
{
|
||||
Write-Verbose -Message ('Coping unencrypted {0}' -f $_.FullName)
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $outFile
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Verbose -Message ('Decrypting {0}' -f $_.FullName)
|
||||
Decrypt-File -FileKey $fileKey -MediaKey $mediaKey -InputFileName $_.FullName -OutputFileName $outFile
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Verbose -Message ('Writing empty file {0}' -f $_.FullName)
|
||||
'' | Out-File -LiteralPath $outFile
|
||||
}
|
||||
|
||||
Write-Progress -Activity $activity -PercentComplete ((++$decDone) / $decTotal * 100)
|
||||
}
|
||||
Write-Progress -Activity $activity -Completed
|
||||
|
||||
switch ($contentKind)
|
||||
{
|
||||
([HooplaKind]::EBOOK) {
|
||||
Convert-HooplaDecryptedToEpub -InputFolder $decDir -OutFolder $OutputFolder
|
||||
}
|
||||
|
||||
([HooplaKind]::COMIC) {
|
||||
$title = $contents.title
|
||||
$subtitle = $contents.subtitle
|
||||
$name = $title
|
||||
if ($subtitle) {
|
||||
$name += ", $subtitle"
|
||||
}
|
||||
Convert-HooplaDecryptedToCbz -InputFolder $decDir -OutFolder $OutputFolder -Name $name
|
||||
}
|
||||
|
||||
([HooplaKind]::AUDIOBOOK) {
|
||||
Convert-HooplaDecryptedToM4a -InputFolder $decDir -OutFolder $OutputFolder -Name $info.title -Title $info.title `
|
||||
-Year $info.year -Author $info.artist.name -Subtitle $contents.subtitle -ChapterData $contents.chapters
|
||||
}
|
||||
}
|
||||
|
||||
if (!$KeepDecryptedData)
|
||||
{
|
||||
Remove-Item -LiteralPath $decDir -Recurse
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host ('Decrypted data for {0} ({1}) stored in {2}' -f $_.id, $_.title, $decDir)
|
||||
}
|
||||
|
||||
if (!$KeepEncryptedData)
|
||||
{
|
||||
Remove-Item -LiteralPath $encDir -Recurse
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
From a Libation user about possibility of integrating Hoopla:
|
||||
|
||||
I have a powershell script. I didn't write it, and neither did the person that gave it to me. It works most of the time (98%). Some titles, it doesn't play well with, but does allow to keep the downloaded data, whether the decrypt was successful, or not, and then you can mess with the data, from there.
|
||||
|
||||
If you run the script with no parameters, then all the books in your library will download, and decrypt into the same directory as the script, into a folder named Completed.
|
||||
|
||||
If you run the script with the command:
|
||||
'.\HooplaDownloader.newer.ps1 -KeepDecryptedData'
|
||||
then it will, and will notify you, when complete, where it was stored.
|
||||
|
||||
There is a parameter to download a specific titleID#, whether it's in your library, or not, but I've not played with it that far, as the method to accomplish it still reserves it to your library, and then proceeds as normal. I can tell you, if it's a "trial and error concern", the title will not be removed from your library, after you run the script, whether it succeeds or not. So, if it fails, you can retry, or try the -KeepDecryptedData option. I received no documentation for it, which is why I'm telling you as much as I know about using it.
|
||||
|
||||
[ see HooplaDownloader.newer.ps1 ]
|
||||
@@ -1,9 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Hoopla
|
||||
{
|
||||
public class temp
|
||||
{
|
||||
// placeholder
|
||||
}
|
||||
}
|
||||
BIN
Images/Plus Minus.psd
Normal file
BIN
Images/Stoplight with pdf.psd
Normal file
BIN
Images/Stoplight.psd
Normal file
32
Images/libation_cheers.svg
Normal file
@@ -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 |
28
Images/libation_glass.svg
Normal file
@@ -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 |
30
Images/libation_hangover.svg
Normal file
@@ -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 |