mirror of
https://github.com/rmcrackan/Libation.git
synced 2025-12-24 06:28:02 -05:00
Compare commits
597 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5ad1e45c65 | ||
|
|
9fe95bbddc | ||
|
|
aecc54401d |
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
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
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.
|
||||
3
.gitignore
vendored
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
|
||||
6
.releaseindex.json
Normal file
6
.releaseindex.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"WindowsClassic": "Libation\\.\\d+\\.\\d+\\.\\d+-win-classic\\.zip",
|
||||
"WindowsAvalonia":"Libation\\.\\d+\\.\\d+\\.\\d+-win-chardonnay\\.zip",
|
||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay",
|
||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macos-chardonnay"
|
||||
}
|
||||
@@ -27,7 +27,7 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
|
||||
|
||||
### Linux and Mac
|
||||
|
||||
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592))
|
||||
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592), [Linux and WINE](https://github.com/rmcrackan/Libation/issues/28#issuecomment-1161111014))
|
||||
|
||||
### Settings
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ Libation's advanced searching is built on the powerful Lucene search engine. Sim
|
||||
* 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 
|
||||
* 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.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- [Getting started](Documentation/GettingStarted.md)
|
||||
- [Download Libation](Documentation/GettingStarted.md#download-libation-1)
|
||||
- [Installation](Documentation/GettingStarted.md#installation)
|
||||
- [Installation on Ubuntu](Source/LibationAvalonia/README.md)
|
||||
- [Create Accounts](Documentation/GettingStarted.md#create-accounts)
|
||||
- [Import your library](Documentation/GettingStarted.md#import-your-library)
|
||||
- [Download your books -- DRM-free!](Documentation/GettingStarted.md#download-your-books----drm-free)
|
||||
@@ -66,4 +67,4 @@
|
||||
|
||||
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk.
|
||||
|
||||
I made this for myself and I want to share it with the great programming and audible/audiobook communiites which have been so generous with their time and help.
|
||||
I made this for myself and I want to share it with the great programming and audible/audiobook communities which have been so generous with their time and help.
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean" Version="0.4.6" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="0.2.7" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean.Codecs" Version="0.2.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
@@ -10,8 +11,8 @@ namespace AaxDecrypter
|
||||
|
||||
protected AaxFile AaxFile;
|
||||
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, DownloadOptions dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic) { }
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||
|
||||
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
||||
public override void SetCoverArt(byte[] coverArt)
|
||||
@@ -33,27 +34,12 @@ namespace AaxDecrypter
|
||||
|
||||
//Finishing configuring lame encoder.
|
||||
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||
{
|
||||
double bitrateMultiple = 1;
|
||||
MpegUtil.ConfigureLameOptions(
|
||||
AaxFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.Downsample,
|
||||
DownloadOptions.MatchSourceBitrate);
|
||||
|
||||
if (AaxFile.AudioChannels == 2)
|
||||
{
|
||||
if (DownloadOptions.Downsample)
|
||||
bitrateMultiple = 0.5;
|
||||
else
|
||||
DownloadOptions.LameConfig.Mode = NAudio.Lame.MPEGMode.Stereo;
|
||||
}
|
||||
|
||||
if (DownloadOptions.MatchSourceBitrate)
|
||||
{
|
||||
int kbps = (int)(AaxFile.AverageBitrate * bitrateMultiple / 1024);
|
||||
|
||||
if (DownloadOptions.LameConfig.VBR is null)
|
||||
DownloadOptions.LameConfig.BitRate = kbps;
|
||||
else if (DownloadOptions.LameConfig.VBR == NAudio.Lame.VBRMode.ABR)
|
||||
DownloadOptions.LameConfig.ABRRateKbps = kbps;
|
||||
}
|
||||
}
|
||||
|
||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
||||
@@ -109,10 +95,11 @@ namespace AaxDecrypter
|
||||
});
|
||||
}
|
||||
|
||||
public override void Cancel()
|
||||
public override async Task CancelAsync()
|
||||
{
|
||||
IsCanceled = true;
|
||||
AaxFile?.Cancel();
|
||||
if (AaxFile != null)
|
||||
await AaxFile.CancelAsync();
|
||||
AaxFile?.Dispose();
|
||||
CloseInputFileStream();
|
||||
}
|
||||
|
||||
@@ -2,38 +2,68 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
|
||||
private Func<MultiConvertFileProperties, string> multipartFileNameCallback { get; }
|
||||
|
||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
||||
private List<string> multiPartFilePaths { get; } = new List<string>();
|
||||
|
||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, DownloadOptions dlLic,
|
||||
Func<MultiConvertFileProperties, string> multipartFileNameCallback = null)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
{
|
||||
Steps = new StepSequence
|
||||
{
|
||||
Name = "Download and Convert Aaxc To " + DownloadOptions.OutputFormat,
|
||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||
|
||||
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
|
||||
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsMultipleFilesPerChapter,
|
||||
["Step 3: Cleanup"] = Step_Cleanup,
|
||||
};
|
||||
this.multipartFileNameCallback = multipartFileNameCallback ?? MultiConvertFileProperties.DefaultMultipartFilename;
|
||||
}
|
||||
public override async Task<bool> RunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
|
||||
/*
|
||||
//Step 1
|
||||
Serilog.Log.Information("Begin Get Aaxc Metadata");
|
||||
if (await Task.Run(Step_GetMetadata))
|
||||
Serilog.Log.Information("Completed Get Aaxc Metadata");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Get Aaxc Metadata");
|
||||
return false;
|
||||
}
|
||||
|
||||
//Step 2
|
||||
Serilog.Log.Information("Begin Download Decrypted Audiobook");
|
||||
if (await Step_DownloadAudiobookAsMultipleFilesPerChapter())
|
||||
Serilog.Log.Information("Completed Download Decrypted Audiobook");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Download Decrypted Audiobook");
|
||||
return false;
|
||||
}
|
||||
|
||||
//Step 3
|
||||
Serilog.Log.Information("Begin Cleanup");
|
||||
if (await Task.Run(Step_Cleanup))
|
||||
Serilog.Log.Information("Completed Cleanup");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Cleanup");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
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.
|
||||
@@ -56,86 +86,104 @@ The book will be split into the following files:
|
||||
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();
|
||||
*/
|
||||
private async Task<bool> Step_DownloadAudiobookAsMultipleFilesPerChapter()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
|
||||
var chapters = DownloadOptions.ChapterInfo.Chapters.ToList();
|
||||
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
||||
|
||||
// Ensure split files are at least minChapterLength in duration.
|
||||
var splitChapters = new ChapterInfo(DownloadOptions.ChapterInfo.StartOffset);
|
||||
// Ensure split files are at least minChapterLength in duration.
|
||||
var splitChapters = new ChapterInfo(DownloadOptions.ChapterInfo.StartOffset);
|
||||
|
||||
var runningTotal = TimeSpan.Zero;
|
||||
string title = "";
|
||||
var runningTotal = TimeSpan.Zero;
|
||||
string title = "";
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
if (runningTotal == TimeSpan.Zero)
|
||||
title = chapters[i].Title;
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
if (runningTotal == TimeSpan.Zero)
|
||||
title = chapters[i].Title;
|
||||
|
||||
runningTotal += chapters[i].Duration;
|
||||
runningTotal += chapters[i].Duration;
|
||||
|
||||
if (runningTotal >= minChapterLength)
|
||||
{
|
||||
splitChapters.AddChapter(title, runningTotal);
|
||||
runningTotal = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
if (runningTotal >= minChapterLength)
|
||||
{
|
||||
splitChapters.AddChapter(title, runningTotal);
|
||||
runningTotal = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
// reset, just in case
|
||||
multiPartFilePaths.Clear();
|
||||
// reset, just in case
|
||||
multiPartFilePaths.Clear();
|
||||
|
||||
ConversionResult result;
|
||||
ConversionResult result;
|
||||
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
|
||||
result = ConvertToMultiMp4a(splitChapters);
|
||||
else
|
||||
result = ConvertToMultiMp3(splitChapters);
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
|
||||
result = await ConvertToMultiMp4a(splitChapters);
|
||||
else
|
||||
result = await ConvertToMultiMp3(splitChapters);
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
|
||||
return result == ConversionResult.NoErrorsDetected;
|
||||
}
|
||||
return result == ConversionResult.NoErrorsDetected;
|
||||
}
|
||||
|
||||
private ConversionResult ConvertToMultiMp4a(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return AaxFile.ConvertToMultiMp4a(splitChapters, newSplitCallback =>
|
||||
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback),
|
||||
DownloadOptions.TrimOutputToChapterLength);
|
||||
}
|
||||
private Task<ConversionResult> ConvertToMultiMp4a(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return AaxFile.ConvertToMultiMp4aAsync
|
||||
(
|
||||
splitChapters,
|
||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
||||
DownloadOptions.TrimOutputToChapterLength
|
||||
);
|
||||
}
|
||||
|
||||
private ConversionResult ConvertToMultiMp3(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return AaxFile.ConvertToMultiMp3(splitChapters, newSplitCallback =>
|
||||
{
|
||||
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback);
|
||||
((NAudio.Lame.LameConfig)newSplitCallback.UserState).ID3.Track = chapterCount.ToString();
|
||||
}, DownloadOptions.LameConfig, DownloadOptions.TrimOutputToChapterLength);
|
||||
}
|
||||
private Task<ConversionResult> ConvertToMultiMp3(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return AaxFile.ConvertToMultiMp3Async
|
||||
(
|
||||
splitChapters,
|
||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.TrimOutputToChapterLength
|
||||
);
|
||||
}
|
||||
|
||||
private void createOutputFileStream(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
var fileName = multipartFileNameCallback(new()
|
||||
{
|
||||
OutputFileName = OutputFileName,
|
||||
PartsPosition = currentChapter,
|
||||
PartsTotal = splitChapters.Count,
|
||||
Title = newSplitCallback?.Chapter?.Title
|
||||
});
|
||||
fileName = FileUtility.GetValidFilename(fileName);
|
||||
|
||||
multiPartFilePaths.Add(fileName);
|
||||
private void Callback(int currentChapter, ChapterInfo splitChapters, NewMP3SplitCallback newSplitCallback)
|
||||
=> Callback(currentChapter, splitChapters, newSplitCallback as NewSplitCallback);
|
||||
|
||||
FileUtility.SaferDelete(fileName);
|
||||
private void Callback(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
MultiConvertFileProperties props = new()
|
||||
{
|
||||
OutputFileName = OutputFileName,
|
||||
PartsPosition = currentChapter,
|
||||
PartsTotal = splitChapters.Count,
|
||||
Title = newSplitCallback?.Chapter?.Title,
|
||||
};
|
||||
newSplitCallback.OutputFile = createOutputFileStream(props);
|
||||
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitleName(props);
|
||||
newSplitCallback.TrackNumber = currentChapter;
|
||||
newSplitCallback.TrackCount = splitChapters.Count;
|
||||
}
|
||||
|
||||
newSplitCallback.OutputFile = File.Open(fileName, FileMode.OpenOrCreate);
|
||||
private FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||
{
|
||||
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
||||
fileName = FileUtility.GetValidFilename(fileName, DownloadOptions.ReplacementCharacters);
|
||||
|
||||
OnFileCreated(fileName);
|
||||
}
|
||||
}
|
||||
multiPartFilePaths.Add(fileName);
|
||||
|
||||
FileUtility.SaferDelete(fileName);
|
||||
|
||||
var file = File.Open(fileName, FileMode.OpenOrCreate);
|
||||
OnFileCreated(fileName);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,113 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||
|
||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, DownloadOptions dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
{
|
||||
Steps = new StepSequence
|
||||
{
|
||||
Name = "Download and Convert Aaxc To " + DownloadOptions.OutputFormat,
|
||||
public override async Task<bool> RunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.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,
|
||||
};
|
||||
}
|
||||
//Step 1
|
||||
Serilog.Log.Information("Begin Step 1: Get Aaxc Metadata");
|
||||
if (await Task.Run(Step_GetMetadata))
|
||||
Serilog.Log.Information("Completed Step 1: Get Aaxc Metadata");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 1: Get Aaxc Metadata");
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool Step_DownloadAudiobookAsSingleFile()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
//Step 2
|
||||
Serilog.Log.Information("Begin Step 2: Download Decrypted Audiobook");
|
||||
if (await Step_DownloadAudiobookAsSingleFile())
|
||||
Serilog.Log.Information("Completed Step 2: Download Decrypted Audiobook");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 2: Download Decrypted Audiobook");
|
||||
return false;
|
||||
}
|
||||
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
//Step 3
|
||||
Serilog.Log.Information("Begin Step 3: Create Cue");
|
||||
if (await Task.Run(Step_CreateCue))
|
||||
Serilog.Log.Information("Completed Step 3: Create Cue");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 3: Create Cue");
|
||||
return false;
|
||||
}
|
||||
|
||||
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
OnFileCreated(OutputFileName);
|
||||
//Step 4
|
||||
Serilog.Log.Information("Begin Step 4: Cleanup");
|
||||
if (await Task.Run(Step_Cleanup))
|
||||
Serilog.Log.Information("Completed Step 4: Cleanup");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 4: Cleanup");
|
||||
return false;
|
||||
}
|
||||
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
var decryptionResult
|
||||
= DownloadOptions.OutputFormat == OutputFormat.M4b
|
||||
? AaxFile.ConvertToMp4a(outputFile, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength)
|
||||
: AaxFile.ConvertToMp3(outputFile, DownloadOptions.LameConfig, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength);
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
DownloadOptions.ChapterInfo = AaxFile.Chapters;
|
||||
private async Task<bool> Step_DownloadAudiobookAsSingleFile()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
|
||||
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
|
||||
if (success)
|
||||
base.OnFileCreated(OutputFileName);
|
||||
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
OnFileCreated(OutputFileName);
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
|
||||
ConversionResult decryptionResult = await decryptAsync(outputFile);
|
||||
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
|
||||
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
|
||||
if (success)
|
||||
base.OnFileCreated(OutputFileName);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private Task<ConversionResult> decryptAsync(Stream outputFile)
|
||||
=> DownloadOptions.OutputFormat == OutputFormat.Mp3 ?
|
||||
AaxFile.ConvertToMp3Async
|
||||
(
|
||||
outputFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.ChapterInfo,
|
||||
DownloadOptions.TrimOutputToChapterLength
|
||||
)
|
||||
: DownloadOptions.FixupFile ?
|
||||
AaxFile.ConvertToMp4aAsync
|
||||
(
|
||||
outputFile,
|
||||
DownloadOptions.ChapterInfo,
|
||||
DownloadOptions.TrimOutputToChapterLength
|
||||
)
|
||||
: AaxFile.ConvertToMp4aAsync(outputFile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Net.Http;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
@@ -20,42 +20,41 @@ namespace AaxDecrypter
|
||||
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
||||
public event EventHandler<string> FileCreated;
|
||||
|
||||
protected bool IsCanceled { get; set; }
|
||||
|
||||
public bool IsCanceled { get; set; }
|
||||
public string TempFilePath { get; }
|
||||
|
||||
protected string OutputFileName { get; private set; }
|
||||
protected DownloadOptions DownloadOptions { get; }
|
||||
protected IDownloadOptions DownloadOptions { 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; }
|
||||
public string TempFilePath { get; }
|
||||
|
||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, DownloadOptions dlLic)
|
||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
{
|
||||
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
|
||||
|
||||
var outDir = Path.GetDirectoryName(OutputFileName);
|
||||
if (!Directory.Exists(outDir))
|
||||
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(outDir)}");
|
||||
Directory.CreateDirectory(outDir);
|
||||
|
||||
if (!Directory.Exists(cacheDirectory))
|
||||
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(cacheDirectory)}");
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
|
||||
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
|
||||
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
||||
|
||||
DownloadOptions = ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
|
||||
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
||||
|
||||
// delete file after validation is complete
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
}
|
||||
}
|
||||
|
||||
public abstract void Cancel();
|
||||
public abstract Task CancelAsync();
|
||||
|
||||
public virtual void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
@@ -63,15 +62,7 @@ namespace AaxDecrypter
|
||||
OnRetrievedCoverArt(coverArt);
|
||||
}
|
||||
|
||||
public bool Run()
|
||||
{
|
||||
var (IsSuccess, Elapsed) = Steps.Run();
|
||||
|
||||
if (!IsSuccess)
|
||||
Serilog.Log.Logger.Error("Conversion failed");
|
||||
|
||||
return IsSuccess;
|
||||
}
|
||||
public abstract Task<bool> RunAsync();
|
||||
|
||||
protected void OnRetrievedTitle(string title)
|
||||
=> RetrievedTitle?.Invoke(this, title);
|
||||
@@ -79,10 +70,8 @@ namespace AaxDecrypter
|
||||
=> 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)
|
||||
@@ -104,7 +93,7 @@ namespace AaxDecrypter
|
||||
try
|
||||
{
|
||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
||||
path = FileUtility.GetValidFilename(path);
|
||||
path = FileUtility.GetValidFilename(path, DownloadOptions.ReplacementCharacters);
|
||||
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
||||
OnFileCreated(path);
|
||||
}
|
||||
@@ -140,11 +129,6 @@ namespace AaxDecrypter
|
||||
else
|
||||
FileUtility.SaferDelete(TempFilePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
}
|
||||
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
using AAXClean;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class DownloadOptions
|
||||
{
|
||||
public string DownloadUrl { get; }
|
||||
public string UserAgent { get; }
|
||||
public string AudibleKey { get; init; }
|
||||
public string AudibleIV { get; init; }
|
||||
public OutputFormat OutputFormat { get; init; }
|
||||
public bool TrimOutputToChapterLength { get; init; }
|
||||
public bool RetainEncryptedFile { get; init; }
|
||||
public bool StripUnabridged { get; init; }
|
||||
public bool CreateCueSheet { get; init; }
|
||||
public ChapterInfo ChapterInfo { get; set; }
|
||||
public NAudio.Lame.LameConfig LameConfig { get; set; }
|
||||
public bool Downsample { get; set; }
|
||||
public bool MatchSourceBitrate { get; set; }
|
||||
|
||||
public DownloadOptions(string downloadUrl, string userAgent)
|
||||
{
|
||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
||||
|
||||
// no null/empty check for key/iv. unencrypted files do not have them
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Source/AaxDecrypter/IDownloadOptions.cs
Normal file
25
Source/AaxDecrypter/IDownloadOptions.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using AAXClean;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public interface IDownloadOptions
|
||||
{
|
||||
FileManager.ReplacementCharacters ReplacementCharacters { get; }
|
||||
string DownloadUrl { get; }
|
||||
string UserAgent { get; }
|
||||
string AudibleKey { get; }
|
||||
string AudibleIV { get; }
|
||||
OutputFormat OutputFormat { get; }
|
||||
bool TrimOutputToChapterLength { get; }
|
||||
bool RetainEncryptedFile { get; }
|
||||
bool StripUnabridged { get; }
|
||||
bool CreateCueSheet { get; }
|
||||
ChapterInfo ChapterInfo { get; }
|
||||
bool FixupFile { get; }
|
||||
NAudio.Lame.LameConfig LameConfig { get; }
|
||||
bool Downsample { get; }
|
||||
bool MatchSourceBitrate { get; }
|
||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
||||
string GetMultipartTitleName(MultiConvertFileProperties props);
|
||||
}
|
||||
}
|
||||
33
Source/AaxDecrypter/MpegUtil.cs
Normal file
33
Source/AaxDecrypter/MpegUtil.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using AAXClean;
|
||||
using NAudio.Lame;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public static class MpegUtil
|
||||
{
|
||||
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
|
||||
{
|
||||
double bitrateMultiple = 1;
|
||||
|
||||
if (mp4File.AudioChannels == 2)
|
||||
{
|
||||
if (downsample)
|
||||
bitrateMultiple = 0.5;
|
||||
else
|
||||
lameConfig.Mode = MPEGMode.Stereo;
|
||||
}
|
||||
|
||||
if (matchSourceBitrate)
|
||||
{
|
||||
int kbps = (int)(mp4File.AverageBitrate * bitrateMultiple / 1024);
|
||||
|
||||
if (lameConfig.VBR is null)
|
||||
lameConfig.BitRate = kbps;
|
||||
else if (lameConfig.VBR == VBRMode.ABR)
|
||||
lameConfig.ABRRateKbps = kbps;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,5 @@ namespace AaxDecrypter
|
||||
public int PartsTotal { get; set; }
|
||||
public string Title { get; set; }
|
||||
|
||||
public static string DefaultMultipartFilename(MultiConvertFileProperties multiConvertFileProperties)
|
||||
{
|
||||
var template = Path.ChangeExtension(multiConvertFileProperties.OutputFileName, null) + " - <ch# 0> - <title>" + Path.GetExtension(multiConvertFileProperties.OutputFileName);
|
||||
|
||||
var fileNamingTemplate = new FileNamingTemplate(template) { IllegalCharacterReplacements = " " };
|
||||
fileNamingTemplate.AddParameterReplacement("ch# 0", FileUtility.GetSequenceFormatted(multiConvertFileProperties.PartsPosition, multiConvertFileProperties.PartsTotal));
|
||||
fileNamingTemplate.AddParameterReplacement("title", multiConvertFileProperties.Title ?? "");
|
||||
|
||||
return fileNamingTemplate.GetFilePath();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,435 +9,457 @@ 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}.");
|
||||
|
||||
//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
|
||||
/// <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 = 32 * 1024;
|
||||
|
||||
//NetworkFileStream will flush all data in _writeFile to disk after every
|
||||
//DATA_FLUSH_SZ bytes are written to the file stream.
|
||||
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
||||
|
||||
#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;
|
||||
try
|
||||
{
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "An error was encountered while saving the download progress to JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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}.");
|
||||
|
||||
//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>
|
||||
/// Download <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];
|
||||
|
||||
try
|
||||
{
|
||||
int bytesRead;
|
||||
do
|
||||
{
|
||||
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 && bytesRead > 0);
|
||||
|
||||
_writeFile.Close();
|
||||
_networkStream.Close();
|
||||
WritePosition = downloadPosition;
|
||||
Update();
|
||||
|
||||
if (!IsCancelled && WritePosition < ContentLength)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
if (WritePosition > ContentLength)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "An error was encountered while downloading {Uri}", Uri);
|
||||
}
|
||||
finally
|
||||
{
|
||||
downloadedPiece.Set();
|
||||
downloadEnded.Set();
|
||||
}
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
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)
|
||||
/// <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)) ;
|
||||
}
|
||||
while (WritePosition < requiredPosition
|
||||
&& hasBegunDownloading
|
||||
&& !IsCancelled
|
||||
&& !downloadEnded.WaitOne(0))
|
||||
{
|
||||
downloadedPiece.WaitOne(100);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Close()
|
||||
{
|
||||
IsCancelled = true;
|
||||
public override void Close()
|
||||
{
|
||||
IsCancelled = true;
|
||||
|
||||
while (downloadEnded is not null && !downloadEnded.WaitOne(1000)) ;
|
||||
while (downloadEnded is not null && !downloadEnded.WaitOne(100)) ;
|
||||
|
||||
_readFile.Close();
|
||||
_writeFile.Close();
|
||||
_networkStream?.Close();
|
||||
Update();
|
||||
}
|
||||
_readFile.Close();
|
||||
_writeFile.Close();
|
||||
_networkStream?.Close();
|
||||
Update();
|
||||
}
|
||||
|
||||
#endregion
|
||||
~NetworkFileStream()
|
||||
{
|
||||
downloadEnded?.Close();
|
||||
downloadedPiece?.Close();
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
~NetworkFileStream()
|
||||
{
|
||||
downloadEnded?.Close();
|
||||
downloadedPiece?.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,69 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
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, DownloadOptions dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic) { }
|
||||
|
||||
public override async Task<bool> RunAsync()
|
||||
{
|
||||
Steps = new StepSequence
|
||||
try
|
||||
{
|
||||
Name = "Download Mp3 Audiobook",
|
||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
|
||||
["Step 1: Get Mp3 Metadata"] = Step_GetMetadata,
|
||||
["Step 2: Download Audiobook"] = Step_DownloadAudiobookAsSingleFile,
|
||||
["Step 3: Create Cue"] = Step_CreateCue,
|
||||
["Step 4: Cleanup"] = Step_Cleanup,
|
||||
};
|
||||
//Step 1
|
||||
Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata");
|
||||
if (await Task.Run(Step_GetMetadata))
|
||||
Serilog.Log.Information("Completed Step 1: Get Mp3 Metadata");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 1: Get Mp3 Metadata");
|
||||
return false;
|
||||
}
|
||||
|
||||
//Step 2
|
||||
Serilog.Log.Information("Begin Step 2: Download Audiobook");
|
||||
if (await Task.Run(Step_DownloadAudiobookAsSingleFile))
|
||||
Serilog.Log.Information("Completed Step 2: Download Audiobook");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 2: Download Audiobook");
|
||||
return false;
|
||||
}
|
||||
|
||||
//Step 3
|
||||
Serilog.Log.Information("Begin Step 3: Cleanup");
|
||||
if (await Task.Run(Step_Cleanup))
|
||||
Serilog.Log.Information("Completed Step 3: Cleanup");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 3: Cleanup");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Cancel()
|
||||
|
||||
public override Task CancelAsync()
|
||||
{
|
||||
IsCanceled = true;
|
||||
CloseInputFileStream();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
@@ -65,8 +101,8 @@ namespace AaxDecrypter
|
||||
}
|
||||
|
||||
CloseInputFileStream();
|
||||
|
||||
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName);
|
||||
|
||||
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName, DownloadOptions.ReplacementCharacters);
|
||||
SetOutputFileName(realOutputFileName);
|
||||
OnFileCreated(realOutputFileName);
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"//": "https://github.com/BalassaMarton/MSBump",
|
||||
BumpRevision: true
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<Version>7.3.0.1</Version>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Version>8.4.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.51.0" />
|
||||
<PackageReference Include="Octokit" Version="2.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -3,18 +3,41 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using ApplicationServices;
|
||||
using AudibleUtilities;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
using Dinah.Core.IO;
|
||||
using Dinah.Core.Logging;
|
||||
using LibationFileManager;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog;
|
||||
|
||||
namespace AppScaffolding
|
||||
{
|
||||
public enum ReleaseIdentifier
|
||||
{
|
||||
None,
|
||||
WindowsClassic,
|
||||
WindowsAvalonia,
|
||||
LinuxAvalonia,
|
||||
MacOSAvalonia
|
||||
}
|
||||
|
||||
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
|
||||
public enum VarietyType { None, Classic, Chardonnay }
|
||||
|
||||
public static class LibationScaffolding
|
||||
{
|
||||
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
|
||||
public static VarietyType Variety
|
||||
=> ReleaseIdentifier == ReleaseIdentifier.WindowsClassic ? VarietyType.Classic
|
||||
: ReleaseIdentifier.In(ReleaseIdentifier.WindowsAvalonia, ReleaseIdentifier.LinuxAvalonia, ReleaseIdentifier.MacOSAvalonia) ? VarietyType.Chardonnay
|
||||
: VarietyType.None;
|
||||
|
||||
public static void SetReleaseIdentifier(ReleaseIdentifier releaseID)
|
||||
=> ReleaseIdentifier = releaseID;
|
||||
|
||||
// AppScaffolding
|
||||
private static Assembly _executingAssembly;
|
||||
private static Assembly ExecutingAssembly
|
||||
@@ -32,11 +55,12 @@ namespace AppScaffolding
|
||||
??= 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()
|
||||
/// <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();
|
||||
// // outdated. kept here as an example of what belongs in this area
|
||||
// // Migrations.migrate_to_v5_2_0__pre_config();
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
@@ -57,13 +81,20 @@ namespace AppScaffolding
|
||||
//
|
||||
|
||||
Migrations.migrate_to_v6_6_9(config);
|
||||
Migrations.migrate_from_7_10_1(config);
|
||||
}
|
||||
|
||||
public static void PopulateMissingConfigValues(Configuration config)
|
||||
{
|
||||
config.InProgress ??= Configuration.WinTemp;
|
||||
|
||||
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
||||
if (!config.Exists(nameof(config.UseCoverAsFolderIcon)))
|
||||
config.UseCoverAsFolderIcon = false;
|
||||
|
||||
if (!config.Exists(nameof(config.BetaOptIn)))
|
||||
config.BetaOptIn = false;
|
||||
|
||||
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
||||
config.AllowLibationFixup = true;
|
||||
|
||||
if (!config.Exists(nameof(config.CreateCueSheet)))
|
||||
@@ -85,20 +116,20 @@ namespace AppScaffolding
|
||||
config.DecryptToLossy = false;
|
||||
|
||||
if (!config.Exists(nameof(config.LameTargetBitrate)))
|
||||
config.LameTargetBitrate = false;
|
||||
|
||||
config.LameTargetBitrate = false;
|
||||
|
||||
if (!config.Exists(nameof(config.LameDownsampleMono)))
|
||||
config.LameDownsampleMono = true;
|
||||
|
||||
|
||||
if (!config.Exists(nameof(config.LameBitrate)))
|
||||
config.LameBitrate = 64;
|
||||
|
||||
|
||||
if (!config.Exists(nameof(config.LameConstantBitrate)))
|
||||
config.LameConstantBitrate = false;
|
||||
|
||||
|
||||
if (!config.Exists(nameof(config.LameMatchSourceBR)))
|
||||
config.LameMatchSourceBR = true;
|
||||
|
||||
|
||||
if (!config.Exists(nameof(config.LameVBRQuality)))
|
||||
config.LameVBRQuality = 2;
|
||||
|
||||
@@ -114,6 +145,9 @@ namespace AppScaffolding
|
||||
if (!config.Exists(nameof(config.DownloadEpisodes)))
|
||||
config.DownloadEpisodes = true;
|
||||
|
||||
if (!config.Exists(nameof(config.ReplacementCharacters)))
|
||||
config.ReplacementCharacters = FileManager.ReplacementCharacters.Default;
|
||||
|
||||
if (!config.Exists(nameof(config.FolderTemplate)))
|
||||
config.FolderTemplate = Templates.Folder.DefaultTemplate;
|
||||
|
||||
@@ -123,16 +157,38 @@ namespace AppScaffolding
|
||||
if (!config.Exists(nameof(config.ChapterFileTemplate)))
|
||||
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
|
||||
|
||||
if (!config.Exists(nameof(config.ChapterTitleTemplate)))
|
||||
config.ChapterTitleTemplate = Templates.ChapterTitle.DefaultTemplate;
|
||||
|
||||
if (!config.Exists(nameof(config.AutoScan)))
|
||||
config.AutoScan = true;
|
||||
|
||||
if (!config.Exists(nameof(config.GridColumnsVisibilities)))
|
||||
config.GridColumnsVisibilities = new Dictionary<string, bool>();
|
||||
|
||||
if (!config.Exists(nameof(config.GridColumnsDisplayIndices)))
|
||||
config.GridColumnsDisplayIndices = new Dictionary<string, int>();
|
||||
|
||||
if (!config.Exists(nameof(config.GridColumnsWidths)))
|
||||
config.GridColumnsWidths = new Dictionary<string, int>();
|
||||
|
||||
if (!config.Exists(nameof(config.DownloadCoverArt)))
|
||||
config.DownloadCoverArt = true;
|
||||
|
||||
if (!config.Exists(nameof(config.AutoDownloadEpisodes)))
|
||||
config.AutoDownloadEpisodes = false;
|
||||
}
|
||||
|
||||
/// <summary>Initialize logging. Run after migration</summary>
|
||||
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
||||
public static void RunPostMigrationScaffolding(Configuration config)
|
||||
{
|
||||
ensureSerilogConfig(config);
|
||||
configureLogging(config);
|
||||
logStartupState(config);
|
||||
|
||||
// all else should occur after logging
|
||||
|
||||
wireUpSystemEvents(config);
|
||||
}
|
||||
|
||||
private static void ensureSerilogConfig(Configuration config)
|
||||
@@ -189,8 +245,8 @@ namespace AppScaffolding
|
||||
// However, empirical testing so far has shown no issues.
|
||||
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
||||
|
||||
#region Console => Serilog tests
|
||||
/*
|
||||
#region Console => Serilog tests
|
||||
/*
|
||||
// all below apply to "Console." and "Console.Out."
|
||||
|
||||
// captured
|
||||
@@ -229,27 +285,33 @@ namespace AppScaffolding
|
||||
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
|
||||
Console.Write("{0}", new object[] { "arr" });
|
||||
*/
|
||||
#endregion
|
||||
#endregion
|
||||
|
||||
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
||||
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
||||
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
||||
}
|
||||
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
||||
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
||||
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
||||
}
|
||||
|
||||
private static void logStartupState(Configuration config)
|
||||
{
|
||||
#if DEBUG
|
||||
var mode = "Debug";
|
||||
#else
|
||||
var mode = "Release";
|
||||
#endif
|
||||
if (System.Diagnostics.Debugger.IsAttached)
|
||||
mode += " (Debugger attached)";
|
||||
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||
{
|
||||
AppName = EntryAssembly.GetName().Name,
|
||||
Version = BuildVersion.ToString(),
|
||||
#if DEBUG
|
||||
Mode = "Debug",
|
||||
#else
|
||||
Mode = "Release",
|
||||
#endif
|
||||
|
||||
ReleaseIdentifier,
|
||||
Configuration.OS,
|
||||
InteropFactory.InteropFunctionsType,
|
||||
Mode = mode,
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||
@@ -257,7 +319,9 @@ namespace AppScaffolding
|
||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
||||
|
||||
config.LibationFiles,
|
||||
config.BetaOptIn,
|
||||
config.UseCoverAsFolderIcon,
|
||||
config.LibationFiles,
|
||||
AudibleFileStorage.BooksDirectory,
|
||||
|
||||
config.InProgress,
|
||||
@@ -268,27 +332,35 @@ namespace AppScaffolding
|
||||
AudibleFileStorage.DecryptInProgressDirectory,
|
||||
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
||||
});
|
||||
|
||||
if (InteropFactory.InteropFunctionsType is null)
|
||||
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||
}
|
||||
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
{
|
||||
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
|
||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
|
||||
}
|
||||
|
||||
public static (bool hasUpgrade, string zipUrl, string htmlUrl, string zipName) GetLatestRelease()
|
||||
public static UpgradeProperties 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 latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||
|
||||
if (latest is null || zip is null)
|
||||
return null;
|
||||
|
||||
var latestVersionString = latest.TagName.Trim('v');
|
||||
if (!Version.TryParse(latestVersionString, out var latestRelease))
|
||||
return isFalse;
|
||||
return null;
|
||||
|
||||
// we're up to date
|
||||
if (latestRelease <= BuildVersion)
|
||||
return isFalse;
|
||||
return null;
|
||||
|
||||
// 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
|
||||
@@ -298,13 +370,13 @@ namespace AppScaffolding
|
||||
zipUrl
|
||||
});
|
||||
|
||||
return (true, zipUrl, latest.HtmlUrl, zip.Name);
|
||||
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease);
|
||||
}
|
||||
private static Octokit.Release getLatestRelease(TimeSpan timeout)
|
||||
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
var task = System.Threading.Tasks.Task.Run(() => getLatestRelease());
|
||||
var task = getLatestRelease();
|
||||
if (task.Wait(timeout))
|
||||
return task.Result;
|
||||
|
||||
@@ -314,56 +386,31 @@ namespace AppScaffolding
|
||||
{
|
||||
Log.Logger.Error(aggEx, "Checking for new version too often");
|
||||
}
|
||||
return null;
|
||||
return (null, null);
|
||||
}
|
||||
private static Octokit.Release getLatestRelease()
|
||||
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
||||
{
|
||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("Libation"));
|
||||
var ownerAccount = "rmcrackan";
|
||||
var repoName = "Libation";
|
||||
|
||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
||||
|
||||
//Download the release index
|
||||
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
||||
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||
var regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
||||
|
||||
// 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;
|
||||
var releases = await gitHubClient.Repository.Release.GetAll(ownerAccount, repoName);
|
||||
|
||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
var latestRelease = releases.FirstOrDefault(r => !r.Draft && !r.Prerelease && r.Assets.Any(a => regex.IsMatch(a.Name)));
|
||||
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
#endregion
|
||||
|
||||
public static void migrate_to_v6_6_9(Configuration config)
|
||||
{
|
||||
var writeToPath = $"Serilog.WriteTo";
|
||||
@@ -410,5 +457,74 @@ namespace AppScaffolding
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
||||
}
|
||||
}
|
||||
|
||||
public static void migrate_from_7_10_1(Configuration config)
|
||||
{
|
||||
var lastMigrationThrew = config.GetNonString<bool>($"{nameof(migrate_from_7_10_1)}_ThrewError");
|
||||
|
||||
if (lastMigrationThrew) return;
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
//https://github.com/rmcrackan/Libation/issues/270#issuecomment-1152863629
|
||||
//This migration helps fix databases contaminated with the 7.10.1 hack workaround
|
||||
//and those with improperly identified or missing series. This does not solve cases
|
||||
//where individual episodes are in the db with a valid series link, but said series'
|
||||
//parents have not been imported into the database. For those cases, Libation will
|
||||
//attempt fixup by retrieving parents from the catalog endpoint
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
//This migration removes books and series with SERIES_ prefix that were created
|
||||
//as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2
|
||||
string removeHackSeries = "delete " +
|
||||
"from series " +
|
||||
"where AudibleSeriesId like 'SERIES%'";
|
||||
|
||||
string removeHackBooks = "delete " +
|
||||
"from books " +
|
||||
"where AudibleProductId like 'SERIES%'";
|
||||
|
||||
//Detect series parents that were added to the database as books with ContentType.Episode,
|
||||
//and change them to ContentType.Parent
|
||||
string updateContentType =
|
||||
"UPDATE books " +
|
||||
"SET contenttype = 4 " +
|
||||
"WHERE audibleproductid IN (SELECT books.audibleproductid " +
|
||||
"FROM books " +
|
||||
"INNER JOIN series " +
|
||||
"ON ( books.audibleproductid = " +
|
||||
"series.audibleseriesid) " +
|
||||
"WHERE books.contenttype = 2)";
|
||||
|
||||
//Then detect series parents that were added to the database as books with ContentType.Parent
|
||||
//but are missing a series link, and add the link (don't know how this happened)
|
||||
string addMissingSeriesLink =
|
||||
"INSERT INTO seriesbook " +
|
||||
"SELECT series.seriesid, " +
|
||||
"books.bookid, " +
|
||||
"'- 1' " +
|
||||
"FROM books " +
|
||||
"LEFT OUTER JOIN seriesbook " +
|
||||
"ON books.bookid = seriesbook.bookid " +
|
||||
"INNER JOIN series " +
|
||||
"ON books.audibleproductid = series.audibleseriesid " +
|
||||
"WHERE books.contenttype = 4 " +
|
||||
"AND seriesbook.seriesid IS NULL";
|
||||
|
||||
context.Database.ExecuteSqlRaw(removeHackSeries);
|
||||
context.Database.ExecuteSqlRaw(removeHackBooks);
|
||||
context.Database.ExecuteSqlRaw(updateContentType);
|
||||
context.Database.ExecuteSqlRaw(addMissingSeriesLink);
|
||||
|
||||
LibraryCommands.SaveContext(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "An error occurred while running database migrations in {0}", nameof(migrate_from_7_10_1));
|
||||
config.SetObject($"{nameof(migrate_from_7_10_1)}_ThrewError", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
Source/AppScaffolding/OSConfigBase.cs
Normal file
29
Source/AppScaffolding/OSConfigBase.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
|
||||
namespace AppScaffolding
|
||||
{
|
||||
public abstract class OSConfigBase
|
||||
{
|
||||
public abstract Type InteropFunctionsType { get; }
|
||||
public virtual Type[] ReferencedTypes { get; } = new Type[0];
|
||||
|
||||
public void Run()
|
||||
{
|
||||
//Each of these types belongs to a different windows-only assembly that's needed by
|
||||
//the WinInterop methods. By referencing these types in main we force the runtime to
|
||||
//load their assemblies before execution reaches inside main. This allows the calling
|
||||
//process to find these assemblies in its module list.
|
||||
_ = ReferencedTypes;
|
||||
_ = InteropFunctionsType;
|
||||
|
||||
//Wait for the calling process to be ready to read the WriteLine()
|
||||
Console.ReadLine();
|
||||
|
||||
// Signal the calling process that execution has reached inside main, and that all referenced assemblies have been loaded.
|
||||
Console.WriteLine();
|
||||
|
||||
// Wait for the calling process to finish reading the process module list, then exit.
|
||||
Console.ReadLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,15 @@ namespace AppScaffolding
|
||||
///
|
||||
///
|
||||
/// </summary>
|
||||
internal static class UNSAFE_MigrationHelper
|
||||
public static class UNSAFE_MigrationHelper
|
||||
{
|
||||
public static string SettingsDirectory
|
||||
=> !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null
|
||||
? null
|
||||
: value;
|
||||
|
||||
#region appsettings.json
|
||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
|
||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "appsettings.json");
|
||||
|
||||
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);
|
||||
|
||||
@@ -87,19 +92,11 @@ namespace AppScaffolding
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Settings.json
|
||||
public const string LIBATION_FILES_KEY = "LibationFiles";
|
||||
private const string SETTINGS_JSON = "Settings.json";
|
||||
|
||||
public static string SettingsJsonPath
|
||||
{
|
||||
get
|
||||
{
|
||||
var success = APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value);
|
||||
return !success || value is null ? null : Path.Combine(value, SETTINGS_JSON);
|
||||
}
|
||||
}
|
||||
public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON);
|
||||
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
|
||||
|
||||
public static bool Settings_TryGet(string key, out string value)
|
||||
@@ -267,5 +264,10 @@ namespace AppScaffolding
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
#endregion
|
||||
#region LibationContext.db
|
||||
public const string LIBATION_CONTEXT = "LibationContext.db";
|
||||
public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
|
||||
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
6
Source/AppScaffolding/UpgradeProperties.cs
Normal file
6
Source/AppScaffolding/UpgradeProperties.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace AppScaffolding
|
||||
{
|
||||
public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="27.2.1" />
|
||||
<PackageReference Include="CsvHelper" Version="28.0.1" />
|
||||
<PackageReference Include="NPOI" Version="2.5.6" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -14,4 +14,12 @@
|
||||
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -12,10 +12,10 @@ namespace ApplicationServices
|
||||
=> LibationContext.Create(SqliteStorage.ConnectionString);
|
||||
|
||||
/// <summary>Use for full library querying. No lazy loading</summary>
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking()
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibrary_Flat_NoTracking();
|
||||
return context.GetLibrary_Flat_NoTracking(includeParents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,24 +27,33 @@ namespace ApplicationServices
|
||||
ScanEnd += (_, __) => Scanning = false;
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, List<LibraryBook> existingLibrary, params Account[] accounts)
|
||||
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
|
||||
{
|
||||
logRestart();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (Scanning)
|
||||
return new();
|
||||
}
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
//These are the minimum response groups required for the
|
||||
//library scanner to pass all validation and filtering.
|
||||
var libraryResponseGroups =
|
||||
LibraryOptions.ResponseGroupOptions.ProductAttrs |
|
||||
LibraryOptions.ResponseGroupOptions.ProductDesc |
|
||||
LibraryOptions.ResponseGroupOptions.Relationships;
|
||||
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
var libraryOptions = new LibraryOptions
|
||||
{
|
||||
ResponseGroups
|
||||
= 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);
|
||||
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = libraryItems.Count;
|
||||
@@ -81,6 +90,7 @@ namespace ApplicationServices
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +108,16 @@ namespace ApplicationServices
|
||||
{
|
||||
if (Scanning)
|
||||
return (0, 0);
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
}
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, LibraryOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
var libraryOptions = new LibraryOptions
|
||||
{
|
||||
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS,
|
||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||
};
|
||||
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
@@ -111,6 +126,22 @@ namespace ApplicationServices
|
||||
if (totalCount == 0)
|
||||
return default;
|
||||
|
||||
|
||||
Log.Logger.Information("Begin scan for orphaned episode parents");
|
||||
var newParents = await findAndAddMissingParents(accounts);
|
||||
Log.Logger.Information($"Orphan episode scan complete. New parents count {newParents}");
|
||||
|
||||
if (newParents >= 0)
|
||||
{
|
||||
//If any episodes are still orphaned, their series have been
|
||||
//removed from the catalog and we'll never be able to find them.
|
||||
|
||||
//only do this if findAndAddMissingParents returned >= 0. If it
|
||||
//returned < 0, an error happened and there's still a chance that
|
||||
//a future successful run will find missing parents.
|
||||
removedOrphanedEpisodes();
|
||||
}
|
||||
|
||||
Log.Logger.Information("Begin long-running import");
|
||||
logTime($"pre {nameof(importIntoDbAsync)}");
|
||||
var newCount = await importIntoDbAsync(importItems);
|
||||
@@ -150,7 +181,7 @@ namespace ApplicationServices
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions.ResponseGroupOptions libraryResponseGroups)
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
foreach (var account in accounts)
|
||||
@@ -159,7 +190,7 @@ namespace ApplicationServices
|
||||
var apiExtended = await apiExtendedfunc(account);
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryResponseGroups));
|
||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions));
|
||||
}
|
||||
|
||||
// import library in parallel
|
||||
@@ -168,7 +199,7 @@ namespace ApplicationServices
|
||||
return importItems;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions.ResponseGroupOptions libraryResponseGroups)
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
@@ -179,7 +210,7 @@ namespace ApplicationServices
|
||||
|
||||
logTime($"pre scanAccountAsync {account.AccountName}");
|
||||
|
||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryResponseGroups, Configuration.Instance.ImportEpisodes);
|
||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
|
||||
|
||||
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
||||
|
||||
@@ -192,8 +223,8 @@ namespace ApplicationServices
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryBookImporter = new LibraryBookImporter(context);
|
||||
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
|
||||
logTime("importIntoDbAsync -- post Import()");
|
||||
int qtyChanges = saveChanges(context);
|
||||
logTime("importIntoDbAsync -- post Import()");
|
||||
int qtyChanges = SaveContext(context);
|
||||
logTime("importIntoDbAsync -- post SaveChanges");
|
||||
|
||||
// this is any changes at all to the database, not just new books
|
||||
@@ -204,7 +235,85 @@ namespace ApplicationServices
|
||||
return newCount;
|
||||
}
|
||||
|
||||
private static int saveChanges(LibationContext context)
|
||||
static void removedOrphanedEpisodes()
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
try
|
||||
{
|
||||
var orphanedEpisodes =
|
||||
context
|
||||
.GetLibrary_Flat_NoTracking(includeParents: true)
|
||||
.FindOrphanedEpisodes();
|
||||
|
||||
context.LibraryBooks.RemoveRange(orphanedEpisodes);
|
||||
context.Books.RemoveRange(orphanedEpisodes.Select(lb => lb.Book));
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "An error occurred while trying to remove orphaned episodes from the database");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<int> findAndAddMissingParents(Account[] accounts)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
|
||||
|
||||
try
|
||||
{
|
||||
var orphanedEpisodes = library.FindOrphanedEpisodes().ToList();
|
||||
|
||||
if (!orphanedEpisodes.Any())
|
||||
return -1;
|
||||
|
||||
var orphanedSeries =
|
||||
orphanedEpisodes
|
||||
.SelectMany(lb => lb.Book.SeriesLink)
|
||||
.DistinctBy(s => s.Series.AudibleSeriesId)
|
||||
.ToList();
|
||||
|
||||
// The Catalog endpoint does not require authentication.
|
||||
var api = new ApiUnauthenticated(accounts[0].Locale);
|
||||
|
||||
var seriesParents = orphanedSeries.Select(o => o.Series.AudibleSeriesId).ToList();
|
||||
var items = await api.GetCatalogProductsAsync(seriesParents, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
|
||||
List<ImportItem> newParentsImportItems = new();
|
||||
foreach (var sp in orphanedSeries)
|
||||
{
|
||||
var seriesItem = items.First(i => i.Asin == sp.Series.AudibleSeriesId);
|
||||
|
||||
if (seriesItem.Relationships is null)
|
||||
continue;
|
||||
|
||||
var episode = orphanedEpisodes.First(l => l.Book.AudibleProductId == sp.Book.AudibleProductId);
|
||||
|
||||
seriesItem.PurchaseDate = new DateTimeOffset(episode.DateAdded);
|
||||
seriesItem.Series = new AudibleApi.Common.Series[]
|
||||
{
|
||||
new AudibleApi.Common.Series{ Asin = seriesItem.Asin, Title = seriesItem.TitleWithSubtitle, Sequence = "-1"}
|
||||
};
|
||||
|
||||
newParentsImportItems.Add(new ImportItem { DtoItem = seriesItem, AccountId = episode.Account, LocaleName = episode.Book.Locale });
|
||||
}
|
||||
|
||||
var newCoutn = new LibraryBookImporter(context)
|
||||
.Import(newParentsImportItems);
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return newCoutn;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "An error occurred while trying to scan for orphaned episode parents.");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static int SaveContext(LibationContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -212,7 +321,7 @@ namespace ApplicationServices
|
||||
}
|
||||
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
|
||||
{
|
||||
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culpret is the "WithExceptionDetails" serilog extension
|
||||
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culprit is the "WithExceptionDetails" serilog extension
|
||||
|
||||
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
|
||||
|
||||
@@ -246,43 +355,78 @@ namespace ApplicationServices
|
||||
#endregion
|
||||
|
||||
// call this whenever books are added or removed from library
|
||||
private static void finalizeLibrarySizeChange()
|
||||
{
|
||||
SearchEngineCommands.FullReIndex();
|
||||
LibrarySizeChanged?.Invoke(null, null);
|
||||
}
|
||||
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
|
||||
|
||||
/// <summary>Occurs when books are added or removed from library</summary>
|
||||
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</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.
|
||||
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
||||
/// </summary>
|
||||
public static event EventHandler<string> BookUserDefinedItemCommitted;
|
||||
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
|
||||
|
||||
#region Update book details
|
||||
public static int UpdateUserDefinedItem(Book book)
|
||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
|
||||
{
|
||||
book.UserDefinedItem.BookStatus = bookStatus;
|
||||
return UpdateUserDefinedItem(book);
|
||||
}
|
||||
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
|
||||
{
|
||||
book.UserDefinedItem.PdfStatus = pdfStatus;
|
||||
return UpdateUserDefinedItem(book);
|
||||
}
|
||||
public static int UpdateBook(
|
||||
this Book book,
|
||||
string tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null)
|
||||
=> UpdateBooks(tags, bookStatus, pdfStatus, book);
|
||||
public static int UpdateBooks(
|
||||
string tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null,
|
||||
params Book[] books)
|
||||
{
|
||||
foreach (var book in books)
|
||||
{
|
||||
// blank tags are expected. null tags are not
|
||||
if (tags is not null && book.UserDefinedItem.Tags != tags)
|
||||
book.UserDefinedItem.Tags = tags;
|
||||
|
||||
if (bookStatus is not null && book.UserDefinedItem.BookStatus != bookStatus.Value)
|
||||
book.UserDefinedItem.BookStatus = bookStatus.Value;
|
||||
|
||||
// even though PdfStatus is nullable, there's no case where we'd actually overwrite with null
|
||||
if (pdfStatus is not null && book.UserDefinedItem.PdfStatus != pdfStatus.Value)
|
||||
book.UserDefinedItem.PdfStatus = pdfStatus.Value;
|
||||
}
|
||||
|
||||
return UpdateUserDefinedItem(books);
|
||||
}
|
||||
public static int UpdateUserDefinedItem(params Book[] books) => UpdateUserDefinedItem(books.ToList());
|
||||
public static int UpdateUserDefinedItem(IEnumerable<Book> books)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (books is null || !books.Any())
|
||||
return 0;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
// Attach() NoTracking entities before SaveChanges()
|
||||
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
foreach (var book in books)
|
||||
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);
|
||||
}
|
||||
BookUserDefinedItemCommitted?.Invoke(null, books);
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, $"Error updating {nameof(book.UserDefinedItem)}");
|
||||
Log.Logger.Error(ex, $"Error updating {nameof(Book.UserDefinedItem)}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -290,7 +434,7 @@ namespace ApplicationServices
|
||||
|
||||
// must be here instead of in db layer due to AaxcExists
|
||||
public static LiberatedStatus Liberated_Status(Book book)
|
||||
=> book.Audio_Exists ? book.UserDefinedItem.BookStatus
|
||||
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
|
||||
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
|
||||
: LiberatedStatus.NotLiberated;
|
||||
|
||||
@@ -299,7 +443,14 @@ namespace ApplicationServices
|
||||
|
||||
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
||||
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded) { }
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
|
||||
{
|
||||
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
||||
public bool HasPendingBooks => PendingBooks > 0;
|
||||
|
||||
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
|
||||
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
|
||||
}
|
||||
public static LibraryStats GetCounts()
|
||||
{
|
||||
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
@@ -317,7 +468,7 @@ namespace ApplicationServices
|
||||
|
||||
var boolResults = libraryBooks
|
||||
.AsParallel()
|
||||
.Where(lb => lb.Book.HasPdf)
|
||||
.Where(lb => lb.Book.HasPdf())
|
||||
.Select(lb => Pdf_Status(lb.Book))
|
||||
.ToList();
|
||||
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
|
||||
|
||||
@@ -100,6 +100,9 @@ namespace ApplicationServices
|
||||
|
||||
[Name("Content Type")]
|
||||
public string ContentType { get; set; }
|
||||
|
||||
[Name("Audio Format")]
|
||||
public string AudioFormat { get; set; }
|
||||
}
|
||||
public static class LibToDtos
|
||||
{
|
||||
@@ -111,13 +114,13 @@ namespace ApplicationServices
|
||||
AudibleProductId = a.Book.AudibleProductId,
|
||||
Locale = a.Book.Locale,
|
||||
Title = a.Book.Title,
|
||||
AuthorNames = a.Book.AuthorNames,
|
||||
NarratorNames = a.Book.NarratorNames,
|
||||
AuthorNames = a.Book.AuthorNames(),
|
||||
NarratorNames = a.Book.NarratorNames(),
|
||||
LengthInMinutes = a.Book.LengthInMinutes,
|
||||
Description = a.Book.Description,
|
||||
Publisher = a.Book.Publisher,
|
||||
HasPdf = a.Book.HasPdf,
|
||||
SeriesNames = a.Book.SeriesNames,
|
||||
HasPdf = a.Book.HasPdf(),
|
||||
SeriesNames = a.Book.SeriesNames(),
|
||||
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
CommunityRatingOverall = a.Book.Rating?.OverallRating,
|
||||
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
|
||||
@@ -125,14 +128,15 @@ namespace ApplicationServices
|
||||
PictureId = a.Book.PictureId,
|
||||
IsAbridged = a.Book.IsAbridged,
|
||||
DatePublished = a.Book.DatePublished,
|
||||
CategoriesNames = a.Book.CategoriesNames.Any() ? a.Book.CategoriesNames.Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
CategoriesNames = a.Book.CategoriesNames().Any() ? a.Book.CategoriesNames().Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
||||
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||
ContentType = a.Book.ContentType.ToString()
|
||||
ContentType = a.Book.ContentType.ToString(),
|
||||
AudioFormat = a.Book.AudioFormat.ToString()
|
||||
}).ToList();
|
||||
}
|
||||
public static class LibraryExporter
|
||||
@@ -176,33 +180,34 @@ namespace ApplicationServices
|
||||
var row = sheet.CreateRow(rowIndex);
|
||||
|
||||
var columns = new[] {
|
||||
nameof (ExportDto.Account),
|
||||
nameof (ExportDto.DateAdded),
|
||||
nameof (ExportDto.AudibleProductId),
|
||||
nameof (ExportDto.Locale),
|
||||
nameof (ExportDto.Title),
|
||||
nameof (ExportDto.AuthorNames),
|
||||
nameof (ExportDto.NarratorNames),
|
||||
nameof (ExportDto.LengthInMinutes),
|
||||
nameof (ExportDto.Description),
|
||||
nameof (ExportDto.Publisher),
|
||||
nameof (ExportDto.HasPdf),
|
||||
nameof (ExportDto.SeriesNames),
|
||||
nameof (ExportDto.SeriesOrder),
|
||||
nameof (ExportDto.CommunityRatingOverall),
|
||||
nameof (ExportDto.CommunityRatingPerformance),
|
||||
nameof (ExportDto.CommunityRatingStory),
|
||||
nameof (ExportDto.PictureId),
|
||||
nameof (ExportDto.IsAbridged),
|
||||
nameof (ExportDto.DatePublished),
|
||||
nameof (ExportDto.CategoriesNames),
|
||||
nameof (ExportDto.MyRatingOverall),
|
||||
nameof (ExportDto.MyRatingPerformance),
|
||||
nameof (ExportDto.MyRatingStory),
|
||||
nameof (ExportDto.MyLibationTags),
|
||||
nameof (ExportDto.BookStatus),
|
||||
nameof (ExportDto.PdfStatus),
|
||||
nameof (ExportDto.ContentType)
|
||||
nameof(ExportDto.Account),
|
||||
nameof(ExportDto.DateAdded),
|
||||
nameof(ExportDto.AudibleProductId),
|
||||
nameof(ExportDto.Locale),
|
||||
nameof(ExportDto.Title),
|
||||
nameof(ExportDto.AuthorNames),
|
||||
nameof(ExportDto.NarratorNames),
|
||||
nameof(ExportDto.LengthInMinutes),
|
||||
nameof(ExportDto.Description),
|
||||
nameof(ExportDto.Publisher),
|
||||
nameof(ExportDto.HasPdf),
|
||||
nameof(ExportDto.SeriesNames),
|
||||
nameof(ExportDto.SeriesOrder),
|
||||
nameof(ExportDto.CommunityRatingOverall),
|
||||
nameof(ExportDto.CommunityRatingPerformance),
|
||||
nameof(ExportDto.CommunityRatingStory),
|
||||
nameof(ExportDto.PictureId),
|
||||
nameof(ExportDto.IsAbridged),
|
||||
nameof(ExportDto.DatePublished),
|
||||
nameof(ExportDto.CategoriesNames),
|
||||
nameof(ExportDto.MyRatingOverall),
|
||||
nameof(ExportDto.MyRatingPerformance),
|
||||
nameof(ExportDto.MyRatingStory),
|
||||
nameof(ExportDto.MyLibationTags),
|
||||
nameof(ExportDto.BookStatus),
|
||||
nameof(ExportDto.PdfStatus),
|
||||
nameof(ExportDto.ContentType),
|
||||
nameof(ExportDto.AudioFormat)
|
||||
};
|
||||
var col = 0;
|
||||
foreach (var c in columns)
|
||||
@@ -268,6 +273,7 @@ namespace ApplicationServices
|
||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
||||
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
|
||||
@@ -7,51 +9,99 @@ 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 =>
|
||||
#region Search
|
||||
public static SearchResultSet Search(string searchString) => performSafeQuery(e =>
|
||||
e.Search(searchString)
|
||||
);
|
||||
|
||||
public static void UpdateBookTags(Book book) => performSearchEngineAction_safe(e =>
|
||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
|
||||
private static T performSafeQuery<T>(Func<SearchEngine, T> func)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
try
|
||||
{
|
||||
return func(engine);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
fullReIndex(engine);
|
||||
return func(engine);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
public static event EventHandler SearchEngineUpdated;
|
||||
|
||||
#region Update
|
||||
private static bool isUpdating;
|
||||
|
||||
public static void UpdateBooks(IEnumerable<Book> books)
|
||||
{
|
||||
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
|
||||
// I did not benchmark before choosing the number here
|
||||
if (books.Count() > 15)
|
||||
FullReIndex();
|
||||
else
|
||||
{
|
||||
foreach (var book in books)
|
||||
{
|
||||
UpdateLiberatedStatus(book);
|
||||
UpdateBookTags(book);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void FullReIndex() => performSafeCommand(e =>
|
||||
fullReIndex(e)
|
||||
);
|
||||
|
||||
public static void UpdateLiberatedStatus(Book book) => performSearchEngineAction_safe(e =>
|
||||
internal static void UpdateLiberatedStatus(Book book) => performSafeCommand(e =>
|
||||
e.UpdateLiberatedStatus(book)
|
||||
);
|
||||
|
||||
private static void performSearchEngineAction_safe(Action<SearchEngine> action)
|
||||
internal static void UpdateBookTags(Book book) => performSafeCommand(e =>
|
||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
|
||||
);
|
||||
|
||||
private static void performSafeCommand(Action<SearchEngine> action)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
try
|
||||
{
|
||||
action(engine);
|
||||
update(action);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
FullReIndex(engine);
|
||||
action(engine);
|
||||
fullReIndex(new SearchEngine());
|
||||
update(action);
|
||||
}
|
||||
}
|
||||
|
||||
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> func)
|
||||
private static void update(Action<SearchEngine> action)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
if (action is null)
|
||||
return;
|
||||
|
||||
// support nesting incl recursion
|
||||
var prevIsUpdating = isUpdating;
|
||||
try
|
||||
{
|
||||
return func(engine);
|
||||
isUpdating = true;
|
||||
|
||||
action(new SearchEngine());
|
||||
|
||||
if (!prevIsUpdating)
|
||||
SearchEngineUpdated?.Invoke(null, null);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
finally
|
||||
{
|
||||
FullReIndex(engine);
|
||||
return func(engine);
|
||||
isUpdating = prevIsUpdating;
|
||||
}
|
||||
}
|
||||
|
||||
private static void fullReIndex(SearchEngine engine)
|
||||
{
|
||||
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
engine.CreateNewIndex(library);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Common;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
@@ -106,43 +110,55 @@ namespace AudibleUtilities
|
||||
// 2 retries == 3 total
|
||||
.RetryAsync(2);
|
||||
|
||||
public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions.ResponseGroupOptions responseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS, bool importEpisodes = true)
|
||||
public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions libraryOptions, 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));
|
||||
return policy.ExecuteAsync(() => getItemsAsync(libraryOptions, importEpisodes));
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getItemsAsync(LibraryOptions.ResponseGroupOptions responseGroups, bool importEpisodes)
|
||||
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, 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
|
||||
|
||||
Serilog.Log.Logger.Debug("Begin initial library scan");
|
||||
Serilog.Log.Logger.Debug("Beginning library scan.");
|
||||
|
||||
if (!items.Any())
|
||||
items = await Api.GetAllLibraryItemsAsync(responseGroups);
|
||||
List<Task<List<Item>>> getChildEpisodesTasks = new();
|
||||
|
||||
Serilog.Log.Logger.Debug("Initial library scan complete. Begin episode scan");
|
||||
int count = 0, maxConcurrentEpisodeScans = 5;
|
||||
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
|
||||
|
||||
await manageEpisodesAsync(items, importEpisodes);
|
||||
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
|
||||
{
|
||||
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
|
||||
{
|
||||
//Get child episodes asynchronously and await all at the end
|
||||
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
|
||||
}
|
||||
else if (!item.IsEpisodes && !item.IsSeriesParent)
|
||||
items.Add(item);
|
||||
|
||||
Serilog.Log.Logger.Debug("Episode scan complete");
|
||||
count++;
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
|
||||
|
||||
//await and add all episodes from all parents
|
||||
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
|
||||
items.AddRange(epList);
|
||||
|
||||
Serilog.Log.Logger.Debug("Completed library scan.");
|
||||
|
||||
#if DEBUG
|
||||
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
|
||||
//// 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));
|
||||
//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)
|
||||
@@ -151,60 +167,88 @@ namespace AudibleUtilities
|
||||
if (exceptions is not null && exceptions.Any())
|
||||
throw new AggregateException(exceptions);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
#region episodes and podcasts
|
||||
private async Task manageEpisodesAsync(List<Item> items, bool importEpisodes)
|
||||
private static List<IValidator> getValidators()
|
||||
{
|
||||
// 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
|
||||
var type = typeof(IValidator);
|
||||
var types = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(s => s.GetTypes())
|
||||
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
|
||||
|
||||
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
|
||||
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");
|
||||
}
|
||||
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getEpisodesAsync(List<Item> parents)
|
||||
#region episodes and podcasts
|
||||
|
||||
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
|
||||
{
|
||||
var results = new List<Item>();
|
||||
await concurrencySemaphore.WaitAsync();
|
||||
|
||||
foreach (var parent in parents)
|
||||
try
|
||||
{
|
||||
var children = await getEpisodeChildrenAsync(parent);
|
||||
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
|
||||
|
||||
// actual individual episode, not the parent of a series.
|
||||
// for now I'm keeping it inside this method since it fits the work flow, incl. importEpisodes logic
|
||||
if (!children.Any())
|
||||
List<Item> children;
|
||||
|
||||
if (parent.IsEpisodes)
|
||||
{
|
||||
results.Add(parent);
|
||||
continue;
|
||||
//The 'parent' is a single episode that was added to the library.
|
||||
//Get the episode's parent and add it to the database.
|
||||
|
||||
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
|
||||
|
||||
children = new() { parent };
|
||||
|
||||
var parentAsins = parent.Relationships
|
||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
||||
.Select(p => p.Asin);
|
||||
|
||||
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
|
||||
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
|
||||
if (numSeriesParents != 1)
|
||||
{
|
||||
//There should only ever be 1 top-level parent per episode. If not, log
|
||||
//so we can figure out what to do about those special cases, and don't
|
||||
//import the episode.
|
||||
JsonSerializerSettings Settings = new()
|
||||
{
|
||||
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
|
||||
DateParseHandling = DateParseHandling.None,
|
||||
Converters =
|
||||
{
|
||||
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
|
||||
},
|
||||
};
|
||||
Serilog.Log.Logger.Error($"Found {numSeriesParents} parents for {parent.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
|
||||
return new List<Item>();
|
||||
}
|
||||
|
||||
var realParent = seriesParents.Single(p => p.IsSeriesParent);
|
||||
realParent.PurchaseDate = parent.PurchaseDate;
|
||||
|
||||
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
|
||||
parent = realParent;
|
||||
}
|
||||
else
|
||||
{
|
||||
children = await getEpisodeChildrenAsync(parent);
|
||||
if (!children.Any())
|
||||
return new();
|
||||
}
|
||||
|
||||
//A series parent will always have exactly 1 Series
|
||||
parent.Series = new Series[]
|
||||
{
|
||||
new Series
|
||||
{
|
||||
Asin = parent.Asin,
|
||||
Sequence = "-1",
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
@@ -217,25 +261,22 @@ namespace AudibleUtilities
|
||||
{
|
||||
Asin = parent.Asin,
|
||||
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin).Sort.ToString(),
|
||||
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
// overload (read: abuse) IsEpisodes flag
|
||||
child.Relationships = new Relationship[]
|
||||
{
|
||||
new Relationship
|
||||
{
|
||||
RelationshipToProduct = RelationshipToProduct.Child,
|
||||
RelationshipType = RelationshipType.Episode
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
results.AddRange(children);
|
||||
}
|
||||
children.Add(parent);
|
||||
|
||||
return results;
|
||||
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
|
||||
|
||||
return children;
|
||||
}
|
||||
finally
|
||||
{
|
||||
concurrencySemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
|
||||
@@ -261,8 +302,8 @@ namespace AudibleUtilities
|
||||
{
|
||||
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);
|
||||
//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)
|
||||
@@ -277,7 +318,7 @@ namespace AudibleUtilities
|
||||
throw;
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results");
|
||||
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results\t({{parent}})", parent);
|
||||
// the service returned no results. probably indicates an error. stop running batches
|
||||
if (!childrenBatch.Any())
|
||||
break;
|
||||
@@ -295,22 +336,12 @@ namespace AudibleUtilities
|
||||
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.");
|
||||
Serilog.Log.Logger.Error(ex, "{parent} - Quantity of series episodes defined by parent does not match quantity returned by batch fetching.", parent);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
#endregion
|
||||
|
||||
private static List<IValidator> getValidators()
|
||||
{
|
||||
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 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="2.7.6.1" />
|
||||
<PackageReference Include="AudibleApi" Version="4.6.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
199
Source/AudibleUtilities/Mkb79Auth.cs
Normal file
199
Source/AudibleUtilities/Mkb79Auth.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AudibleUtilities
|
||||
{
|
||||
public partial class Mkb79Auth : IIdentityMaintainer
|
||||
{
|
||||
[JsonProperty("website_cookies")]
|
||||
private JObject _websiteCookies { get; set; }
|
||||
|
||||
[JsonProperty("adp_token")]
|
||||
public string AdpToken { get; private set; }
|
||||
|
||||
[JsonProperty("access_token")]
|
||||
public string AccessToken { get; private set; }
|
||||
|
||||
[JsonProperty("refresh_token")]
|
||||
public string RefreshToken { get; private set; }
|
||||
|
||||
[JsonProperty("device_private_key")]
|
||||
public string DevicePrivateKey { get; private set; }
|
||||
|
||||
[JsonProperty("store_authentication_cookie")]
|
||||
private JObject _storeAuthenticationCookie { get; set; }
|
||||
|
||||
[JsonProperty("device_info")]
|
||||
public DeviceInfo DeviceInfo { get; private set; }
|
||||
|
||||
[JsonProperty("customer_info")]
|
||||
public CustomerInfo CustomerInfo { get; private set; }
|
||||
|
||||
[JsonProperty("expires")]
|
||||
private double _expires { get; set; }
|
||||
|
||||
[JsonProperty("locale_code")]
|
||||
public string LocaleCode { get; private set; }
|
||||
|
||||
[JsonProperty("activation_bytes")]
|
||||
public string ActivationBytes { get; private set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Dictionary<string, string> WebsiteCookies
|
||||
{
|
||||
get => _websiteCookies.ToObject<Dictionary<string, string>>();
|
||||
private set => _websiteCookies = JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings));
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string StoreAuthenticationCookie
|
||||
{
|
||||
get => _storeAuthenticationCookie.ToObject<Dictionary<string, string>>()["cookie"];
|
||||
private set => _storeAuthenticationCookie = JObject.Parse(JsonConvert.SerializeObject(new Dictionary<string, string>() { { "cookie", value } }, Converter.Settings));
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public DateTime AccessTokenExpires
|
||||
{
|
||||
get => DateTimeOffset.FromUnixTimeMilliseconds((long)(_expires * 1000)).DateTime;
|
||||
private set => _expires = new DateTimeOffset(value).ToUnixTimeMilliseconds() / 1000d;
|
||||
}
|
||||
|
||||
[JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime();
|
||||
[JsonIgnore] public Locale Locale => Localization.Get(LocaleCode);
|
||||
[JsonIgnore] public string DeviceSerialNumber => DeviceInfo.DeviceSerialNumber;
|
||||
[JsonIgnore] public string DeviceType => DeviceInfo.DeviceType;
|
||||
[JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId;
|
||||
|
||||
public Task<AccessToken> GetAccessTokenAsync()
|
||||
=> Task.FromResult(new AccessToken(AccessToken, AccessTokenExpires));
|
||||
|
||||
public Task<AdpToken> GetAdpTokenAsync()
|
||||
=> Task.FromResult(new AdpToken(AdpToken));
|
||||
|
||||
public Task<PrivateKey> GetPrivateKeyAsync()
|
||||
=> Task.FromResult(new PrivateKey(DevicePrivateKey));
|
||||
}
|
||||
|
||||
public partial class CustomerInfo
|
||||
{
|
||||
[JsonProperty("account_pool")]
|
||||
public string AccountPool { get; set; }
|
||||
|
||||
[JsonProperty("user_id")]
|
||||
public string UserId { get; set; }
|
||||
|
||||
[JsonProperty("home_region")]
|
||||
public string HomeRegion { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("given_name")]
|
||||
public string GivenName { get; set; }
|
||||
}
|
||||
|
||||
public partial class DeviceInfo
|
||||
{
|
||||
[JsonProperty("device_name")]
|
||||
public string DeviceName { get; set; }
|
||||
|
||||
[JsonProperty("device_serial_number")]
|
||||
public string DeviceSerialNumber { get; set; }
|
||||
|
||||
[JsonProperty("device_type")]
|
||||
public string DeviceType { get; set; }
|
||||
}
|
||||
|
||||
public partial class Mkb79Auth
|
||||
{
|
||||
public static Mkb79Auth FromJson(string json)
|
||||
=> JsonConvert.DeserializeObject<Mkb79Auth>(json, Converter.Settings);
|
||||
|
||||
public string ToJson()
|
||||
=> JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings)).ToString(Formatting.Indented);
|
||||
|
||||
public async Task<Account> ToAccountAsync()
|
||||
{
|
||||
var refreshToken = new RefreshToken(RefreshToken);
|
||||
|
||||
var authorize = new Authorize(Locale);
|
||||
var newToken = await authorize.RefreshAccessTokenAsync(refreshToken);
|
||||
AccessToken = newToken.TokenValue;
|
||||
AccessTokenExpires = newToken.Expires;
|
||||
|
||||
var api = new Api(this);
|
||||
var email = await api.GetEmailAsync();
|
||||
var account = new Account(email)
|
||||
{
|
||||
DecryptKey = ActivationBytes,
|
||||
AccountName = $"{email} - {Locale.Name}",
|
||||
IdentityTokens = new Identity(Locale)
|
||||
};
|
||||
|
||||
account.IdentityTokens.Update(
|
||||
await GetPrivateKeyAsync(),
|
||||
await GetAdpTokenAsync(),
|
||||
await GetAccessTokenAsync(),
|
||||
refreshToken,
|
||||
WebsiteCookies.Select(c => new KeyValuePair<string, string>(c.Key, c.Value)),
|
||||
DeviceSerialNumber,
|
||||
DeviceType,
|
||||
AmazonAccountId,
|
||||
DeviceInfo.DeviceName,
|
||||
StoreAuthenticationCookie);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public static Mkb79Auth FromAccount(Account account)
|
||||
=> new()
|
||||
{
|
||||
AccessToken = account.IdentityTokens.ExistingAccessToken.TokenValue,
|
||||
ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey,
|
||||
AdpToken = account.IdentityTokens.AdpToken.Value,
|
||||
CustomerInfo = new CustomerInfo
|
||||
{
|
||||
AccountPool = "Amazon",
|
||||
GivenName = string.Empty,
|
||||
HomeRegion = "NA",
|
||||
Name = string.Empty,
|
||||
UserId = account.IdentityTokens.AmazonAccountId
|
||||
},
|
||||
DeviceInfo = new DeviceInfo
|
||||
{
|
||||
DeviceName = account.IdentityTokens.DeviceName,
|
||||
DeviceSerialNumber = account.IdentityTokens.DeviceSerialNumber,
|
||||
DeviceType = account.IdentityTokens.DeviceType,
|
||||
},
|
||||
DevicePrivateKey = account.IdentityTokens.PrivateKey,
|
||||
AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires,
|
||||
LocaleCode = account.Locale.CountryCode,
|
||||
RefreshToken = account.IdentityTokens.RefreshToken.Value,
|
||||
StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie,
|
||||
WebsiteCookies = new(account.IdentityTokens.Cookies.ToKeyValuePair()),
|
||||
};
|
||||
}
|
||||
|
||||
public static class Serialize
|
||||
{
|
||||
public static string ToJson(this Mkb79Auth self)
|
||||
=> JObject.Parse(JsonConvert.SerializeObject(self, Converter.Settings)).ToString(Formatting.Indented);
|
||||
}
|
||||
|
||||
internal static class Converter
|
||||
{
|
||||
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
|
||||
{
|
||||
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
|
||||
DateParseHandling = DateParseHandling.None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,13 @@ namespace DataLayer.Configurations
|
||||
|
||||
entity.OwnsOne(b => b.Rating);
|
||||
|
||||
entity.Property(nameof(Book._audioFormat));
|
||||
//
|
||||
// CRUCIAL: ignore unmapped collections, even get-only
|
||||
//
|
||||
entity.Ignore(nameof(Book.Authors));
|
||||
entity.Ignore(nameof(Book.Narrators));
|
||||
entity.Ignore(nameof(Book.AudioFormat));
|
||||
//// these don't seem to matter
|
||||
//entity.Ignore(nameof(Book.AuthorNames));
|
||||
//entity.Ignore(nameof(Book.NarratorNames));
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<ApplicationIcon />
|
||||
<OutputType>Library</OutputType>
|
||||
<StartupObject />
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.0.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.4">
|
||||
<PackageReference Include="Dinah.Core" Version="5.1.0.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="5.0.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.4">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<None Update="migrate.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
61
Source/DataLayer/EfClasses/AudioFormat.cs
Normal file
61
Source/DataLayer/EfClasses/AudioFormat.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
internal enum AudioFormatEnum : long
|
||||
{
|
||||
//Defining the enum this way ensures that when comparing:
|
||||
//LC_128_44100_stereo > LC_64_44100_stereo > LC_64_22050_stereo > LC_64_22050_stereo
|
||||
//This matches how audible interprets these codecs when specifying quality using AudibleApi.DownloadQuality
|
||||
//I've never seen mono formats.
|
||||
Unknown = 0,
|
||||
LC_32_22050_stereo = (32L << 18) | (22050 << 2) | 2,
|
||||
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
|
||||
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
|
||||
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
|
||||
}
|
||||
|
||||
public class AudioFormat : IComparable<AudioFormat>, IComparable
|
||||
{
|
||||
internal int AudioFormatID { get; private set; }
|
||||
public int Bitrate { get; private init; }
|
||||
public int SampleRate { get; private init; }
|
||||
public int Channels { get; private init; }
|
||||
public bool IsValid => Bitrate != 0 && SampleRate != 0 && Channels != 0;
|
||||
|
||||
public static AudioFormat FromString(string formatStr)
|
||||
{
|
||||
if (Enum.TryParse(formatStr, ignoreCase: true, out AudioFormatEnum enumVal))
|
||||
return FromEnum(enumVal);
|
||||
return FromEnum(AudioFormatEnum.Unknown);
|
||||
}
|
||||
|
||||
internal static AudioFormat FromEnum(AudioFormatEnum enumVal)
|
||||
{
|
||||
var val = (long)enumVal;
|
||||
|
||||
return new()
|
||||
{
|
||||
Bitrate = (int)(val >> 18),
|
||||
SampleRate = (int)(val >> 2) & ushort.MaxValue,
|
||||
Channels = (int)(val & 3)
|
||||
};
|
||||
}
|
||||
internal AudioFormatEnum ToEnum()
|
||||
{
|
||||
var val = (AudioFormatEnum)(((long)Bitrate << 18) | ((long)SampleRate << 2) | (long)Channels);
|
||||
|
||||
return Enum.IsDefined(val) ?
|
||||
val : AudioFormatEnum.Unknown;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
=> IsValid ?
|
||||
$"{Bitrate} Kbps, {SampleRate / 1000d:F1} kHz, {(Channels == 2 ? "Stereo" : Channels)}" :
|
||||
"Unknown";
|
||||
|
||||
public int CompareTo(AudioFormat other) => ToEnum().CompareTo(other.ToEnum());
|
||||
|
||||
public int CompareTo(object obj) => CompareTo(obj as AudioFormat);
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,15 @@ namespace DataLayer
|
||||
}
|
||||
}
|
||||
|
||||
// enum will be easier than bool to extend later
|
||||
public enum ContentType { Unknown = 0, Product = 1, Episode = 2 }
|
||||
// enum will be easier than bool to extend later.
|
||||
public enum ContentType
|
||||
{
|
||||
Unknown = 0,
|
||||
Product = 1,
|
||||
Episode = 2,
|
||||
Parent = 4,
|
||||
}
|
||||
|
||||
|
||||
public class Book
|
||||
{
|
||||
@@ -32,8 +39,13 @@ namespace DataLayer
|
||||
public ContentType ContentType { get; private set; }
|
||||
public string Locale { get; private set; }
|
||||
|
||||
internal AudioFormatEnum _audioFormat;
|
||||
|
||||
public AudioFormat AudioFormat { get => AudioFormat.FromEnum(_audioFormat); set => _audioFormat = value.ToEnum(); }
|
||||
|
||||
// mutable
|
||||
public string PictureId { get; set; }
|
||||
public string PictureLarge { get; set; }
|
||||
|
||||
// book details
|
||||
public bool IsAbridged { get; private set; }
|
||||
@@ -42,27 +54,10 @@ namespace DataLayer
|
||||
// non-null. use "empty pattern"
|
||||
internal int CategoryId { get; private set; }
|
||||
public Category Category { get; private set; }
|
||||
public string[] CategoriesNames
|
||||
=> Category is null ? new string[0]
|
||||
: Category.ParentCategory is null ? new[] { Category.Name }
|
||||
: new[] { Category.ParentCategory.Name, Category.Name };
|
||||
public string[] CategoriesIds
|
||||
=> Category is null ? null
|
||||
: Category.ParentCategory is null ? new[] { Category.AudibleCategoryId }
|
||||
: new[] { Category.ParentCategory.AudibleCategoryId, Category.AudibleCategoryId };
|
||||
|
||||
public string TitleSortable => Formatters.GetSortName(Title);
|
||||
public string SeriesSortable => Formatters.GetSortName(SeriesNames);
|
||||
|
||||
// is owned, not optional 1:1
|
||||
public UserDefinedItem UserDefinedItem { get; private set; }
|
||||
|
||||
// UserDefinedItem convenience properties
|
||||
/// <summary>True if IsLiberated or Error. False if NotLiberated</summary>
|
||||
public bool Audio_Exists => UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated;
|
||||
/// <summary>True if exists and IsLiberated. Else false</summary>
|
||||
public bool PDF_Exists => UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
|
||||
|
||||
// is owned, not optional 1:1
|
||||
/// <summary>The product's aggregate community rating</summary>
|
||||
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
|
||||
@@ -124,11 +119,7 @@ namespace DataLayer
|
||||
.ToList();
|
||||
|
||||
public IEnumerable<Contributor> Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList();
|
||||
public string AuthorNames => string.Join(", ", Authors.Select(a => a.Name));
|
||||
|
||||
public IEnumerable<Contributor> Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList();
|
||||
public string NarratorNames => string.Join(", ", Narrators.Select(n => n.Name));
|
||||
|
||||
public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name;
|
||||
|
||||
public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext context = null)
|
||||
@@ -184,30 +175,6 @@ namespace DataLayer
|
||||
#region series
|
||||
private HashSet<SeriesBook> _seriesLink;
|
||||
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
|
||||
public string SeriesNames
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_seriesLink is null)
|
||||
return "";
|
||||
|
||||
// first: alphabetical by name
|
||||
var withNames = _seriesLink
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
|
||||
.Select(s => s.Series.Name)
|
||||
.OrderBy(a => a)
|
||||
.ToList();
|
||||
// then un-named are alpha by series id
|
||||
var nullNames = _seriesLink
|
||||
.Where(s => string.IsNullOrWhiteSpace(s.Series.Name))
|
||||
.Select(s => s.Series.AudibleSeriesId)
|
||||
.OrderBy(a => a)
|
||||
.ToList();
|
||||
|
||||
var all = withNames.Union(nullNames).ToList();
|
||||
return string.Join(", ", all);
|
||||
}
|
||||
}
|
||||
|
||||
public void UpsertSeries(Series series, string order, DbContext context = null)
|
||||
{
|
||||
@@ -229,7 +196,6 @@ namespace DataLayer
|
||||
#region supplements
|
||||
private HashSet<Supplement> _supplements;
|
||||
public IEnumerable<Supplement> Supplements => _supplements?.ToList();
|
||||
public bool HasPdf => Supplements.Any();
|
||||
|
||||
public void AddSupplementDownloadUrl(string url)
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace DataLayer
|
||||
public float StoryRating { get; private set; }
|
||||
|
||||
private Rating() { }
|
||||
internal Rating(float overallRating, float performanceRating, float storyRating)
|
||||
public Rating(float overallRating, float performanceRating, float storyRating)
|
||||
{
|
||||
OverallRating = overallRating;
|
||||
PerformanceRating = performanceRating;
|
||||
@@ -38,41 +38,6 @@ namespace DataLayer
|
||||
yield return StoryRating;
|
||||
}
|
||||
|
||||
public float FirstScore
|
||||
=> OverallRating > 0 ? OverallRating
|
||||
: PerformanceRating > 0 ? PerformanceRating
|
||||
: StoryRating;
|
||||
|
||||
/// <summary>character: ★</summary>
|
||||
const char STAR = '\u2605';
|
||||
/// <summary>character: ½</summary>
|
||||
const char HALF = '\u00BD';
|
||||
string getStars(float score)
|
||||
{
|
||||
var fullStars = (int)Math.Floor(score);
|
||||
|
||||
var starString = "".PadLeft(fullStars, STAR);
|
||||
|
||||
if (score - fullStars == 0.5f)
|
||||
starString += HALF;
|
||||
|
||||
return starString;
|
||||
}
|
||||
|
||||
public string ToStarString()
|
||||
{
|
||||
var items = new List<string>();
|
||||
|
||||
if (OverallRating > 0)
|
||||
items.Add($"Overall: {getStars(OverallRating)}");
|
||||
if (PerformanceRating > 0)
|
||||
items.Add($"Perform: {getStars(PerformanceRating)}");
|
||||
if (StoryRating > 0)
|
||||
items.Add($"Story: {getStars(StoryRating)}");
|
||||
|
||||
return string.Join("\r\n", items);
|
||||
}
|
||||
|
||||
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
|
||||
@@ -30,10 +30,6 @@ namespace DataLayer
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
Book = book;
|
||||
|
||||
// import previously saved tags
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
|
||||
Tags = LibationFileManager.TagsPersistence.GetTags(book.AudibleProductId);
|
||||
}
|
||||
|
||||
#region Tags
|
||||
@@ -141,9 +137,11 @@ namespace DataLayer
|
||||
get => _bookStatus;
|
||||
set
|
||||
{
|
||||
if (_bookStatus != value)
|
||||
{
|
||||
_bookStatus = value;
|
||||
// PartialDownload is a live/ephemeral status, not a persistent one. Do not store
|
||||
var displayStatus = value == LiberatedStatus.PartialDownload ? LiberatedStatus.NotLiberated : value;
|
||||
if (_bookStatus != displayStatus)
|
||||
{
|
||||
_bookStatus = displayStatus;
|
||||
OnItemChanged(nameof(BookStatus));
|
||||
}
|
||||
}
|
||||
|
||||
102
Source/DataLayer/EntityExtensions.cs
Normal file
102
Source/DataLayer/EntityExtensions.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
public static class EntityExtensions
|
||||
{
|
||||
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title);
|
||||
|
||||
public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name));
|
||||
public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name));
|
||||
|
||||
/// <summary>True if IsLiberated or Error. False if NotLiberated</summary>
|
||||
public static bool Audio_Exists(this Book book) => book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated;
|
||||
/// <summary>True if exists and IsLiberated. Else false</summary>
|
||||
public static bool PDF_Exists(this Book book) => book.UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
|
||||
|
||||
public static string SeriesSortable(this Book book) => Formatters.GetSortName(book.SeriesNames());
|
||||
public static bool HasPdf(this Book book) => book.Supplements.Any();
|
||||
public static string SeriesNames(this Book book)
|
||||
{
|
||||
if (book.SeriesLink is null)
|
||||
return "";
|
||||
|
||||
// first: alphabetical by name
|
||||
var withNames = book.SeriesLink
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
|
||||
.Select(s => s.Series.Name)
|
||||
.OrderBy(a => a)
|
||||
.ToList();
|
||||
// then un-named are alpha by series id
|
||||
var nullNames = book.SeriesLink
|
||||
.Where(s => string.IsNullOrWhiteSpace(s.Series.Name))
|
||||
.Select(s => s.Series.AudibleSeriesId)
|
||||
.OrderBy(a => a)
|
||||
.ToList();
|
||||
|
||||
var all = withNames.Union(nullNames).ToList();
|
||||
return string.Join(", ", all);
|
||||
}
|
||||
public static string[] CategoriesNames(this Book book)
|
||||
=> book.Category is null ? new string[0]
|
||||
: book.Category.ParentCategory is null ? new[] { book.Category.Name }
|
||||
: new[] { book.Category.ParentCategory.Name, book.Category.Name };
|
||||
public static string[] CategoriesIds(this Book book)
|
||||
=> book.Category is null ? null
|
||||
: book.Category.ParentCategory is null ? new[] { book.Category.AudibleCategoryId }
|
||||
: new[] { book.Category.ParentCategory.AudibleCategoryId, book.Category.AudibleCategoryId };
|
||||
|
||||
public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5)
|
||||
{
|
||||
if (libraryBooks is null || !libraryBooks.Any())
|
||||
return "";
|
||||
|
||||
max = Math.Max(max, 1);
|
||||
|
||||
var titles = libraryBooks.Select(lb => "- " + lb.Book.Title).ToList();
|
||||
var titlesAgg = titles.Take(max).Aggregate((a, b) => $"{a}\r\n{b}");
|
||||
if (titles.Count == max + 1)
|
||||
titlesAgg += $"\r\n\r\nand 1 other";
|
||||
else if (titles.Count > max + 1)
|
||||
titlesAgg += $"\r\n\r\nand {titles.Count - max } others";
|
||||
return titlesAgg;
|
||||
}
|
||||
|
||||
public static float FirstScore(this Rating rating)
|
||||
=> rating.OverallRating > 0 ? rating.OverallRating
|
||||
: rating.PerformanceRating > 0 ? rating.PerformanceRating
|
||||
: rating.StoryRating;
|
||||
public static string ToStarString(this Rating rating)
|
||||
{
|
||||
var items = new List<string>();
|
||||
|
||||
if (rating.OverallRating > 0)
|
||||
items.Add($"Overall: {getStars(rating.OverallRating)}");
|
||||
if (rating.PerformanceRating > 0)
|
||||
items.Add($"Perform: {getStars(rating.PerformanceRating)}");
|
||||
if (rating.StoryRating > 0)
|
||||
items.Add($"Story: {getStars(rating.StoryRating)}");
|
||||
|
||||
return string.Join("\r\n", items);
|
||||
}
|
||||
/// <summary>character: ★</summary>
|
||||
const char STAR = '\u2605';
|
||||
/// <summary>character: ½</summary>
|
||||
const char HALF = '\u00BD';
|
||||
private static string getStars(float score)
|
||||
{
|
||||
var fullStars = (int)Math.Floor(score);
|
||||
|
||||
var starString = new string(STAR, fullStars);
|
||||
|
||||
if (score - fullStars >= 0.25f)
|
||||
starString += HALF;
|
||||
|
||||
return starString;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
using DataLayer.Configurations;
|
||||
using Dinah.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
public class LibationContext : InterceptableDbContext
|
||||
public class LibationContext : DbContext
|
||||
{
|
||||
// IMPORTANT: USING DbSet<>
|
||||
// ========================
|
||||
// these run against the db. linq queries against these MUST be translatable to sql. primatives only. no POCOs or this error occurs:
|
||||
// Unable to create a constant value of type 'DataLayer.Contributor'. Only primitive types or enumeration types are supported in this context.
|
||||
// to use full object-linq, load and use local
|
||||
// to use full object-linq, load and use Local. HOWEVER, Local is only hashed/indexed on PK. All other searches are very slow
|
||||
// load full table:
|
||||
// List<Contributor> contributors = ...;
|
||||
// Contributors.Load();
|
||||
@@ -35,14 +34,6 @@ namespace DataLayer
|
||||
// see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring()
|
||||
internal LibationContext(DbContextOptions options) : base(options) { }
|
||||
|
||||
// called on each instantiation
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
AddInterceptor(new TagPersistenceInterceptor());
|
||||
|
||||
base.OnConfiguring(optionsBuilder);
|
||||
}
|
||||
|
||||
// typically only called once per execution; NOT once per instantiation
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
Binary file not shown.
394
Source/DataLayer/Migrations/20220510175257_AddPictureIDLargeMigration.Designer.cs
generated
Normal file
394
Source/DataLayer/Migrations/20220510175257_AddPictureIDLargeMigration.Designer.cs
generated
Normal file
@@ -0,0 +1,394 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20220510175257_AddPictureIDLargeMigration")]
|
||||
partial class AddPictureIDLargeMigration
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.4");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating");
|
||||
});
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
|
||||
b.Navigation("ParentCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
public partial class AddPictureIDLargeMigration : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PictureLarge",
|
||||
table: "Books",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PictureLarge",
|
||||
table: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
397
Source/DataLayer/Migrations/20220624214932_AddAudioFormat.Designer.cs
generated
Normal file
397
Source/DataLayer/Migrations/20220624214932_AddAudioFormat.Designer.cs
generated
Normal file
@@ -0,0 +1,397 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20220624214932_AddAudioFormat")]
|
||||
partial class AddAudioFormat
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("_audioFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating");
|
||||
});
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
|
||||
b.Navigation("ParentCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Source/DataLayer/Migrations/20220624214932_AddAudioFormat.cs
Normal file
26
Source/DataLayer/Migrations/20220624214932_AddAudioFormat.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
public partial class AddAudioFormat : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "_audioFormat",
|
||||
table: "Books",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "_audioFormat",
|
||||
table: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
@@ -13,8 +15,7 @@ namespace DataLayer.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "5.0.10");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
@@ -49,9 +50,15 @@ namespace DataLayer.Migrations
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("_audioFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
@@ -267,7 +274,7 @@ namespace DataLayer.Migrations
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem");
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
@@ -35,5 +35,17 @@ namespace DataLayer
|
||||
.Include(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
||||
.Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
|
||||
.Include(b => b.Category).ThenInclude(c => c.ParentCategory);
|
||||
|
||||
public static bool IsProduct(this Book book)
|
||||
=> book.ContentType is not ContentType.Episode and not ContentType.Parent;
|
||||
|
||||
public static bool IsEpisodeChild(this Book book)
|
||||
=> book.ContentType is ContentType.Episode;
|
||||
|
||||
public static bool IsEpisodeParent(this Book book)
|
||||
=> book.ContentType is ContentType.Parent;
|
||||
public static bool HasLiberated(this Book book)
|
||||
=> book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated ||
|
||||
book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@ namespace DataLayer
|
||||
// .GetLibrary()
|
||||
// .ToList();
|
||||
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context, bool includeParents = false)
|
||||
=> context
|
||||
.LibraryBooks
|
||||
.AsNoTrackingWithIdentityResolution()
|
||||
.GetLibrary()
|
||||
.AsEnumerable()
|
||||
.Where(lb => !lb.Book.IsEpisodeParent() || includeParents)
|
||||
.ToList();
|
||||
|
||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||
@@ -40,5 +42,59 @@ namespace DataLayer
|
||||
.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);
|
||||
|
||||
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
||||
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(s => libraryBooks.FindChildren(s));
|
||||
|
||||
public static IEnumerable<LibraryBook> FindOrphanedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
||||
=> libraryBooks
|
||||
.Where(lb => lb.Book.IsEpisodeChild())
|
||||
.ExceptBy(
|
||||
libraryBooks
|
||||
.ParentedEpisodes()
|
||||
.Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId);
|
||||
|
||||
#nullable enable
|
||||
public static LibraryBook? FindSeriesParent(this IEnumerable<LibraryBook> libraryBooks, LibraryBook seriesEpisode)
|
||||
{
|
||||
if (seriesEpisode.Book.SeriesLink is null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
//Parent books will always have exactly 1 SeriesBook due to how
|
||||
//they are imported in ApiExtended.getChildEpisodesAsync()
|
||||
return libraryBooks.FirstOrDefault(
|
||||
lb =>
|
||||
lb.Book.IsEpisodeParent() &&
|
||||
seriesEpisode.Book.SeriesLink.Any(
|
||||
s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId));
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
#nullable disable
|
||||
|
||||
public static IEnumerable<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
|
||||
=> bookList
|
||||
.Where(
|
||||
lb =>
|
||||
lb.Book.IsEpisodeChild() &&
|
||||
lb.Book.SeriesLink?
|
||||
.Any(
|
||||
s =>
|
||||
s.Series.AudibleSeriesId == parent.Book.AudibleProductId
|
||||
) == true
|
||||
).ToList();
|
||||
|
||||
public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
|
||||
=> bookList
|
||||
.Where(
|
||||
lb =>
|
||||
lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
using Dinah.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
internal class TagPersistenceInterceptor : IDbInterceptor
|
||||
{
|
||||
public void Executed(DbContext context) { }
|
||||
|
||||
public void Executing(DbContext context)
|
||||
{
|
||||
var tagsCollection
|
||||
= context
|
||||
.ChangeTracker
|
||||
.Entries()
|
||||
.Where(e => e.State.In(EntityState.Modified, EntityState.Added))
|
||||
.Select(e => e.Entity as UserDefinedItem)
|
||||
.Where(udi => udi is not null)
|
||||
// do NOT filter out entires with blank tags. blank is the valid way to show the absence of tags
|
||||
.Select(t => (t.Book.AudibleProductId, t.Tags))
|
||||
.ToList();
|
||||
|
||||
LibationFileManager.TagsPersistence.Save(tagsCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;"
|
||||
}
|
||||
}
|
||||
5
Source/DataLayer/migrate.json
Normal file
5
Source/DataLayer/migrate.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"LibationContext": "Data Source=LibationContext.db;Foreign Keys=False;"
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ namespace DtoImporterService
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
var contentType = item.IsEpisodes ? DataLayer.ContentType.Episode : DataLayer.ContentType.Product;
|
||||
var contentType = GetContentType(item);
|
||||
|
||||
// absence of authors is very rare, but possible
|
||||
if (!item.Authors?.Any() ?? true)
|
||||
@@ -84,7 +84,8 @@ namespace DtoImporterService
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
var authors = item
|
||||
.Authors
|
||||
.Select(a => contributorImporter.Cache[a.Name])
|
||||
.DistinctBy(a => a.Name)
|
||||
.Select(a => contributorImporter.Cache[a.Name])
|
||||
.ToList();
|
||||
|
||||
var narrators
|
||||
@@ -94,14 +95,16 @@ namespace DtoImporterService
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
: item
|
||||
.Narrators
|
||||
.Select(n => contributorImporter.Cache[n.Name])
|
||||
.DistinctBy(a => a.Name)
|
||||
.Select(n => contributorImporter.Cache[n.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 ?? "";)
|
||||
// after we support full arbitrary-depth category trees and multiple categories per book, the real impl will be something like this
|
||||
// var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
|
||||
var lastCategory
|
||||
= item.Categories.Length == 0 ? ""
|
||||
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
|
||||
@@ -161,9 +164,15 @@ namespace DtoImporterService
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
var codec = item.AvailableCodecs?.Max(f => AudioFormat.FromString(f.EnhancedCodec)) ?? new AudioFormat();
|
||||
book.AudioFormat = codec;
|
||||
|
||||
// set/update book-specific info which may have changed
|
||||
if (item.PictureId is not null)
|
||||
book.PictureId = item.PictureId;
|
||||
|
||||
if (item.PictureLarge is not null)
|
||||
book.PictureLarge = item.PictureLarge;
|
||||
|
||||
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
||||
|
||||
@@ -181,5 +190,15 @@ namespace DtoImporterService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DataLayer.ContentType GetContentType(Item item)
|
||||
{
|
||||
if (item.IsEpisodes)
|
||||
return DataLayer.ContentType.Episode;
|
||||
else if (item.IsSeriesParent)
|
||||
return DataLayer.ContentType.Parent;
|
||||
else
|
||||
return DataLayer.ContentType.Product;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ namespace DtoImporterService
|
||||
return hash.Count;
|
||||
}
|
||||
|
||||
private Contributor addContributor(string name, string id = null)
|
||||
private Contributor addContributor(string name, string id = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -108,6 +108,6 @@ namespace DtoImporterService
|
||||
Serilog.Log.Logger.Error(ex, "Error adding contributor. {@DebugInfo}", new { name, id });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -55,7 +55,7 @@ namespace DtoImporterService
|
||||
protected ItemsImporterBase(LibationContext context) : base(context) { }
|
||||
|
||||
protected abstract IValidator Validator { get; }
|
||||
public sealed override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems)
|
||||
public sealed override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems)
|
||||
=> Validator.Validate(importItems.Select(i => i.DtoItem));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ namespace DtoImporterService
|
||||
// just use the first
|
||||
var hash = newItems.ToDictionarySafe(dto => dto.DtoItem.ProductId);
|
||||
foreach (var kvp in hash)
|
||||
{
|
||||
{
|
||||
var newItem = kvp.Value;
|
||||
|
||||
var libraryBook = new LibraryBook(
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
using System;
|
||||
using LibationFileManager;
|
||||
using NAudio.Lame;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class AudioDecodable : Processable
|
||||
{
|
||||
public event EventHandler<Action<byte[]>> RequestCoverArt;
|
||||
public delegate byte[] RequestCoverArtHandler(object sender, EventArgs eventArgs);
|
||||
public event RequestCoverArtHandler 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();
|
||||
public abstract Task CancelAsync();
|
||||
|
||||
protected LameConfig GetLameOptions(Configuration config)
|
||||
{
|
||||
LameConfig lameConfig = new();
|
||||
lameConfig.Mode = MPEGMode.Mono;
|
||||
|
||||
if (config.LameTargetBitrate)
|
||||
{
|
||||
if (config.LameConstantBitrate)
|
||||
lameConfig.BitRate = config.LameBitrate;
|
||||
else
|
||||
{
|
||||
lameConfig.ABRRateKbps = config.LameBitrate;
|
||||
lameConfig.VBR = VBRMode.ABR;
|
||||
lameConfig.WriteVBRTag = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lameConfig.VBR = VBRMode.Default;
|
||||
lameConfig.VBRQuality = config.LameVBRQuality;
|
||||
lameConfig.WriteVBRTag = true;
|
||||
}
|
||||
return lameConfig;
|
||||
}
|
||||
protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title);
|
||||
protected void OnTitleDiscovered(object _, string title)
|
||||
{
|
||||
@@ -32,10 +60,10 @@ namespace FileLiberator
|
||||
NarratorsDiscovered?.Invoke(this, narrators);
|
||||
}
|
||||
|
||||
protected void OnRequestCoverArt(Action<byte[]> setCoverArtDel)
|
||||
protected byte[] OnRequestCoverArt()
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
|
||||
RequestCoverArt?.Invoke(this, setCoverArtDel);
|
||||
return RequestCoverArt?.Invoke(this, new());
|
||||
}
|
||||
|
||||
protected void OnCoverImageDiscovered(byte[] coverImage)
|
||||
|
||||
@@ -1,56 +1,57 @@
|
||||
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 class MultipartRenamer
|
||||
{
|
||||
private LibraryBook libraryBook { get; }
|
||||
public static class AudioFileStorageExt
|
||||
{
|
||||
/// <summary>
|
||||
/// DownloadDecryptBook:
|
||||
/// File path for where to move files into.
|
||||
/// Path: directory nested inside of Books directory
|
||||
/// File name: n/a
|
||||
/// </summary>
|
||||
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
{
|
||||
if (libraryBook.Book.IsEpisodeChild() && Configuration.Instance.SavePodcastsToParentFolder)
|
||||
{
|
||||
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
|
||||
if (series is not null)
|
||||
{
|
||||
var seriesParent = ApplicationServices.DbContexts.GetContext().GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
|
||||
|
||||
internal MultipartRenamer(LibraryBook libraryBook) => this.libraryBook = libraryBook;
|
||||
if (seriesParent is not null)
|
||||
{
|
||||
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto());
|
||||
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal string MultipartFilename(AaxDecrypter.MultiConvertFileProperties props)
|
||||
=> Templates.ChapterFile.GetFilename(libraryBook.ToDto(), props);
|
||||
}
|
||||
return Templates.Folder.GetFilename(libraryBook.ToDto());
|
||||
}
|
||||
|
||||
public static Func<AaxDecrypter.MultiConvertFileProperties, string> CreateMultipartRenamerFunc(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
=> new MultipartRenamer(libraryBook).MultipartFilename;
|
||||
/// <summary>
|
||||
/// DownloadDecryptBook:
|
||||
/// Path: in progress directory.
|
||||
/// File name: final file name.
|
||||
/// </summary>
|
||||
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
|
||||
|
||||
/// <summary>
|
||||
/// DownloadDecryptBook:
|
||||
/// File path for where to move files into.
|
||||
/// Path: directory nested inside of Books directory
|
||||
/// File name: n/a
|
||||
/// </summary>
|
||||
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
=> Templates.Folder.GetFilename(libraryBook.ToDto());
|
||||
/// <summary>
|
||||
/// PDF: audio file does not exist
|
||||
/// </summary>
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
|
||||
|
||||
/// <summary>
|
||||
/// DownloadDecryptBook:
|
||||
/// Path: in progress directory.
|
||||
/// File name: final file name.
|
||||
/// </summary>
|
||||
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
|
||||
|
||||
/// <summary>
|
||||
/// PDF: audio file does not exist
|
||||
/// </summary>
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
|
||||
|
||||
/// <summary>
|
||||
/// PDF: audio file already exists
|
||||
/// </summary>
|
||||
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
|
||||
}
|
||||
/// <summary>
|
||||
/// PDF: audio file already exists
|
||||
/// </summary>
|
||||
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
@@ -12,82 +12,102 @@ using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class ConvertToMp3 : AudioDecodable
|
||||
{
|
||||
private Mp4File m4bBook;
|
||||
public class ConvertToMp3 : AudioDecodable
|
||||
{
|
||||
public override string Name => "Convert to Mp3";
|
||||
private Mp4File m4bBook;
|
||||
|
||||
private long fileSize;
|
||||
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
||||
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
||||
|
||||
public override void Cancel() => m4bBook?.Cancel();
|
||||
public override Task CancelAsync() => m4bBook?.CancelAsync() ?? Task.CompletedTask;
|
||||
|
||||
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 static bool ValidateMp3(LibraryBook libraryBook)
|
||||
{
|
||||
var paths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId);
|
||||
return paths.Any(path => path?.ToString()?.ToLower()?.EndsWith(".m4b") == true && !File.Exists(Mp3FileName(path)));
|
||||
}
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnBegin(libraryBook);
|
||||
public override bool Validate(LibraryBook libraryBook) => ValidateMp3(libraryBook);
|
||||
|
||||
OnStreamingBegin($"Begin converting {libraryBook} to mp3");
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnBegin(libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
var m4bPath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
|
||||
m4bBook = new Mp4File(m4bPath, FileAccess.Read);
|
||||
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||
try
|
||||
{
|
||||
var m4bPaths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId);
|
||||
|
||||
fileSize = m4bBook.InputStream.Length;
|
||||
foreach (var m4bPath in m4bPaths)
|
||||
{
|
||||
var proposedMp3Path = Mp3FileName(m4bPath);
|
||||
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
|
||||
|
||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||
m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
||||
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||
|
||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
||||
fileSize = m4bBook.InputStream.Length;
|
||||
|
||||
var result = await Task.Run(() => m4bBook.ConvertToMp3(mp3File));
|
||||
m4bBook.InputStream.Close();
|
||||
mp3File.Close();
|
||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||
|
||||
var proposedMp3Path = Mp3FileName(m4bPath);
|
||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path);
|
||||
OnFileCreated(libraryBook, realMp3Path);
|
||||
var config = Configuration.Instance;
|
||||
var lameConfig = GetLameOptions(config);
|
||||
|
||||
var statusHandler = new StatusHandler();
|
||||
//Finishing configuring lame encoder.
|
||||
AaxDecrypter.MpegUtil.ConfigureLameOptions(
|
||||
m4bBook,
|
||||
lameConfig,
|
||||
config.LameDownsampleMono,
|
||||
config.LameMatchSourceBR);
|
||||
|
||||
if (result == ConversionResult.Failed)
|
||||
statusHandler.AddError("Conversion failed");
|
||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
||||
var result = await m4bBook.ConvertToMp3Async(mp3File, lameConfig);
|
||||
m4bBook.InputStream.Close();
|
||||
mp3File.Close();
|
||||
|
||||
return statusHandler;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted($"Completed converting to mp3: {libraryBook.Book.Title}");
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
if (result == ConversionResult.Failed)
|
||||
{
|
||||
FileUtility.SaferDelete(mp3File.Name);
|
||||
return new StatusHandler { "Conversion failed" };
|
||||
}
|
||||
else if (result == ConversionResult.Cancelled)
|
||||
{
|
||||
FileUtility.SaferDelete(mp3File.Name);
|
||||
return new StatusHandler { "Cancelled" };
|
||||
}
|
||||
|
||||
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
var duration = m4bBook.Duration;
|
||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters);
|
||||
OnFileCreated(libraryBook, realMp3Path);
|
||||
}
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
var duration = m4bBook.Duration;
|
||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
|
||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
OnStreamingProgressChanged(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = progressPercent,
|
||||
BytesReceived = (long)(fileSize * progressPercent),
|
||||
TotalBytesToReceive = fileSize
|
||||
});
|
||||
}
|
||||
}
|
||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
||||
|
||||
OnStreamingProgressChanged(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = progressPercent,
|
||||
BytesReceived = (long)(fileSize * progressPercent),
|
||||
TotalBytesToReceive = fileSize
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AaxDecrypter;
|
||||
using ApplicationServices;
|
||||
using AudibleApi;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
@@ -15,11 +16,12 @@ namespace FileLiberator
|
||||
{
|
||||
public class DownloadDecryptBook : AudioDecodable
|
||||
{
|
||||
public override string Name => "Download & Decrypt";
|
||||
private AudiobookDownloadBase abDownloader;
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists;
|
||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
||||
|
||||
public override void Cancel() => abDownloader?.Cancel();
|
||||
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
@@ -42,7 +44,7 @@ namespace FileLiberator
|
||||
|
||||
try
|
||||
{
|
||||
if (libraryBook.Book.Audio_Exists)
|
||||
if (libraryBook.Book.Audio_Exists())
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
|
||||
bool success = false;
|
||||
@@ -65,17 +67,26 @@ namespace FileLiberator
|
||||
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
|
||||
FileUtility.SaferDelete(tmpFile.Path);
|
||||
|
||||
return new StatusHandler { "Decrypt failed" };
|
||||
return abDownloader?.IsCanceled == true ?
|
||||
new StatusHandler { "Cancelled" } :
|
||||
new StatusHandler { "Decrypt failed" };
|
||||
}
|
||||
|
||||
// moves new files from temp dir to final dest
|
||||
var movedAudioFile = moveFilesToBooksDir(libraryBook, entries);
|
||||
// moves new files from temp dir to final dest.
|
||||
// This could take a few seconds if moving hundreds of files.
|
||||
var finalStorageDir = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
||||
|
||||
// decrypt failed
|
||||
if (!movedAudioFile)
|
||||
if (finalStorageDir is null)
|
||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||
|
||||
libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
|
||||
if (Configuration.Instance.DownloadCoverArt)
|
||||
downloadCoverArt(libraryBook);
|
||||
|
||||
// contains logic to check for config setting and OS
|
||||
WindowsDirectory.SetCoverAsFolderIcon(pictureId: libraryBook.Book.PictureId, directory: finalStorageDir);
|
||||
|
||||
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
@@ -87,56 +98,47 @@ namespace FileLiberator
|
||||
|
||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnStreamingBegin($"Begin decrypting {libraryBook}");
|
||||
var config = Configuration.Instance;
|
||||
|
||||
try
|
||||
downloadValidation(libraryBook);
|
||||
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||
var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
||||
|
||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||
|
||||
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
||||
else
|
||||
{
|
||||
var config = Configuration.Instance;
|
||||
AaxcDownloadConvertBase converter
|
||||
= config.SplitFilesByChapter ?
|
||||
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
||||
|
||||
downloadValidation(libraryBook);
|
||||
if (config.AllowLibationFixup)
|
||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
||||
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||
var audiobookDlLic = BuildDownloadOptions(config, contentLic);
|
||||
|
||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, audiobookDlLic.OutputFormat.ToString().ToLower());
|
||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||
|
||||
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic);
|
||||
else
|
||||
{
|
||||
AaxcDownloadConvertBase converter
|
||||
= config.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
|
||||
outFileName, cacheDir, audiobookDlLic,
|
||||
AudibleFileStorage.Audio.CreateMultipartRenamerFunc(libraryBook))
|
||||
: new AaxcDownloadSingleConverter(outFileName, cacheDir, audiobookDlLic);
|
||||
|
||||
if (config.AllowLibationFixup)
|
||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames);
|
||||
|
||||
abDownloader = converter;
|
||||
}
|
||||
|
||||
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||
|
||||
// REAL WORK DONE HERE
|
||||
var success = await Task.Run(abDownloader.Run);
|
||||
return success;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted($"Completed downloading and decrypting {libraryBook.Book.Title}");
|
||||
abDownloader = converter;
|
||||
}
|
||||
|
||||
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||
|
||||
// REAL WORK DONE HERE
|
||||
var success = await abDownloader.RunAsync();
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private static DownloadOptions BuildDownloadOptions(Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
||||
{
|
||||
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
|
||||
//I also assume that if DrmType != Adrm, the file will be an mp3.
|
||||
@@ -147,10 +149,14 @@ namespace FileLiberator
|
||||
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
|
||||
OutputFormat.Mp3 : OutputFormat.M4b;
|
||||
|
||||
var audiobookDlLic = new DownloadOptions
|
||||
long chapterStartMs = config.StripAudibleBrandAudio ?
|
||||
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
|
||||
|
||||
var dlOptions = new DownloadOptions
|
||||
(
|
||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
||||
Resources.USER_AGENT
|
||||
libraryBook,
|
||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
||||
Resources.USER_AGENT
|
||||
)
|
||||
{
|
||||
AudibleKey = contentLic?.Voucher?.Key,
|
||||
@@ -161,58 +167,154 @@ namespace FileLiberator
|
||||
StripUnabridged = config.AllowLibationFixup && config.StripUnabridged,
|
||||
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
|
||||
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
|
||||
CreateCueSheet = config.CreateCueSheet
|
||||
CreateCueSheet = config.CreateCueSheet,
|
||||
LameConfig = GetLameOptions(config),
|
||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||
FixupFile = config.AllowLibationFixup
|
||||
};
|
||||
|
||||
if (config.AllowLibationFixup || outputFormat == OutputFormat.Mp3)
|
||||
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
||||
|
||||
if (config.MergeOpeningAndEndCredits)
|
||||
combineCredits(chapters);
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
var chapter = chapters[i];
|
||||
long chapLenMs = chapter.LengthMs;
|
||||
|
||||
long startMs = audiobookDlLic.TrimOutputToChapterLength ?
|
||||
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
|
||||
if (i == 0)
|
||||
chapLenMs -= chapterStartMs;
|
||||
|
||||
audiobookDlLic.ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(startMs));
|
||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||
|
||||
for (int i = 0; i < contentLic.ContentMetadata.ChapterInfo.Chapters.Length; i++)
|
||||
{
|
||||
var chapter = contentLic.ContentMetadata.ChapterInfo.Chapters[i];
|
||||
long chapLenMs = chapter.LengthMs;
|
||||
|
||||
if (i == 0)
|
||||
chapLenMs -= startMs;
|
||||
|
||||
if (config.StripAudibleBrandAudio && i == contentLic.ContentMetadata.ChapterInfo.Chapters.Length - 1)
|
||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||
|
||||
audiobookDlLic.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||
}
|
||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||
}
|
||||
|
||||
NAudio.Lame.LameConfig lameConfig = new();
|
||||
return dlOptions;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
lameConfig.Mode = NAudio.Lame.MPEGMode.Mono;
|
||||
Flatten Audible's new hierarchical chapters, combining children into parents.
|
||||
|
||||
if (config.LameTargetBitrate)
|
||||
{
|
||||
if (config.LameConstantBitrate)
|
||||
lameConfig.BitRate = config.LameBitrate;
|
||||
Audible may deliver chapters like this:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 00:12 Book 1
|
||||
00:12 - 00:14 | Part 1
|
||||
00:14 - 01:40 | | Chapter 1
|
||||
01:40 - 03:20 | | Chapter 2
|
||||
03:20 - 03:22 | Part 2
|
||||
03:22 - 05:00 | | Chapter 3
|
||||
05:00 - 06:40 | | Chapter 4
|
||||
06:40 - 06:42 Book 2
|
||||
06:42 - 06:44 | Part 3
|
||||
06:44 - 08:20 | | Chapter 5
|
||||
08:20 - 10:00 | | Chapter 6
|
||||
10:00 - 10:02 | Part 4
|
||||
10:02 - 11:40 | | Chapter 7
|
||||
11:40 - 13:20 | | Chapter 8
|
||||
13:20 - 13:30 End Credits
|
||||
|
||||
And flattenChapters will combine them into this:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 01:40 Book 1: Part 1: Chapter 1
|
||||
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||
06:40 - 08:20 Book 2: Part 3: Chapter 5
|
||||
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||
13:20 - 13:40 End Credits
|
||||
|
||||
However, if one of the parent chapters is longer than 10000 milliseconds, it's kept as its own
|
||||
chapter. A duration longer than a few seconds implies that the chapter contains more than just
|
||||
the narrator saying the chapter title, so it should probably be preserved as a separate chapter.
|
||||
Using the example above, if "Book 1" was 15 seconds long and "Part 3" was 20 seconds long:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 00:25 Book 1
|
||||
00:25 - 00:27 | Part 1
|
||||
00:27 - 01:40 | | Chapter 1
|
||||
01:40 - 03:20 | | Chapter 2
|
||||
03:20 - 03:22 | Part 2
|
||||
03:22 - 05:00 | | Chapter 3
|
||||
05:00 - 06:40 | | Chapter 4
|
||||
06:40 - 06:42 Book 2
|
||||
06:42 - 07:02 | Part 3
|
||||
07:02 - 08:20 | | Chapter 5
|
||||
08:20 - 10:00 | | Chapter 6
|
||||
10:00 - 10:02 | Part 4
|
||||
10:02 - 11:40 | | Chapter 7
|
||||
11:40 - 13:20 | | Chapter 8
|
||||
13:20 - 13:30 End Credits
|
||||
|
||||
then flattenChapters will combine them into this:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 00:25 Book 1
|
||||
00:25 - 01:40 Book 1: Part 1: Chapter 1
|
||||
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||
06:40 - 07:02 Book 2: Part 3
|
||||
07:02 - 08:20 Book 2: Part 3: Chapter 5
|
||||
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||
13:20 - 13:40 End Credits
|
||||
|
||||
*/
|
||||
|
||||
public static List<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
|
||||
{
|
||||
List<AudibleApi.Common.Chapter> chaps = new();
|
||||
|
||||
foreach (var c in chapters)
|
||||
{
|
||||
if (c.Chapters is not null)
|
||||
{
|
||||
if (c.LengthMs < 10000)
|
||||
{
|
||||
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
||||
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
||||
c.Chapters[0].LengthMs += c.LengthMs;
|
||||
}
|
||||
else
|
||||
chaps.Add(c);
|
||||
|
||||
var children = flattenChapters(c.Chapters);
|
||||
|
||||
foreach (var child in children)
|
||||
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||
|
||||
chaps.AddRange(children);
|
||||
c.Chapters = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
lameConfig.ABRRateKbps = config.LameBitrate;
|
||||
lameConfig.VBR = NAudio.Lame.VBRMode.ABR;
|
||||
lameConfig.WriteVBRTag = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lameConfig.VBR = NAudio.Lame.VBRMode.Default;
|
||||
lameConfig.VBRQuality = config.LameVBRQuality;
|
||||
lameConfig.WriteVBRTag = true;
|
||||
chaps.Add(c);
|
||||
}
|
||||
return chaps;
|
||||
}
|
||||
|
||||
audiobookDlLic.LameConfig = lameConfig;
|
||||
|
||||
return audiobookDlLic;
|
||||
public static void combineCredits(IList<AudibleApi.Common.Chapter> chapters)
|
||||
{
|
||||
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
||||
{
|
||||
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
||||
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
||||
chapters[1].LengthMs += chapters[0].LengthMs;
|
||||
chapters.RemoveAt(0);
|
||||
}
|
||||
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
||||
{
|
||||
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
||||
chapters.Remove(chapters[^1]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void downloadValidation(LibraryBook libraryBook)
|
||||
@@ -242,12 +344,12 @@ namespace FileLiberator
|
||||
if (e is not null)
|
||||
OnCoverImageDiscovered(e);
|
||||
else if (Configuration.Instance.AllowLibationFixup)
|
||||
OnRequestCoverArt(abDownloader.SetCoverArt);
|
||||
abDownloader.SetCoverArt(OnRequestCoverArt());
|
||||
}
|
||||
|
||||
/// <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)
|
||||
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||
private static string moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||
{
|
||||
// create final directory. move each file into it
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
@@ -255,27 +357,51 @@ namespace FileLiberator
|
||||
|
||||
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||
|
||||
if (getFirstAudio() == default)
|
||||
return false;
|
||||
if (getFirstAudio() == default)
|
||||
return null;
|
||||
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var entry = entries[i];
|
||||
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);
|
||||
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
|
||||
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 };
|
||||
}
|
||||
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||
entries[i] = entry with { Path = realDest };
|
||||
}
|
||||
|
||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||
if (cue != default)
|
||||
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||
if (cue != default)
|
||||
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
||||
|
||||
AudibleFileStorage.Audio.Refresh();
|
||||
AudibleFileStorage.Audio.Refresh();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return destinationDir;
|
||||
}
|
||||
|
||||
private static void downloadCoverArt(LibraryBook libraryBook)
|
||||
{
|
||||
var coverPath = "[null]";
|
||||
|
||||
try
|
||||
{
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
||||
|
||||
if (File.Exists(coverPath))
|
||||
FileUtility.SaferDelete(coverPath);
|
||||
|
||||
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
|
||||
if (picBytes.Length > 0)
|
||||
File.WriteAllBytes(coverPath, picBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Failure to download cover art should not be considered a failure to download the book
|
||||
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +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>(OnStreamingProgressChanged);
|
||||
|
||||
OnStreamingBegin(proposedDownloadFilePath);
|
||||
|
||||
try
|
||||
{
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
OnFileCreated("Upgrade", actualDownloadedFilePath);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted(proposedDownloadFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
Source/FileLiberator/DownloadOptions.cs
Normal file
46
Source/FileLiberator/DownloadOptions.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using AaxDecrypter;
|
||||
using AAXClean;
|
||||
using Dinah.Core;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using FileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadOptions : IDownloadOptions
|
||||
{
|
||||
public LibraryBookDto LibraryBookDto { get; }
|
||||
public string DownloadUrl { get; }
|
||||
public string UserAgent { get; }
|
||||
public string AudibleKey { get; init; }
|
||||
public string AudibleIV { get; init; }
|
||||
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
|
||||
public bool TrimOutputToChapterLength { get; init; }
|
||||
public bool RetainEncryptedFile { get; init; }
|
||||
public bool StripUnabridged { get; init; }
|
||||
public bool CreateCueSheet { get; init; }
|
||||
public ChapterInfo ChapterInfo { get; init; }
|
||||
public bool FixupFile { get; init; }
|
||||
public NAudio.Lame.LameConfig LameConfig { get; init; }
|
||||
public bool Downsample { get; init; }
|
||||
public bool MatchSourceBitrate { get; init; }
|
||||
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
|
||||
|
||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
||||
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
|
||||
|
||||
public string GetMultipartTitleName(MultiConvertFileProperties props)
|
||||
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
|
||||
|
||||
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
|
||||
{
|
||||
LibraryBookDto = ArgumentValidator
|
||||
.EnsureNotNull(libraryBook, nameof(libraryBook))
|
||||
.ToDto();
|
||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
||||
|
||||
// no null/empty check for key/iv. unencrypted files do not have them
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using ApplicationServices;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
@@ -14,9 +15,10 @@ namespace FileLiberator
|
||||
{
|
||||
public class DownloadPdf : Processable
|
||||
{
|
||||
public override string Name => "Download Pdf";
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
|
||||
&& !libraryBook.Book.PDF_Exists;
|
||||
&& !libraryBook.Book.PDF_Exists();
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
@@ -28,7 +30,7 @@ namespace FileLiberator
|
||||
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||
var result = verifyDownload(actualDownloadedFilePath);
|
||||
|
||||
libraryBook.Book.UserDefinedItem.PdfStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
|
||||
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -55,27 +57,18 @@ namespace FileLiberator
|
||||
|
||||
private async Task<string> downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
|
||||
{
|
||||
OnStreamingBegin(proposedDownloadFilePath);
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
||||
|
||||
try
|
||||
{
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
||||
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
|
||||
|
||||
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
|
||||
var client = new HttpClient();
|
||||
|
||||
var client = new HttpClient();
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
OnFileCreated(libraryBook, actualDownloadedFilePath);
|
||||
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
OnFileCreated(libraryBook, actualDownloadedFilePath);
|
||||
|
||||
OnStatusUpdate(actualDownloadedFilePath);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted(proposedDownloadFilePath);
|
||||
}
|
||||
OnStatusUpdate(actualDownloadedFilePath);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
|
||||
private static StatusHandler verifyDownload(string actualDownloadedFilePath)
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" />
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,15 +5,22 @@ using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class Processable : Streamable
|
||||
public abstract class Processable
|
||||
{
|
||||
public abstract string Name { get; }
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
|
||||
/// <summary>General string message to display. DON'T rely on this for success, failure, or control logic</summary>
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
/// <summary>Fired when a file is successfully saved to disk</summary>
|
||||
public event EventHandler<(string id, string path)> FileCreated;
|
||||
public event EventHandler<DownloadProgress> StreamingProgressChanged;
|
||||
public event EventHandler<TimeSpan> StreamingTimeRemaining;
|
||||
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
@@ -27,7 +34,7 @@ namespace FileLiberator
|
||||
public IEnumerable<LibraryBook> GetValidLibraryBooks(IEnumerable<LibraryBook> library)
|
||||
=> library.Where(libraryBook =>
|
||||
Validate(libraryBook)
|
||||
&& (libraryBook.Book.ContentType != ContentType.Episode || LibationFileManager.Configuration.Instance.DownloadEpisodes)
|
||||
&& (!libraryBook.Book.IsEpisodeChild() || Configuration.Instance.DownloadEpisodes)
|
||||
);
|
||||
|
||||
public async Task<StatusHandler> ProcessSingleAsync(LibraryBook libraryBook, bool validate)
|
||||
@@ -49,7 +56,7 @@ namespace FileLiberator
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
|
||||
public async Task<StatusHandler> TryProcessAsync(LibraryBook libraryBook)
|
||||
=> Validate(libraryBook)
|
||||
? await ProcessAsync(libraryBook)
|
||||
@@ -67,6 +74,23 @@ namespace FileLiberator
|
||||
StatusUpdate?.Invoke(this, statusUpdate);
|
||||
}
|
||||
|
||||
protected void OnFileCreated(LibraryBook libraryBook, string path)
|
||||
{
|
||||
Serilog.Log.Logger.Information("File created {@DebugInfo}", new { Name = nameof(FileCreated), libraryBook.Book.AudibleProductId, path });
|
||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, path);
|
||||
FileCreated?.Invoke(this, (libraryBook.Book.AudibleProductId, path));
|
||||
}
|
||||
|
||||
protected void OnStreamingProgressChanged(DownloadProgress progress)
|
||||
=> OnStreamingProgressChanged(null, progress);
|
||||
protected void OnStreamingProgressChanged(object _, DownloadProgress progress)
|
||||
=> StreamingProgressChanged?.Invoke(this, progress);
|
||||
|
||||
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining)
|
||||
=> OnStreamingTimeRemaining(null, timeRemaining);
|
||||
protected void OnStreamingTimeRemaining(object _, TimeSpan timeRemaining)
|
||||
=> StreamingTimeRemaining?.Invoke(this, timeRemaining);
|
||||
|
||||
protected void OnCompleted(LibraryBook libraryBook)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = libraryBook.LogFriendly() });
|
||||
|
||||
@@ -1,47 +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) => OnStreamingProgressChanged(null, progress);
|
||||
protected void OnStreamingProgressChanged(object _, DownloadProgress progress)
|
||||
{
|
||||
StreamingProgressChanged?.Invoke(this, progress);
|
||||
}
|
||||
|
||||
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining) => OnStreamingTimeRemaining(null, timeRemaining);
|
||||
protected void OnStreamingTimeRemaining(object _, 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,11 @@ namespace FileLiberator
|
||||
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
||||
|
||||
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
||||
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order
|
||||
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order,
|
||||
|
||||
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
||||
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
||||
Channels = libraryBook.Book.AudioFormat.Channels,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace FileManager
|
||||
/// </summary>
|
||||
public class BackgroundFileSystem
|
||||
{
|
||||
public string RootDirectory { get; private set; }
|
||||
public LongPath RootDirectory { get; private set; }
|
||||
public string SearchPattern { get; private set; }
|
||||
public SearchOption SearchOption { get; private set; }
|
||||
|
||||
@@ -21,9 +21,9 @@ namespace FileManager
|
||||
private Task backgroundScanner { get; set; }
|
||||
|
||||
private object fsCacheLocker { get; } = new();
|
||||
private List<string> fsCache { get; } = new();
|
||||
private List<LongPath> fsCache { get; } = new();
|
||||
|
||||
public BackgroundFileSystem(string rootDirectory, string searchPattern, SearchOption searchOptions)
|
||||
public BackgroundFileSystem(LongPath rootDirectory, string searchPattern, SearchOption searchOptions)
|
||||
{
|
||||
RootDirectory = rootDirectory;
|
||||
SearchPattern = searchPattern;
|
||||
@@ -32,12 +32,18 @@ namespace FileManager
|
||||
Init();
|
||||
}
|
||||
|
||||
public string FindFile(System.Text.RegularExpressions.Regex regex)
|
||||
public LongPath FindFile(System.Text.RegularExpressions.Regex regex)
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
return fsCache.FirstOrDefault(s => regex.IsMatch(s));
|
||||
}
|
||||
|
||||
public List<LongPath> FindFiles(System.Text.RegularExpressions.Regex regex)
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
return fsCache.Where(s => regex.IsMatch(s)).ToList();
|
||||
}
|
||||
|
||||
public void RefreshFiles()
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
@@ -73,8 +79,13 @@ namespace FileManager
|
||||
//Stop raising events
|
||||
fileSystemWatcher?.Dispose();
|
||||
|
||||
//Calling CompleteAdding() will cause background scanner to terminate.
|
||||
directoryChangesEvents?.CompleteAdding();
|
||||
try
|
||||
{
|
||||
//Calling CompleteAdding() will cause background scanner to terminate.
|
||||
directoryChangesEvents?.CompleteAdding();
|
||||
}
|
||||
// if directoryChangesEvents is non-null and isDisposed, this exception is thrown. there's no other way to check >:(
|
||||
catch (ObjectDisposedException) { }
|
||||
|
||||
//Wait for background scanner to terminate before reinitializing.
|
||||
backgroundScanner?.Wait();
|
||||
@@ -124,27 +135,33 @@ namespace FileManager
|
||||
}
|
||||
}
|
||||
|
||||
private void RemovePath(string path)
|
||||
private void RemovePath(LongPath path)
|
||||
{
|
||||
var pathsToRemove = fsCache.Where(p => p.StartsWith(path)).ToArray();
|
||||
path = path.LongPathName;
|
||||
var pathsToRemove = fsCache.Where(p => ((string)p).StartsWith(path)).ToArray();
|
||||
|
||||
foreach (var p in pathsToRemove)
|
||||
fsCache.Remove(p);
|
||||
}
|
||||
|
||||
private void AddPath(string path)
|
||||
private void AddPath(LongPath path)
|
||||
{
|
||||
path = path.LongPathName;
|
||||
if (!File.Exists(path) && !Directory.Exists(path))
|
||||
return;
|
||||
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
|
||||
AddUniqueFiles(FileUtility.SaferEnumerateFiles(path, SearchPattern, SearchOption));
|
||||
else
|
||||
AddUniqueFile(path);
|
||||
}
|
||||
private void AddUniqueFiles(IEnumerable<string> newFiles)
|
||||
|
||||
private void AddUniqueFiles(IEnumerable<LongPath> newFiles)
|
||||
{
|
||||
foreach (var file in newFiles)
|
||||
AddUniqueFile(file);
|
||||
}
|
||||
private void AddUniqueFile(string newFile)
|
||||
|
||||
private void AddUniqueFile(LongPath newFile)
|
||||
{
|
||||
if (!fsCache.Contains(newFile))
|
||||
fsCache.Add(newFile);
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="4.2.0.1" />
|
||||
<PackageReference Include="Dinah.Core" Version="5.1.0.1" />
|
||||
<PackageReference Include="Polly" Version="7.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,64 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
using System.Text;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
||||
public class FileNamingTemplate
|
||||
{
|
||||
/// <summary>Proposed full file path. May contain optional html-styled template tags. Eg: <name></summary>
|
||||
public string Template { get; }
|
||||
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
||||
public class FileNamingTemplate : NamingTemplate
|
||||
{
|
||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
||||
public FileNamingTemplate(string template) : base(template) { }
|
||||
|
||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
||||
public FileNamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
/// <summary>Generate a valid path for this file or directory</summary>
|
||||
public LongPath GetFilePath(ReplacementCharacters replacements, bool returnFirstExisting = false)
|
||||
{
|
||||
string fileName =
|
||||
Template.EndsWith(Path.DirectorySeparatorChar) || Template.EndsWith(Path.AltDirectorySeparatorChar) ?
|
||||
FileUtility.RemoveLastCharacter(Template) :
|
||||
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>();
|
||||
List<string> pathParts = new();
|
||||
|
||||
/// <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);
|
||||
var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value, replacements));
|
||||
|
||||
/// <summary>If set, truncate each parameter replacement to this many characters. Default 50</summary>
|
||||
public int? ParameterMaxSize { get; set; } = 50;
|
||||
while (!string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
var file = Path.GetFileName(fileName);
|
||||
|
||||
/// <summary>Optional step 2: Replace all illegal characters with this. Default=<see cref="string.Empty"/></summary>
|
||||
public string IllegalCharacterReplacements { get; set; }
|
||||
if (Path.IsPathRooted(Template) && file == string.Empty)
|
||||
{
|
||||
pathParts.Add(fileName);
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
pathParts.Add(file);
|
||||
fileName = Path.GetDirectoryName(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Generate a valid path for this file or directory</summary>
|
||||
public string GetFilePath(bool returnFirstExisting = false)
|
||||
{
|
||||
var filename = Template;
|
||||
pathParts.Reverse();
|
||||
var fileNamePart = pathParts[^1];
|
||||
pathParts.Remove(fileNamePart);
|
||||
|
||||
foreach (var r in ParameterReplacements)
|
||||
filename = filename.Replace($"<{formatKey(r.Key)}>", formatValue(r.Value));
|
||||
LongPath directory = Path.Join(pathParts.Select(p => replaceFileName(p, paramReplacements, LongPath.MaxFilenameLength)).ToArray());
|
||||
|
||||
return FileUtility.GetValidFilename(filename, IllegalCharacterReplacements, returnFirstExisting);
|
||||
}
|
||||
//If file already exists, GetValidFilename will append " (n)" to the filename.
|
||||
//This could cause the filename length to exceed MaxFilenameLength, so reduce
|
||||
//allowable filename length by 5 chars, allowing for up to 99 duplicates.
|
||||
return FileUtility.GetValidFilename(Path.Join(directory, replaceFileName(fileNamePart, paramReplacements, LongPath.MaxFilenameLength - 5)), replacements, returnFirstExisting);
|
||||
}
|
||||
|
||||
private static string formatKey(string key)
|
||||
=> key
|
||||
.Replace("<", "")
|
||||
.Replace(">", "");
|
||||
private static string replaceFileName(string filename, Dictionary<string,string> paramReplacements, int maxFilenameLength)
|
||||
{
|
||||
List<StringBuilder> filenameParts = new();
|
||||
//Build the filename in parts, replacing replacement parameters with
|
||||
//their values, and storing the parts in a list.
|
||||
while (!string.IsNullOrEmpty(filename))
|
||||
{
|
||||
int openIndex = filename.IndexOf('<');
|
||||
int closeIndex = filename.IndexOf('>');
|
||||
|
||||
private string formatValue(object value)
|
||||
{
|
||||
if (value is null)
|
||||
return "";
|
||||
if (openIndex == 0 && closeIndex > 0)
|
||||
{
|
||||
var key = filename[..(closeIndex + 1)];
|
||||
|
||||
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
|
||||
// Esp important for file templates.
|
||||
var val = value
|
||||
.ToString()
|
||||
.Replace($"{System.IO.Path.DirectorySeparatorChar}", IllegalCharacterReplacements)
|
||||
.Replace($"{System.IO.Path.AltDirectorySeparatorChar}", IllegalCharacterReplacements);
|
||||
return
|
||||
ParameterMaxSize.HasValue && ParameterMaxSize.Value > 0
|
||||
? val.Truncate(ParameterMaxSize.Value)
|
||||
: val;
|
||||
}
|
||||
}
|
||||
if (paramReplacements.ContainsKey(key))
|
||||
filenameParts.Add(new StringBuilder(paramReplacements[key]));
|
||||
else
|
||||
filenameParts.Add(new StringBuilder(key));
|
||||
|
||||
filename = filename[(closeIndex + 1)..];
|
||||
}
|
||||
else if (openIndex > 0 && closeIndex > openIndex)
|
||||
{
|
||||
var other = filename[..openIndex];
|
||||
filenameParts.Add(new StringBuilder(other));
|
||||
filename = filename[openIndex..];
|
||||
}
|
||||
else
|
||||
{
|
||||
filenameParts.Add(new StringBuilder(filename));
|
||||
filename = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
//Remove 1 character from the end of the longest filename part until
|
||||
//the total filename is less than max filename length
|
||||
while (filenameParts.Sum(p => p.Length) > maxFilenameLength)
|
||||
{
|
||||
int maxLength = filenameParts.Max(p => p.Length);
|
||||
var maxEntry = filenameParts.First(p => p.Length == maxLength);
|
||||
|
||||
maxEntry.Remove(maxLength - 1, 1);
|
||||
}
|
||||
return string.Join("", filenameParts);
|
||||
}
|
||||
|
||||
private static string formatValue(object value, ReplacementCharacters replacements)
|
||||
{
|
||||
if (value is null)
|
||||
return "";
|
||||
|
||||
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
|
||||
// Esp important for file templates.
|
||||
return replacements.ReplaceInvalidFilenameChars(value.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,264 +9,230 @@ 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('.');
|
||||
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}");
|
||||
/// <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');
|
||||
}
|
||||
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 = "", bool returnFirstExisting = false)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
/// <summary>
|
||||
/// Ensure valid file name path:
|
||||
/// <br/>- remove invalid chars
|
||||
/// <br/>- ensure uniqueness
|
||||
/// <br/>- enforce max file length
|
||||
/// </summary>
|
||||
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, bool returnFirstExisting = false)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
|
||||
// remove invalid chars
|
||||
path = GetSafePath(path, illegalCharacterReplacements);
|
||||
// remove invalid chars
|
||||
path = GetSafePath(path, replacements);
|
||||
|
||||
// ensure uniqueness and check lengths
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
dir = dir.Truncate(MAX_DIRECTORY_LENGTH);
|
||||
// ensure uniqueness and check lengths
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
dir = dir?.Truncate(LongPath.MaxDirectoryLength) ?? string.Empty;
|
||||
|
||||
var filename = Path.GetFileNameWithoutExtension(path);
|
||||
var fileStem = Path.Combine(dir, filename);
|
||||
var extension = Path.GetExtension(path);
|
||||
|
||||
var extension = Path.GetExtension(path);
|
||||
var filename = Path.GetFileNameWithoutExtension(path).Truncate(LongPath.MaxFilenameLength - extension.Length);
|
||||
var fileStem = Path.Combine(dir, filename);
|
||||
|
||||
var fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - extension.Length) + extension;
|
||||
|
||||
fullfilename = removeInvalidWhitespace(fullfilename);
|
||||
var fullfilename = fileStem.Truncate(LongPath.MaxPathLength - extension.Length) + extension;
|
||||
|
||||
var i = 0;
|
||||
while (File.Exists(fullfilename) && !returnFirstExisting)
|
||||
{
|
||||
var increm = $" ({++i})";
|
||||
fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - increm.Length - extension.Length) + increm + extension;
|
||||
}
|
||||
fullfilename = removeInvalidWhitespace(fullfilename);
|
||||
|
||||
return fullfilename;
|
||||
}
|
||||
var i = 0;
|
||||
while (File.Exists(fullfilename) && !returnFirstExisting)
|
||||
{
|
||||
var increm = $" ({++i})";
|
||||
fullfilename = fileStem.Truncate(LongPath.MaxPathLength - increm.Length - extension.Length) + increm + extension;
|
||||
}
|
||||
|
||||
// GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/'
|
||||
return fullfilename;
|
||||
}
|
||||
|
||||
/// <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 characters which are invalid file name characters will be retained: '\\', '/'</summary>
|
||||
public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
|
||||
/// <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 pathNoPrefix = path.PathWithoutPrefix;
|
||||
|
||||
path = replaceInvalidChars(path, illegalCharacterReplacements);
|
||||
path = standardizeSlashes(path);
|
||||
path = replaceColons(path, illegalCharacterReplacements);
|
||||
path = removeDoubleSlashes(path);
|
||||
pathNoPrefix = replacements.ReplaceInvalidPathChars(pathNoPrefix);
|
||||
pathNoPrefix = removeDoubleSlashes(pathNoPrefix);
|
||||
|
||||
return path;
|
||||
}
|
||||
return pathNoPrefix;
|
||||
}
|
||||
|
||||
private static char[] invalidChars { get; } = Path.GetInvalidPathChars().Union(new[] {
|
||||
'*', '?',
|
||||
// these are weird. If you run Path.GetInvalidPathChars() in Visual Studio's "C# Interactive", then these characters are included.
|
||||
// In live code, Path.GetInvalidPathChars() does not include them
|
||||
'"', '<', '>'
|
||||
}).ToArray();
|
||||
private static string replaceInvalidChars(string path, string illegalCharacterReplacements)
|
||||
=> string.Join(illegalCharacterReplacements ?? "", path.Split(invalidChars));
|
||||
private static string removeDoubleSlashes(string path)
|
||||
{
|
||||
if (path.Length < 2)
|
||||
return path;
|
||||
|
||||
private static string standardizeSlashes(string path)
|
||||
=> path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
// exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1
|
||||
|
||||
private static string replaceColons(string path, string illegalCharacterReplacements)
|
||||
{
|
||||
// replace all colons except within the first 2 chars
|
||||
var builder = new System.Text.StringBuilder();
|
||||
for (var i = 0; i < path.Length; i++)
|
||||
{
|
||||
var c = path[i];
|
||||
if (i >= 2 && c == ':')
|
||||
builder.Append(illegalCharacterReplacements);
|
||||
else
|
||||
builder.Append(c);
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
var remainder = path[1..];
|
||||
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||
while (remainder.Contains(dblSeparator))
|
||||
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
|
||||
|
||||
private static string removeDoubleSlashes(string path)
|
||||
{
|
||||
if (path.Length < 2)
|
||||
return path;
|
||||
return path[0] + remainder;
|
||||
}
|
||||
|
||||
// exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1
|
||||
private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*";
|
||||
private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
|
||||
|
||||
var remainder = path[1..];
|
||||
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||
while (remainder.Contains(dblSeparator))
|
||||
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
|
||||
/// <summary>no part of the path may begin or end in whitespace</summary>
|
||||
private static string removeInvalidWhitespace(string fullfilename)
|
||||
{
|
||||
// no whitespace at beginning or end
|
||||
// replace whitespace around path slashes
|
||||
// regex (with space added for clarity)
|
||||
// \s* \\ \s* => \
|
||||
// no ending dots. beginning dots are valid
|
||||
|
||||
return path[0] + remainder;
|
||||
}
|
||||
// regex is easier by ending with separator
|
||||
fullfilename += Path.DirectorySeparatorChar;
|
||||
fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, Path.DirectorySeparatorChar.ToString());
|
||||
// take separator back off
|
||||
fullfilename = RemoveLastCharacter(fullfilename);
|
||||
|
||||
private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*";
|
||||
private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
|
||||
fullfilename = removeDoubleSlashes(fullfilename);
|
||||
return fullfilename;
|
||||
}
|
||||
|
||||
/// <summary>no part of the path may begin or end in whitespace</summary>
|
||||
private static string removeInvalidWhitespace(string fullfilename)
|
||||
{
|
||||
// no whitespace at beginning or end
|
||||
// replace whitespace around path slashes
|
||||
// regex (with space added for clarity)
|
||||
// \s* \\ \s* => \
|
||||
// no ending dots. beginning dots are valid
|
||||
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
|
||||
|
||||
// regex is easier by ending with separator
|
||||
fullfilename += Path.DirectorySeparatorChar;
|
||||
fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, Path.DirectorySeparatorChar.ToString());
|
||||
// take seperator back off
|
||||
fullfilename = RemoveLastCharacter(fullfilename);
|
||||
/// <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(LongPath source, LongPath destination, ReplacementCharacters replacements)
|
||||
{
|
||||
destination = GetValidFilename(destination, replacements);
|
||||
SaferMove(source, destination);
|
||||
return destination;
|
||||
}
|
||||
|
||||
fullfilename = removeDoubleSlashes(fullfilename);
|
||||
return fullfilename;
|
||||
}
|
||||
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);
|
||||
|
||||
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
|
||||
/// <summary>Delete file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferDelete(LongPath source)
|
||||
=> retryPolicy.Execute(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source });
|
||||
File.Delete(source);
|
||||
Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
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>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferMove(LongPath source, LongPath destination)
|
||||
=> retryPolicy.Execute(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
Serilog.Log.Logger.Debug("No file to move: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
|
||||
/// <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))
|
||||
{
|
||||
Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
SaferDelete(destination);
|
||||
|
||||
Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source });
|
||||
File.Delete(source);
|
||||
Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
var dir = Path.GetDirectoryName(destination);
|
||||
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
/// <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))
|
||||
{
|
||||
Serilog.Log.Logger.Debug("No file to move: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
Serilog.Log.Logger.Debug("Attempt to move file: {@DebugText}", new { source, destination });
|
||||
File.Move(source, destination);
|
||||
Serilog.Log.Logger.Information("File successfully moved: {@DebugText}", new { source, destination });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to move file: {@DebugText}", new { source, destination });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
SaferDelete(destination);
|
||||
/// <summary>
|
||||
/// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException
|
||||
/// </summary>
|
||||
/// <param name="rootPath">Starting directory</param>
|
||||
/// <param name="patternMatch">Filename pattern match</param>
|
||||
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
|
||||
/// <returns>List of files</returns>
|
||||
public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
||||
{
|
||||
var foundFiles = Enumerable.Empty<LongPath>();
|
||||
|
||||
var dir = Path.GetDirectoryName(destination);
|
||||
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
|
||||
Directory.CreateDirectory(dir);
|
||||
if (searchOption == SearchOption.AllDirectories)
|
||||
{
|
||||
try
|
||||
{
|
||||
IEnumerable <LongPath> subDirs = Directory.EnumerateDirectories(path).Select(p => (LongPath)p);
|
||||
// Add files in subdirectories recursively to the list
|
||||
foreach (string dir in subDirs)
|
||||
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (PathTooLongException) { }
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Attempt to move file: {@DebugText}", new { source, destination });
|
||||
File.Move(source, destination);
|
||||
Serilog.Log.Logger.Information("File successfully moved: {@DebugText}", new { source, destination });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to move file: {@DebugText}", new { source, destination });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
try
|
||||
{
|
||||
// Add files from the current directory
|
||||
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern).Select(f => (LongPath)f));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
|
||||
/// <summary>
|
||||
/// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException
|
||||
/// </summary>
|
||||
/// <param name="rootPath">Starting directory</param>
|
||||
/// <param name="patternMatch">Filename pattern match</param>
|
||||
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
|
||||
/// <returns>List of files</returns>
|
||||
public static IEnumerable<string> SaferEnumerateFiles(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
||||
{
|
||||
var foundFiles = Enumerable.Empty<string>();
|
||||
|
||||
if (searchOption == SearchOption.AllDirectories)
|
||||
{
|
||||
try
|
||||
{
|
||||
IEnumerable<string> subDirs = Directory.EnumerateDirectories(path);
|
||||
// Add files in subdirectories recursively to the list
|
||||
foreach (string dir in subDirs)
|
||||
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (PathTooLongException) { }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Add files from the current directory
|
||||
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
|
||||
return foundFiles;
|
||||
}
|
||||
}
|
||||
return foundFiles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
134
Source/FileManager/LongPath.cs
Normal file
134
Source/FileManager/LongPath.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public class LongPath
|
||||
{
|
||||
//https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd
|
||||
|
||||
public const int MaxDirectoryLength = MaxPathLength - 13;
|
||||
public const int MaxPathLength = short.MaxValue;
|
||||
public const int MaxFilenameLength = 255;
|
||||
|
||||
private const int MAX_PATH = 260;
|
||||
private const string LONG_PATH_PREFIX = "\\\\?\\";
|
||||
|
||||
public string Path { get; init; }
|
||||
public override string ToString() => Path;
|
||||
|
||||
private static readonly PlatformID PlatformID = Environment.OSVersion.Platform;
|
||||
|
||||
|
||||
public static implicit operator LongPath(string path)
|
||||
{
|
||||
if (PlatformID is PlatformID.Unix) return new LongPath { Path = path };
|
||||
|
||||
if (path is null) return null;
|
||||
|
||||
//File I/O functions in the Windows API convert "/" to "\" as part of converting
|
||||
//the name to an NT-style name, except when using the "\\?\" prefix
|
||||
path = path.Replace(System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar);
|
||||
|
||||
if (path.StartsWith(LONG_PATH_PREFIX))
|
||||
return new LongPath { Path = path };
|
||||
else if ((path.Length > 2 && path[1] == ':') || path.StartsWith("UNC\\"))
|
||||
return new LongPath { Path = LONG_PATH_PREFIX + path };
|
||||
else if (path.StartsWith("\\\\"))
|
||||
//The "\\?\" prefix can also be used with paths constructed according to the
|
||||
//universal naming convention (UNC). To specify such a path using UNC, use
|
||||
//the "\\?\UNC\" prefix.
|
||||
return new LongPath { Path = LONG_PATH_PREFIX + "UNC\\" + path.Substring(2) };
|
||||
else
|
||||
{
|
||||
//These prefixes are not used as part of the path itself. They indicate that
|
||||
//the path should be passed to the system with minimal modification, which
|
||||
//means that you cannot use forward slashes to represent path separators, or
|
||||
//a period to represent the current directory, or double dots to represent the
|
||||
//parent directory. Because you cannot use the "\\?\" prefix with a relative
|
||||
//path, relative paths are always limited to a total of MAX_PATH characters.
|
||||
if (path.Length > MAX_PATH)
|
||||
throw new System.IO.PathTooLongException();
|
||||
return new LongPath { Path = path };
|
||||
}
|
||||
}
|
||||
|
||||
public static implicit operator string(LongPath path) => path?.Path;
|
||||
|
||||
[JsonIgnore]
|
||||
public string ShortPathName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (PlatformID is PlatformID.Unix) return Path;
|
||||
|
||||
//Short Path names are useful for navigating to the file in windows explorer,
|
||||
//which will not recognize paths longer than MAX_PATH. Short path names are not
|
||||
//always enabled on every volume. So to check if a volume enables short path
|
||||
//names (aka 8dot3 names), run the following command from an elevated command
|
||||
//prompt:
|
||||
//
|
||||
// fsutil 8dot3name query c:
|
||||
//
|
||||
//It will say:
|
||||
//
|
||||
// "Based on the above settings, 8dot3 name creation is [enabled/disabled] on c:"
|
||||
//
|
||||
//To enable short names on a volume on the system, run the following command
|
||||
//from an elevated command prompt:
|
||||
//
|
||||
// fsutil 8dot3name set c: 0
|
||||
//
|
||||
//or for all volumes on the system:
|
||||
//
|
||||
// fsutil 8dot3name set 0
|
||||
//
|
||||
//Note that after enabling 8dot3 names on a volume, they will only be available
|
||||
//for newly-created entries in ther file system. Existing entries made while
|
||||
//8dot3 names were disabled will not be reachable by short paths.
|
||||
|
||||
if (Path is null) return null;
|
||||
|
||||
StringBuilder shortPathBuffer = new(MaxPathLength);
|
||||
GetShortPathName(Path, shortPathBuffer, MaxPathLength);
|
||||
return shortPathBuffer.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string LongPathName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (PlatformID is PlatformID.Unix) return Path;
|
||||
if (Path is null) return null;
|
||||
|
||||
StringBuilder longPathBuffer = new(MaxPathLength);
|
||||
GetLongPathName(Path, longPathBuffer, MaxPathLength);
|
||||
return longPathBuffer.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string PathWithoutPrefix
|
||||
{
|
||||
get
|
||||
{
|
||||
if (PlatformID is PlatformID.Unix) return Path;
|
||||
return
|
||||
Path?.StartsWith(LONG_PATH_PREFIX) == true ? Path.Remove(0, LONG_PATH_PREFIX.Length)
|
||||
:Path;
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetShortPathName([MarshalAs(UnmanagedType.LPWStr)] string path, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder shortPath, int shortPathLength);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetLongPathName([MarshalAs(UnmanagedType.LPWStr)] string lpszShortPath, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpszLongPath, int cchBuffer);
|
||||
|
||||
}
|
||||
}
|
||||
20
Source/FileManager/MetadataNamingTemplate.cs
Normal file
20
Source/FileManager/MetadataNamingTemplate.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public class MetadataNamingTemplate : NamingTemplate
|
||||
{
|
||||
public MetadataNamingTemplate(string template) : base(template) { }
|
||||
|
||||
public string GetTagContents()
|
||||
{
|
||||
var tagValue = Template;
|
||||
|
||||
foreach (var r in ParameterReplacements)
|
||||
tagValue = tagValue.Replace($"<{formatKey(r.Key)}>", r.Value?.ToString() ?? "");
|
||||
|
||||
return tagValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Source/FileManager/NamingTemplate.cs
Normal file
28
Source/FileManager/NamingTemplate.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public class NamingTemplate
|
||||
{
|
||||
/// <summary>Proposed full name. 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 NamingTemplate(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);
|
||||
|
||||
protected static string formatKey(string key)
|
||||
=> key
|
||||
.Replace("<", "")
|
||||
.Replace(">", "");
|
||||
}
|
||||
}
|
||||
278
Source/FileManager/ReplacementCharacters.cs
Normal file
278
Source/FileManager/ReplacementCharacters.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public class Replacement : ICloneable
|
||||
{
|
||||
public const int FIXED_COUNT = 6;
|
||||
|
||||
internal const char QUOTE_MARK = '"';
|
||||
[JsonIgnore] public bool Mandatory { get; internal set; }
|
||||
[JsonProperty] public char CharacterToReplace { get; private set; }
|
||||
[JsonProperty] public string ReplacementString { get; set; }
|
||||
[JsonProperty] public string Description { get; private set; }
|
||||
public override string ToString() => $"{CharacterToReplace} → {ReplacementString} ({Description})";
|
||||
|
||||
public Replacement(char charToReplace, string replacementString, string description)
|
||||
{
|
||||
CharacterToReplace = charToReplace;
|
||||
ReplacementString = replacementString;
|
||||
Description = description;
|
||||
}
|
||||
private Replacement(char charToReplace, string replacementString, string description, bool mandatory)
|
||||
: this(charToReplace, replacementString, description)
|
||||
{
|
||||
Mandatory = mandatory;
|
||||
}
|
||||
|
||||
public object Clone() => new Replacement(CharacterToReplace, ReplacementString, Description, Mandatory);
|
||||
|
||||
public void Update(char charToReplace, string replacementString, string description)
|
||||
{
|
||||
ReplacementString = replacementString;
|
||||
|
||||
if (!Mandatory)
|
||||
{
|
||||
CharacterToReplace = charToReplace;
|
||||
Description = description;
|
||||
}
|
||||
}
|
||||
|
||||
public static Replacement OtherInvalid(string replacement) => new(default, replacement, "All other invalid characters", true);
|
||||
public static Replacement FilenameForwardSlash(string replacement) => new('/', replacement, "Forward Slash (Filename Only)", true);
|
||||
public static Replacement FilenameBackSlash(string replacement) => new('\\', replacement, "Back Slash (Filename Only)", true);
|
||||
public static Replacement OpenQuote(string replacement) => new('"', replacement, "Open Quote", true);
|
||||
public static Replacement CloseQuote(string replacement) => new('"', replacement, "Close Quote", true);
|
||||
public static Replacement OtherQuote(string replacement) => new('"', replacement, "Other Quote", true);
|
||||
public static Replacement Colon(string replacement) => new(':', replacement, "Colon");
|
||||
public static Replacement Asterisk(string replacement) => new('*', replacement, "Asterisk");
|
||||
public static Replacement QuestionMark(string replacement) => new('?', replacement, "Question Mark");
|
||||
public static Replacement OpenAngleBracket(string replacement) => new('<', replacement, "Open Angle Bracket");
|
||||
public static Replacement CloseAngleBracket(string replacement) => new('>', replacement, "Close Angle Bracket");
|
||||
public static Replacement Pipe(string replacement) => new('|', replacement, "Vertical Line");
|
||||
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(ReplacementCharactersConverter))]
|
||||
public class ReplacementCharacters
|
||||
{
|
||||
public static readonly ReplacementCharacters Default = new()
|
||||
{
|
||||
Replacements = new List<Replacement>()
|
||||
{
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("∕"),
|
||||
Replacement.FilenameBackSlash(""),
|
||||
Replacement.OpenQuote("“"),
|
||||
Replacement.CloseQuote("”"),
|
||||
Replacement.OtherQuote("""),
|
||||
Replacement.OpenAngleBracket("<"),
|
||||
Replacement.CloseAngleBracket(">"),
|
||||
Replacement.Colon("꞉"),
|
||||
Replacement.Asterisk("✱"),
|
||||
Replacement.QuestionMark("?"),
|
||||
Replacement.Pipe("⏐"),
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly ReplacementCharacters LoFiDefault = new()
|
||||
{
|
||||
Replacements = new List<Replacement>()
|
||||
{
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("_"),
|
||||
Replacement.FilenameBackSlash("_"),
|
||||
Replacement.OpenQuote("'"),
|
||||
Replacement.CloseQuote("'"),
|
||||
Replacement.OtherQuote("'"),
|
||||
Replacement.OpenAngleBracket("{"),
|
||||
Replacement.CloseAngleBracket("}"),
|
||||
Replacement.Colon("-"),
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly ReplacementCharacters Barebones = new()
|
||||
{
|
||||
Replacements = new List<Replacement>()
|
||||
{
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("_"),
|
||||
Replacement.FilenameBackSlash("_"),
|
||||
Replacement.OpenQuote("_"),
|
||||
Replacement.CloseQuote("_"),
|
||||
Replacement.OtherQuote("_"),
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly char[] invalidChars = Path.GetInvalidPathChars().Union(new[] {
|
||||
'*', '?', ':',
|
||||
// these are weird. If you run Path.GetInvalidPathChars() in Visual Studio's "C# Interactive", then these characters are included.
|
||||
// In live code, Path.GetInvalidPathChars() does not include them
|
||||
'"', '<', '>'
|
||||
}).ToArray();
|
||||
|
||||
public IReadOnlyList<Replacement> Replacements { get; init; }
|
||||
private string DefaultReplacement => Replacements[0].ReplacementString;
|
||||
private Replacement ForwardSlash => Replacements[1];
|
||||
private Replacement BackSlash => Replacements[2];
|
||||
private string OpenQuote => Replacements[3].ReplacementString;
|
||||
private string CloseQuote => Replacements[4].ReplacementString;
|
||||
private string OtherQuote => Replacements[5].ReplacementString;
|
||||
|
||||
private string GetFilenameCharReplacement(char toReplace, char preceding, char succeding)
|
||||
{
|
||||
if (toReplace == ForwardSlash.CharacterToReplace)
|
||||
return ForwardSlash.ReplacementString;
|
||||
else if (toReplace == BackSlash.CharacterToReplace)
|
||||
return BackSlash.ReplacementString;
|
||||
else return GetPathCharReplacement(toReplace, preceding, succeding);
|
||||
}
|
||||
private string GetPathCharReplacement(char toReplace, char preceding, char succeding)
|
||||
{
|
||||
if (toReplace == Replacement.QUOTE_MARK)
|
||||
{
|
||||
if (
|
||||
preceding == default ||
|
||||
(
|
||||
!char.IsLetter(preceding) &&
|
||||
!char.IsNumber(preceding) &&
|
||||
(char.IsLetter(succeding) || char.IsNumber(succeding))
|
||||
)
|
||||
)
|
||||
return OpenQuote;
|
||||
else if (
|
||||
succeding == default ||
|
||||
(
|
||||
!char.IsLetter(succeding) &&
|
||||
!char.IsNumber(succeding) &&
|
||||
(char.IsLetter(preceding) || char.IsNumber(preceding))
|
||||
)
|
||||
)
|
||||
return CloseQuote;
|
||||
else
|
||||
return OtherQuote;
|
||||
}
|
||||
|
||||
for (int i = Replacement.FIXED_COUNT; i < Replacements.Count; i++)
|
||||
{
|
||||
var r = Replacements[i];
|
||||
if (r.CharacterToReplace == toReplace)
|
||||
return r.ReplacementString;
|
||||
}
|
||||
return DefaultReplacement;
|
||||
}
|
||||
|
||||
|
||||
public static bool ContainsInvalidPathChar(string path)
|
||||
=> path.Any(c => invalidChars.Contains(c));
|
||||
public static bool ContainsInvalidFilenameChar(string path)
|
||||
=> path.Any(c => invalidChars.Concat(new char[] { '\\', '/' }).Contains(c));
|
||||
|
||||
public string ReplaceInvalidFilenameChars(string fileName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fileName)) return string.Empty;
|
||||
var builder = new System.Text.StringBuilder();
|
||||
for (var i = 0; i < fileName.Length; i++)
|
||||
{
|
||||
var c = fileName[i];
|
||||
|
||||
if (invalidChars.Contains(c) || c == ForwardSlash.CharacterToReplace || c == BackSlash.CharacterToReplace)
|
||||
{
|
||||
char preceding = i > 0 ? fileName[i - 1] : default;
|
||||
char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default;
|
||||
builder.Append(GetFilenameCharReplacement(c, preceding, succeeding));
|
||||
}
|
||||
else
|
||||
builder.Append(c);
|
||||
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public string ReplaceInvalidPathChars(string pathStr)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pathStr)) return string.Empty;
|
||||
|
||||
// replace all colons except within the first 2 chars
|
||||
var builder = new System.Text.StringBuilder();
|
||||
for (var i = 0; i < pathStr.Length; i++)
|
||||
{
|
||||
var c = pathStr[i];
|
||||
|
||||
if (!invalidChars.Contains(c) || (c == ':' && i == 1 && Path.IsPathRooted(pathStr)))
|
||||
builder.Append(c);
|
||||
else
|
||||
{
|
||||
char preceding = i > 0 ? pathStr[i - 1] : default;
|
||||
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
|
||||
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
|
||||
}
|
||||
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
#region JSON Converter
|
||||
internal class ReplacementCharactersConverter : JsonConverter
|
||||
{
|
||||
public override bool CanConvert(Type objectType)
|
||||
=> objectType == typeof(ReplacementCharacters);
|
||||
|
||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
||||
{
|
||||
var jObj = JObject.Load(reader);
|
||||
var replaceArr = jObj[nameof(Replacement)];
|
||||
IReadOnlyList<Replacement> dict = replaceArr
|
||||
.ToObject<Replacement[]>().ToList();
|
||||
|
||||
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
|
||||
//If not, reset to default.
|
||||
|
||||
var default0 = Replacement.OtherInvalid("");
|
||||
var default1 = Replacement.FilenameForwardSlash("");
|
||||
var default2 = Replacement.FilenameBackSlash("");
|
||||
var default3 = Replacement.OpenQuote("");
|
||||
var default4 = Replacement.CloseQuote("");
|
||||
var default5 = Replacement.OtherQuote("");
|
||||
|
||||
if (dict.Count < Replacement.FIXED_COUNT ||
|
||||
dict[0].CharacterToReplace != default0.CharacterToReplace || dict[0].Description != default0.Description ||
|
||||
dict[1].CharacterToReplace != default1.CharacterToReplace || dict[1].Description != default1.Description ||
|
||||
dict[2].CharacterToReplace != default2.CharacterToReplace || dict[2].Description != default2.Description ||
|
||||
dict[3].CharacterToReplace != default3.CharacterToReplace || dict[3].Description != default3.Description ||
|
||||
dict[4].CharacterToReplace != default4.CharacterToReplace || dict[4].Description != default4.Description ||
|
||||
dict[5].CharacterToReplace != default5.CharacterToReplace || dict[5].Description != default5.Description ||
|
||||
dict.Any(r => ReplacementCharacters.ContainsInvalidPathChar(r.ReplacementString))
|
||||
)
|
||||
{
|
||||
dict = ReplacementCharacters.Default.Replacements;
|
||||
}
|
||||
//First FIXED_COUNT are mandatory
|
||||
for (int i = 0; i < Replacement.FIXED_COUNT; i++)
|
||||
dict[i].Mandatory = true;
|
||||
|
||||
return new ReplacementCharacters { Replacements = dict };
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
{
|
||||
ReplacementCharacters replacements = (ReplacementCharacters)value;
|
||||
|
||||
var propertyNames = replacements.Replacements
|
||||
.Select(c => JObject.FromObject(c)).ToList();
|
||||
|
||||
var prop = new JProperty(nameof(Replacement), new JArray(propertyNames));
|
||||
|
||||
var obj = new JObject();
|
||||
obj.AddFirst(prop);
|
||||
obj.WriteTo(writer);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
12
Source/HangoverAvalonia/App.axaml
Normal file
12
Source/HangoverAvalonia/App.axaml
Normal file
@@ -0,0 +1,12 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:HangoverAvalonia"
|
||||
x:Class="HangoverAvalonia.App">
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme Mode="Light"/>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
29
Source/HangoverAvalonia/App.axaml.cs
Normal file
29
Source/HangoverAvalonia/App.axaml.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using HangoverAvalonia.ViewModels;
|
||||
using HangoverAvalonia.Views;
|
||||
|
||||
namespace HangoverAvalonia
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Source/HangoverAvalonia/Assets/hangover.ico
Normal file
BIN
Source/HangoverAvalonia/Assets/hangover.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
85
Source/HangoverAvalonia/HangoverAvalonia.csproj
Normal file
85
Source/HangoverAvalonia/HangoverAvalonia.csproj
Normal file
@@ -0,0 +1,85 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 -->
|
||||
<TrimMode>copyused</TrimMode>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<IsPublishable>true</IsPublishable>
|
||||
<AssemblyName>Hangover</AssemblyName>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<StartupObject />
|
||||
<IsPublishable>true</IsPublishable>
|
||||
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
|
||||
<ApplicationIcon>hangover.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
HACK FOR COMPILER BUG 2021-09-14. Hopefully will be fixed in future versions
|
||||
- Not using SatelliteResourceLanguages will load all language packs: works
|
||||
- Specifying 'en' semicolon 1 more should load 1 language pack: works
|
||||
- Specifying only 'en' should load no language packs: broken, still loads all
|
||||
-->
|
||||
<SatelliteResourceLanguages>en;es</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<OutputPath>..\bin\Avalonia\Debug</OutputPath>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\bin\Avalonia\Release</OutputPath>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<AvaloniaXaml Remove="Models\**" />
|
||||
<Compile Remove="Models\**" />
|
||||
<EmbeddedResource Remove="Models\**" />
|
||||
<None Remove="Models\**" />
|
||||
<None Remove=".gitignore" />
|
||||
<None Remove="Assets\hangover.ico" />
|
||||
<None Remove="hangover.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="hangover.ico" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!--This helps with theme dll-s trimming.
|
||||
If you will publish your application in self-contained mode with p:PublishTrimmed=true and it will use Fluent theme Default theme will be trimmed from the output and vice versa.
|
||||
https://github.com/AvaloniaUI/Avalonia/issues/5593 -->
|
||||
<TrimmableAssembly Include="Avalonia.Themes.Fluent" />
|
||||
<TrimmableAssembly Include="Avalonia.Themes.Default" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="0.10.17" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.17" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="0.10.17" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="0.10.17" />
|
||||
<PackageReference Include="XamlNameReferenceGenerator" Version="1.3.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||
<!-- Remove obj folder -->
|
||||
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
|
||||
<!-- Remove bin folder -->
|
||||
<RemoveDir Directories="$(BaseOutputPath)" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
24
Source/HangoverAvalonia/Program.cs
Normal file
24
Source/HangoverAvalonia/Program.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.ReactiveUI;
|
||||
using System;
|
||||
|
||||
namespace HangoverAvalonia
|
||||
{
|
||||
internal class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.LogToTrace()
|
||||
.UseReactiveUI();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>..\bin\Publish\linux-chardonnay</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>..\bin\Publish\macos-chardonnay</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>..\bin\Publish\win-chardonnay</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
30
Source/HangoverAvalonia/ViewLocator.cs
Normal file
30
Source/HangoverAvalonia/ViewLocator.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using HangoverAvalonia.ViewModels;
|
||||
using System;
|
||||
|
||||
namespace HangoverAvalonia
|
||||
{
|
||||
public class ViewLocator : IDataTemplate
|
||||
{
|
||||
public IControl Build(object data)
|
||||
{
|
||||
var name = data.GetType().FullName!.Replace("ViewModel", "View");
|
||||
var type = Type.GetType(name);
|
||||
|
||||
if (type != null)
|
||||
{
|
||||
return (Control)Activator.CreateInstance(type)!;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new TextBlock { Text = "Not Found: " + name };
|
||||
}
|
||||
}
|
||||
|
||||
public bool Match(object data)
|
||||
{
|
||||
return data is ViewModelBase;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
Source/HangoverAvalonia/ViewModels/MainWindowViewModel.cs
Normal file
37
Source/HangoverAvalonia/ViewModels/MainWindowViewModel.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using HangoverBase;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace HangoverAvalonia.ViewModels
|
||||
{
|
||||
public class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
private DatabaseTab _tab;
|
||||
|
||||
private string _databaseFileText;
|
||||
private bool _databaseFound;
|
||||
private string _sqlResults;
|
||||
public string DatabaseFileText { get => _databaseFileText; set => this.RaiseAndSetIfChanged(ref _databaseFileText, value); }
|
||||
public string SqlQuery { get; set; }
|
||||
public bool DatabaseFound { get => _databaseFound; set => this.RaiseAndSetIfChanged(ref _databaseFound, value); }
|
||||
public string SqlResults { get => _sqlResults; set => this.RaiseAndSetIfChanged(ref _sqlResults, value); }
|
||||
|
||||
public MainWindowViewModel()
|
||||
{
|
||||
_tab = new(new(() => SqlQuery, s => SqlResults = s, s => SqlResults = s));
|
||||
|
||||
_tab.LoadDatabaseFile();
|
||||
if (_tab.DbFile is null)
|
||||
{
|
||||
DatabaseFileText = $"Database file not found";
|
||||
DatabaseFound = false;
|
||||
return;
|
||||
}
|
||||
|
||||
DatabaseFileText = $"Database file: {_tab.DbFile}";
|
||||
DatabaseFound = true;
|
||||
}
|
||||
|
||||
public void ExecuteQuery() => _tab.ExecuteQuery();
|
||||
}
|
||||
}
|
||||
11
Source/HangoverAvalonia/ViewModels/ViewModelBase.cs
Normal file
11
Source/HangoverAvalonia/ViewModels/ViewModelBase.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace HangoverAvalonia.ViewModels
|
||||
{
|
||||
public class ViewModelBase : ReactiveObject
|
||||
{
|
||||
}
|
||||
}
|
||||
75
Source/HangoverAvalonia/Views/MainWindow.axaml
Normal file
75
Source/HangoverAvalonia/Views/MainWindow.axaml
Normal file
@@ -0,0 +1,75 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:HangoverAvalonia.ViewModels"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="500"
|
||||
Width="800" Height="500"
|
||||
x:Class="HangoverAvalonia.Views.MainWindow"
|
||||
Icon="/Assets/hangover.ico "
|
||||
Title="Hangover: Libation debug and recovery tool">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
|
||||
<TabControl Grid.Row="0">
|
||||
<TabControl.Styles>
|
||||
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
|
||||
<Setter Property="Height" Value="18"/>
|
||||
</Style>
|
||||
<Style Selector="TabItem">
|
||||
<Setter Property="MinHeight" Value="30"/>
|
||||
<Setter Property="Height" Value="30"/>
|
||||
<Setter Property="Padding" Value="8,2,8,0"/>
|
||||
</Style>
|
||||
<Style Selector="TabItem#Header TextBlock">
|
||||
<Setter Property="MinHeight" Value="5"/>
|
||||
</Style>
|
||||
</TabControl.Styles>
|
||||
|
||||
<!-- Database Tab -->
|
||||
<TabItem>
|
||||
<TabItem.Header>
|
||||
<TextBlock FontSize="14" VerticalAlignment="Center">Database</TextBlock>
|
||||
</TabItem.Header>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto,2*">
|
||||
|
||||
<TextBlock
|
||||
Margin="0,10,0,5"
|
||||
Grid.Row="0"
|
||||
Text="{Binding DatabaseFileText}" />
|
||||
|
||||
<TextBlock
|
||||
Margin="0,5,0,5"
|
||||
Grid.Row="1"
|
||||
Text="SQL (database command)" />
|
||||
|
||||
<TextBox
|
||||
Margin="0,5,0,5"
|
||||
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
|
||||
|
||||
<Button
|
||||
Grid.Row="3"
|
||||
Padding="20,5,20,5"
|
||||
Content="Execute"
|
||||
IsEnabled="{Binding DatabaseFound}"
|
||||
Click="Execute_Click" />
|
||||
|
||||
<TextBox
|
||||
Margin="0,5,0,10"
|
||||
IsReadOnly="True"
|
||||
Grid.Row="4"
|
||||
Text="{Binding SqlResults}" />
|
||||
</Grid>
|
||||
|
||||
</TabItem>
|
||||
<!-- Command Line Interface Tab -->
|
||||
<TabItem>
|
||||
<TabItem.Header>
|
||||
<TextBlock FontSize="14" VerticalAlignment="Center">Command Line Interface</TextBlock>
|
||||
</TabItem.Header>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Window>
|
||||
19
Source/HangoverAvalonia/Views/MainWindow.axaml.cs
Normal file
19
Source/HangoverAvalonia/Views/MainWindow.axaml.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Avalonia.Controls;
|
||||
using HangoverAvalonia.ViewModels;
|
||||
|
||||
namespace HangoverAvalonia.Views
|
||||
{
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
MainWindowViewModel _viewModel => DataContext as MainWindowViewModel;
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
_viewModel.ExecuteQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Source/HangoverAvalonia/hangover.ico
Normal file
BIN
Source/HangoverAvalonia/hangover.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
154
Source/HangoverBase/DatabaseTab.cs
Normal file
154
Source/HangoverBase/DatabaseTab.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using System.Text;
|
||||
using ApplicationServices;
|
||||
using AppScaffolding;
|
||||
using Dinah.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HangoverBase
|
||||
{
|
||||
public class DatabaseTabCommands
|
||||
{
|
||||
public Func<string> SqlInput { get; }
|
||||
public Action<string> SqlOutputAppend { get; }
|
||||
public Action<string> SqlOutputOverwrite { get; }
|
||||
|
||||
public DatabaseTabCommands() { }
|
||||
public DatabaseTabCommands(
|
||||
Func<string> sqlInput,
|
||||
Action<string> sqlDisplayAppend,
|
||||
Action<string> sqlDisplayOverwrite)
|
||||
{
|
||||
SqlInput = ArgumentValidator.EnsureNotNull(sqlInput, nameof(sqlInput));
|
||||
SqlOutputAppend = ArgumentValidator.EnsureNotNull(sqlDisplayAppend, nameof(sqlDisplayAppend));
|
||||
SqlOutputOverwrite = ArgumentValidator.EnsureNotNull(sqlDisplayOverwrite, nameof(sqlDisplayOverwrite));
|
||||
}
|
||||
}
|
||||
|
||||
public class DatabaseTab
|
||||
{
|
||||
private DatabaseTabCommands _commands { get; }
|
||||
public string DbFile { get; private set; }
|
||||
|
||||
public DatabaseTab(DatabaseTabCommands commands)
|
||||
{
|
||||
_commands = ArgumentValidator.EnsureNotNull(commands, nameof(commands));
|
||||
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlInput));
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputAppend));
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputOverwrite));
|
||||
}
|
||||
|
||||
public void LoadDatabaseFile() => DbFile = UNSAFE_MigrationHelper.DatabaseFile;
|
||||
|
||||
private string dbBackup;
|
||||
private DateTime dbFileLastModified;
|
||||
public void EnsureBackup()
|
||||
{
|
||||
if (dbBackup is not null)
|
||||
return;
|
||||
|
||||
dbFileLastModified = File.GetLastWriteTimeUtc(DbFile);
|
||||
|
||||
dbBackup
|
||||
= Path.ChangeExtension(DbFile, "").TrimEnd('.')
|
||||
+ $"_backup_{DateTime.UtcNow:O}".Replace(':', '-').Replace('.', '-')
|
||||
+ Path.GetExtension(DbFile);
|
||||
File.Copy(DbFile, dbBackup);
|
||||
}
|
||||
|
||||
public void ExecuteQuery()
|
||||
{
|
||||
EnsureBackup();
|
||||
|
||||
_commands.SqlOutputOverwrite("");
|
||||
|
||||
try
|
||||
{
|
||||
var sql = _commands.SqlInput().Trim();
|
||||
|
||||
#region // explanation
|
||||
// Routing statements to non-query is a convenience.
|
||||
// I went down the rabbit hole of full parsing and it's more trouble than it's worth. The parsing is easy due to available libraries. The edge cases of what to do next got too complex for slight gains.
|
||||
// It's also not useful to take the extra effort to separate non-queries which don't return a row count. Eg: alter table, drop table
|
||||
// My half-assed solution here won't even catch simple mistakes like this -- and that's ok
|
||||
// -- line 1 is a comment
|
||||
// delete from foo
|
||||
#endregion
|
||||
var lower = sql.ToLower();
|
||||
if (lower.StartsWith("update") || lower.StartsWith("insert") || lower.StartsWith("delete"))
|
||||
NonQuery(sql);
|
||||
else
|
||||
Query(sql);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_commands.SqlOutputOverwrite($"{ex.Message}\r\n{ex.StackTrace}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteUnneededBackups();
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteUnneededBackups()
|
||||
{
|
||||
var newLastModified = File.GetLastWriteTimeUtc(DbFile);
|
||||
if (dbFileLastModified == newLastModified)
|
||||
{
|
||||
File.Delete(dbBackup);
|
||||
dbBackup = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Query(string sql)
|
||||
{
|
||||
// ef doesn't support truly generic queries. have to drop down to ado.net
|
||||
using var context = DbContexts.GetContext();
|
||||
using var conn = context.Database.GetDbConnection();
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
|
||||
var reader = cmd.ExecuteReader();
|
||||
var results = 0;
|
||||
var builder = new StringBuilder();
|
||||
var lines = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
results++;
|
||||
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
builder.Append(reader.GetValue(i) + "\t");
|
||||
builder.AppendLine();
|
||||
|
||||
lines++;
|
||||
if (lines % 10 == 0)
|
||||
{
|
||||
_commands.SqlOutputAppend(builder.ToString());
|
||||
builder.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
_commands.SqlOutputAppend(builder.ToString());
|
||||
builder.Clear();
|
||||
|
||||
if (results == 0)
|
||||
_commands.SqlOutputOverwrite("[no results]");
|
||||
else
|
||||
{
|
||||
_commands.SqlOutputAppend($"\r\n{results} result");
|
||||
if (results != 1) _commands.SqlOutputAppend("s");
|
||||
}
|
||||
}
|
||||
|
||||
public void NonQuery(string sql)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var results = context.Database.ExecuteSqlRaw(sql);
|
||||
|
||||
_commands.SqlOutputAppend($"{results} record");
|
||||
if (results != 1) _commands.SqlOutputAppend("s");
|
||||
_commands.SqlOutputAppend(" affected");
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Source/HangoverBase/HangoverBase.csproj
Normal file
20
Source/HangoverBase/HangoverBase.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
18
Source/HangoverWinForms/Form1.CLI.cs
Normal file
18
Source/HangoverWinForms/Form1.CLI.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using AppScaffolding;
|
||||
|
||||
namespace HangoverWinForms
|
||||
{
|
||||
public partial class Form1
|
||||
{
|
||||
private void Load_cliTab()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private void cliTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!databaseTab.Visible)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Source/HangoverWinForms/Form1.Database.cs
Normal file
31
Source/HangoverWinForms/Form1.Database.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using HangoverBase;
|
||||
|
||||
namespace HangoverWinForms
|
||||
{
|
||||
public partial class Form1
|
||||
{
|
||||
private DatabaseTab _tab;
|
||||
|
||||
private void Load_databaseTab()
|
||||
{
|
||||
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s));
|
||||
|
||||
_tab.LoadDatabaseFile();
|
||||
if (_tab.DbFile is null)
|
||||
{
|
||||
databaseFileLbl.Text = $"Database file not found";
|
||||
return;
|
||||
}
|
||||
|
||||
databaseFileLbl.Text = $"Database file: {_tab.DbFile}";
|
||||
}
|
||||
|
||||
private void databaseTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!databaseTab.Visible)
|
||||
return;
|
||||
}
|
||||
|
||||
private void sqlExecuteBtn_Click(object sender, EventArgs e) => _tab.ExecuteQuery();
|
||||
}
|
||||
}
|
||||
158
Source/HangoverWinForms/Form1.Designer.cs
generated
Normal file
158
Source/HangoverWinForms/Form1.Designer.cs
generated
Normal file
@@ -0,0 +1,158 @@
|
||||
namespace HangoverWinForms
|
||||
{
|
||||
partial class Form1
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1));
|
||||
this.tabControl1 = new System.Windows.Forms.TabControl();
|
||||
this.databaseTab = new System.Windows.Forms.TabPage();
|
||||
this.sqlExecuteBtn = new System.Windows.Forms.Button();
|
||||
this.sqlResultsTb = new System.Windows.Forms.TextBox();
|
||||
this.sqlTb = new System.Windows.Forms.TextBox();
|
||||
this.sqlLbl = new System.Windows.Forms.Label();
|
||||
this.databaseFileLbl = new System.Windows.Forms.Label();
|
||||
this.cliTab = new System.Windows.Forms.TabPage();
|
||||
this.tabControl1.SuspendLayout();
|
||||
this.databaseTab.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// tabControl1
|
||||
//
|
||||
this.tabControl1.Controls.Add(this.databaseTab);
|
||||
this.tabControl1.Controls.Add(this.cliTab);
|
||||
this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.tabControl1.Location = new System.Drawing.Point(0, 0);
|
||||
this.tabControl1.Name = "tabControl1";
|
||||
this.tabControl1.SelectedIndex = 0;
|
||||
this.tabControl1.Size = new System.Drawing.Size(800, 450);
|
||||
this.tabControl1.TabIndex = 0;
|
||||
//
|
||||
// databaseTab
|
||||
//
|
||||
this.databaseTab.Controls.Add(this.sqlExecuteBtn);
|
||||
this.databaseTab.Controls.Add(this.sqlResultsTb);
|
||||
this.databaseTab.Controls.Add(this.sqlTb);
|
||||
this.databaseTab.Controls.Add(this.sqlLbl);
|
||||
this.databaseTab.Controls.Add(this.databaseFileLbl);
|
||||
this.databaseTab.Location = new System.Drawing.Point(4, 24);
|
||||
this.databaseTab.Name = "databaseTab";
|
||||
this.databaseTab.Padding = new System.Windows.Forms.Padding(3);
|
||||
this.databaseTab.Size = new System.Drawing.Size(792, 422);
|
||||
this.databaseTab.TabIndex = 0;
|
||||
this.databaseTab.Text = "Database";
|
||||
this.databaseTab.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// sqlExecuteBtn
|
||||
//
|
||||
this.sqlExecuteBtn.Location = new System.Drawing.Point(8, 153);
|
||||
this.sqlExecuteBtn.Name = "sqlExecuteBtn";
|
||||
this.sqlExecuteBtn.Size = new System.Drawing.Size(75, 23);
|
||||
this.sqlExecuteBtn.TabIndex = 3;
|
||||
this.sqlExecuteBtn.Text = "Execute";
|
||||
this.sqlExecuteBtn.UseVisualStyleBackColor = true;
|
||||
this.sqlExecuteBtn.Click += new System.EventHandler(this.sqlExecuteBtn_Click);
|
||||
//
|
||||
// sqlResultsTb
|
||||
//
|
||||
this.sqlResultsTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.sqlResultsTb.Location = new System.Drawing.Point(8, 182);
|
||||
this.sqlResultsTb.Multiline = true;
|
||||
this.sqlResultsTb.Name = "sqlResultsTb";
|
||||
this.sqlResultsTb.ReadOnly = true;
|
||||
this.sqlResultsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.sqlResultsTb.Size = new System.Drawing.Size(776, 234);
|
||||
this.sqlResultsTb.TabIndex = 4;
|
||||
//
|
||||
// sqlTb
|
||||
//
|
||||
this.sqlTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.sqlTb.Location = new System.Drawing.Point(8, 48);
|
||||
this.sqlTb.Multiline = true;
|
||||
this.sqlTb.Name = "sqlTb";
|
||||
this.sqlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.sqlTb.Size = new System.Drawing.Size(778, 99);
|
||||
this.sqlTb.TabIndex = 2;
|
||||
//
|
||||
// sqlLbl
|
||||
//
|
||||
this.sqlLbl.AutoSize = true;
|
||||
this.sqlLbl.Location = new System.Drawing.Point(6, 30);
|
||||
this.sqlLbl.Name = "sqlLbl";
|
||||
this.sqlLbl.Size = new System.Drawing.Size(144, 15);
|
||||
this.sqlLbl.TabIndex = 1;
|
||||
this.sqlLbl.Text = "SQL (database command)";
|
||||
//
|
||||
// databaseFileLbl
|
||||
//
|
||||
this.databaseFileLbl.AutoSize = true;
|
||||
this.databaseFileLbl.Location = new System.Drawing.Point(6, 3);
|
||||
this.databaseFileLbl.Name = "databaseFileLbl";
|
||||
this.databaseFileLbl.Size = new System.Drawing.Size(80, 15);
|
||||
this.databaseFileLbl.TabIndex = 0;
|
||||
this.databaseFileLbl.Text = "Database file: ";
|
||||
//
|
||||
// cliTab
|
||||
//
|
||||
this.cliTab.Location = new System.Drawing.Point(4, 24);
|
||||
this.cliTab.Name = "cliTab";
|
||||
this.cliTab.Size = new System.Drawing.Size(792, 422);
|
||||
this.cliTab.TabIndex = 1;
|
||||
this.cliTab.Text = "Command Line Interface";
|
||||
this.cliTab.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// Form1
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(800, 450);
|
||||
this.Controls.Add(this.tabControl1);
|
||||
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
|
||||
this.Name = "Form1";
|
||||
this.Text = "Hangover: Libation debug and recovery tool";
|
||||
this.tabControl1.ResumeLayout(false);
|
||||
this.databaseTab.ResumeLayout(false);
|
||||
this.databaseTab.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private TabControl tabControl1;
|
||||
private TabPage databaseTab;
|
||||
private Label databaseFileLbl;
|
||||
private TextBox sqlResultsTb;
|
||||
private TextBox sqlTb;
|
||||
private Label sqlLbl;
|
||||
private Button sqlExecuteBtn;
|
||||
private TabPage cliTab;
|
||||
}
|
||||
}
|
||||
16
Source/HangoverWinForms/Form1.cs
Normal file
16
Source/HangoverWinForms/Form1.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace HangoverWinForms
|
||||
{
|
||||
public partial class Form1 : Form
|
||||
{
|
||||
public Form1()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
|
||||
cliTab.VisibleChanged += cliTab_VisibleChanged;
|
||||
|
||||
Load_databaseTab();
|
||||
Load_cliTab();
|
||||
}
|
||||
}
|
||||
}
|
||||
2328
Source/HangoverWinForms/Form1.resx
Normal file
2328
Source/HangoverWinForms/Form1.resx
Normal file
File diff suppressed because it is too large
Load Diff
63
Source/HangoverWinForms/HangoverWinForms.csproj
Normal file
63
Source/HangoverWinForms/HangoverWinForms.csproj
Normal file
@@ -0,0 +1,63 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationIcon>hangover.ico</ApplicationIcon>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPublishable>true</IsPublishable>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
HACK FOR COMPILER BUG 2021-09-14. Hopefully will be fixed in future versions
|
||||
- Not using SatelliteResourceLanguages will load all language packs: works
|
||||
- Specifying 'en' semicolon 1 more should load 1 language pack: works
|
||||
- Specifying only 'en' should load no language packs: broken, still loads all
|
||||
-->
|
||||
<SatelliteResourceLanguages>en;es</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
When LibationWinForms and Hangover output to the same dir, Hangover must build before LibationWinForms
|
||||
|
||||
VS > rt-clk solution > Properties
|
||||
left: Project Dependencies
|
||||
top: Projects: LibationWinForms
|
||||
bottom: manually check Hangover
|
||||
|
||||
edit debug and release output paths
|
||||
-->
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<OutputPath>..\bin\Debug</OutputPath>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\bin\Release</OutputPath>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Form1.*.cs">
|
||||
<DependentUpon>Form1.cs</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||
<!-- Remove obj folder -->
|
||||
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
|
||||
<!-- Remove bin folder -->
|
||||
<RemoveDir Directories="$(BaseOutputPath)" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
17
Source/HangoverWinForms/Program.cs
Normal file
17
Source/HangoverWinForms/Program.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace HangoverWinForms
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The main entry point for the application.
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
// To customize application configuration such as set high DPI settings or default font,
|
||||
// see https://aka.ms/applicationconfiguration.
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new Form1());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>..\bin\Publish\classic</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user