mirror of
https://github.com/C9Glax/tranga.git
synced 2025-06-23 19:44:16 +02:00
Compare commits
1231 Commits
1.4.1
...
c94c55300c
Author | SHA1 | Date | |
---|---|---|---|
c94c55300c | |||
721f932fac | |||
f691529591 | |||
d75262a8f3 | |||
9521f66bac | |||
3981a41303 | |||
f2961711cf | |||
93ad691971 | |||
cef3b24efd | |||
f10c478cab | |||
01ba927491 | |||
90ce1395b8 | |||
892ef6c9d6 | |||
31c039d71e | |||
5b03befbf1 | |||
5012bbb2eb | |||
45a8f7a038 | |||
9b4baa1334 | |||
657ab571f9 | |||
1480aa0a03 | |||
232fe6406a | |||
a2bc14d54a | |||
d278a25f16 | |||
5f42a2d5ae | |||
60d84d1186 | |||
7ee4d32c07 | |||
0e68d64f75 | |||
434e30dc47 | |||
19ff3f578a | |||
cc03b6fa9c | |||
1f59ef66cd | |||
d7f21550cd | |||
d93c8fdb94 | |||
80320fd44d | |||
6449132dbf | |||
0df3381c8c | |||
2ca43e6f5d | |||
3ba1261f31 | |||
be72d4ba97 | |||
f237d82cac | |||
a43901564b | |||
34ec185125 | |||
cd08d9d78b | |||
339f40b61b | |||
8ee8f33f33 | |||
6721bd863f | |||
ee820cfa27 | |||
24361d5a43 | |||
6687ab4b3b | |||
290324f9d9 | |||
89ed500751 | |||
b00b0ee030 | |||
e47c52ad48 | |||
ef87e02d0b | |||
0af83f2fd0 | |||
f3854ab594 | |||
207604a437 | |||
832ddf1442 | |||
313225a1a1 | |||
ffc0e7555a | |||
ecfc8f349b | |||
94678e744f | |||
73eb02e7cb | |||
c3ebd6acac | |||
a694b9f5ab | |||
b24d2e12fc | |||
6909c367e5 | |||
293f0af8e3 | |||
ebfa34e386 | |||
14524407f9 | |||
d56f0b383a | |||
70391c83c1 | |||
dc7696ee26 | |||
49dab9a670 | |||
4cb48dd1b4 | |||
d43ae881b5 | |||
3583e45071 | |||
c4adba6357 | |||
ed74975312 | |||
043f0b9593 | |||
022ebe2bcc | |||
3a8b400851 | |||
6c5bc3685e | |||
c679d7c677 | |||
4075adfe6b | |||
72abc90af3 | |||
894f105786 | |||
d314559361 | |||
5ab0bd0b78 | |||
5c0ace291b | |||
0d931bc835 | |||
e1e5a45960 | |||
3ecbc1a805 | |||
3305519307 | |||
9fca2d81ab | |||
949c0cc16d | |||
5921e524a9 | |||
bf332717a5 | |||
bb31a94eea | |||
c9bc79fbd5 | |||
83ce315f87 | |||
59511056d0 | |||
ed3ca5dba8 | |||
8df05d7e8a | |||
95d1e37b47 | |||
1d2ca4d76a | |||
e2ff2c76ed | |||
8a0829ef69 | |||
d257095885 | |||
68cc23e158 | |||
b6494ab7f9 | |||
1d1d01b6e5 | |||
5bb4977876 | |||
c6bb1c9180 | |||
9a066e7ac7 | |||
4bafffded4 | |||
235183cd7f | |||
942b43da67 | |||
ce5538b352 | |||
0cfdf17bd4 | |||
0c48c1e020 | |||
0638e75ed6 | |||
5a4bc1c6de | |||
71f663ca2f | |||
1b61a16061 | |||
db81fdce39 | |||
fdb5451162 | |||
6b7632b071 | |||
06c080dfce | |||
8130e11a9c | |||
659a42d370 | |||
9cef068785 | |||
4ad3149523 | |||
dccc9fdbef | |||
e6d40a7b36 | |||
a95cb90561 | |||
603e1b41d9 | |||
bb8a514830 | |||
9928abb674 | |||
ebb034e0c7 | |||
edacaaba8a | |||
d97da26994 | |||
8b923d73c4 | |||
1dca7ec569 | |||
7229fad6c5 | |||
814efd3528 | |||
2cd5d8bc4f | |||
5a864ab9b7 | |||
663e2e2ca0 | |||
c700974693 | |||
553b5558d3 | |||
c9bbfee26b | |||
6e869eeb0d | |||
be7da69dbd | |||
7f13d9b1e6 | |||
0c9e3205c2 | |||
6315940cd6 | |||
ef7ebf022d | |||
725813c2f3 | |||
a69e12179b | |||
45ca2695eb | |||
bd9e79d026 | |||
6bbd09072b | |||
d6018b60ae | |||
8c3b70b32e | |||
4f7031ecfc | |||
a713a006dd | |||
ebe7e145aa | |||
f7a285aabd | |||
786482398c | |||
7921dcb1cb | |||
d0c9313279 | |||
58cf4cf4e0 | |||
be6b3da1be | |||
280d715a7c | |||
d0b775444d | |||
b4edcccafe | |||
268441a47d | |||
b818f63f2a | |||
1701881f4b | |||
78a9322036 | |||
e5be5703f8 | |||
cc32b3dfae | |||
110a0bf481 | |||
fdbe585aa0 | |||
ce217aae4f | |||
123a8b06b2 | |||
2350c5a04b | |||
f532e2ff76 | |||
6a8df2f5f8 | |||
3abf7224d0 | |||
b39dbd5671 | |||
375fad0c21 | |||
ee0d17c24f | |||
36ab3c3fdb | |||
c3d60c6586 | |||
524596ad85 | |||
6aa8413c40 | |||
94adefa8e6 | |||
7cf7eb85d2 | |||
55c0e2c4e7 | |||
5494f2b754 | |||
b96ae4a2d2 | |||
80190e1286 | |||
3a25c0b221 | |||
16dd1ffa97 | |||
9cb5f636dd | |||
df319e9afb | |||
84388a469a | |||
d322445550 | |||
81d22bc022 | |||
6b0cefbc7c | |||
519030861d | |||
6940e6c64d | |||
e66ab49e7d | |||
67a15cec7f | |||
ae11c31b9d | |||
60b128fc30 | |||
729f018712 | |||
03e89913e3 | |||
c4fc2f436b | |||
ebc30c85bf | |||
d6b0e3a366 | |||
e1bfdd675b | |||
e6f8853b49 | |||
99ddb06d6d | |||
62876498d0 | |||
1044821147 | |||
7f946da1c3 | |||
79e7941dda | |||
faa235783c | |||
87c5ad001d | |||
3b58e0498b | |||
87274aca19 | |||
77c5903cf1 | |||
0d32f15ee9 | |||
a0774841bc | |||
3ee3a07565 | |||
b9eecd3afd | |||
6534341fd5 | |||
6737be4a20 | |||
84833acdeb | |||
538e6fa60b | |||
8c5bcd2665 | |||
50dfd92c91 | |||
bf9fe517b0 | |||
e1f1a05724 | |||
1008da7ee8 | |||
72d9bda0e8 | |||
a40a9c84df | |||
ec884f888f | |||
57df419d65 | |||
825b945ad1 | |||
b8c624f3ea | |||
93cfdddd19 | |||
4c8d9bfaf2 | |||
dd988658c0 | |||
cf4c84a47f | |||
5d9bfc3adf | |||
5a770c8e9f | |||
395619acd3 | |||
502821c246 | |||
9d6a8ed686 | |||
e3bd7620aa | |||
afcc2cacaf | |||
4040b5845c | |||
428d6e13d1 | |||
1e6a65c0fd | |||
025d43b752 | |||
113c0abba7 | |||
7daebcb1c4 | |||
747df0bde5 | |||
463f360808 | |||
44ff158c66 | |||
85d7c07b13 | |||
b5b45d0801 | |||
553f56ecaf | |||
9cc4f8c090 | |||
29f3f1a16e | |||
204fb7614d | |||
d6e73ffcdf | |||
5a8202f872 | |||
1bd914571c | |||
483dcc41df | |||
55cc2a2e84 | |||
b619109ea1 | |||
72943330c3 | |||
bc44a5333b | |||
38bc1e4d53 | |||
47479f7a0d | |||
b2381be860 | |||
657e1b338b | |||
5018800d09 | |||
ee265a7519 | |||
5b0624654b | |||
a75549c699 | |||
c7dc5e75f2 | |||
3f37eefe72 | |||
b7bc04a045 | |||
f7daacf0d4 | |||
1cb8899195 | |||
f46244cb9c | |||
9db3f1b0da | |||
dc9cd4b1dd | |||
3566ad774d | |||
94b81969c7 | |||
3e581e2ddb | |||
bd8cb86c52 | |||
34c5436b33 | |||
4690394437 | |||
02cf8578c9 | |||
067497ddd0 | |||
4b88cdbd90 | |||
420013f07b | |||
8cee11aa22 | |||
07c6081c03 | |||
585d7e3380 | |||
febce6b92a | |||
fb7ed21d82 | |||
2db85e5070 | |||
198bbdcf94 | |||
c58adf64fa | |||
957debea01 | |||
5186ae66c9 | |||
c35e1ef517 | |||
8f6891142b | |||
b52e6d4908 | |||
96b5921ed6 | |||
9d47445339 | |||
93696fbac1 | |||
582b3af89c | |||
f57667bc8f | |||
f9a30f2587 | |||
240af81fa9 | |||
26b2910000 | |||
a88b85e599 | |||
27f823cfeb | |||
70993a692a | |||
1a631362c9 | |||
00c4f0533f | |||
8670863810 | |||
2c9bd2532e | |||
575fb739cc | |||
d4af068f0e | |||
6a4d454a08 | |||
225db8beda | |||
d80fcd9039 | |||
30c44760e7 | |||
a3ae3c320d | |||
4871bc801d | |||
ea262889e6 | |||
445542b653 | |||
b7718220ef | |||
34c62e8658 | |||
a9fcc93670 | |||
68d7ef258f | |||
fdea4f5ea5 | |||
ac3039e587 | |||
1c5f105a4d | |||
3829a1cf26 | |||
c3daa0b751 | |||
3a072beea3 | |||
8e6f2798a9 | |||
26a07f4a2f | |||
9cbde9a6b4 | |||
0870aa9fdb | |||
172650e644 | |||
48ab44c28d | |||
52ff2e54a8 | |||
61d80a93cf | |||
32ecdcda76 | |||
7be3ee52e9 | |||
981eb0fd9f | |||
a92eba2d14 | |||
47f3044a6d | |||
6d03cc5f8d | |||
290c405f52 | |||
fcdbd32872 | |||
eb6c37cc53 | |||
d922842186 | |||
69323d6d60 | |||
46a0fb8c48 | |||
ec8eb40941 | |||
d2074fae35 | |||
713bbc230f | |||
32ab9a552f | |||
7b6724ad38 | |||
c11c68d6d7 | |||
09fdb6e5f1 | |||
be68ddc9b7 | |||
e86ad03b1e | |||
9dfbe89e87 | |||
96e2845a5b | |||
98e75af486 | |||
e2f5c3badc | |||
cda07bb9aa | |||
7c18466e95 | |||
c36204c7a8 | |||
ce1c4d3f65 | |||
52d0489a1b | |||
18edcef1c3 | |||
73ad881600 | |||
f89aea6ac8 | |||
5f05ba1049 | |||
c6cfd9eb6c | |||
a20ee01cfa | |||
cf5cbba9a8 | |||
600b56033d | |||
fdea3659f1 | |||
7f3754fb64 | |||
2dac5db4da | |||
99df9a9dfd | |||
77bb309dfa | |||
3456fc6564 | |||
35f2625f05 | |||
0b9948e367 | |||
96f3dbce65 | |||
895128a462 | |||
3b9d4a6735 | |||
a94186455b | |||
7d3deee74c | |||
5980b64caa | |||
cbecb257ef | |||
8316ed08a7 | |||
190fa8cba7 | |||
217700d08d | |||
7ff9ac53ee | |||
6faaaf4139 | |||
9b8b80cd24 | |||
15f3e2b8ec | |||
2be29e4019 | |||
e8dbf7a718 | |||
a968f4328d | |||
398b6fff05 | |||
f5da2f8526 | |||
73093ab86c | |||
75eea8c761 | |||
fccaf9fcbe | |||
3122aa32e8 | |||
06cdbbd283 | |||
02fad2dd44 | |||
e0a7d1a187 | |||
054c88712e | |||
d0f9a4102c | |||
9f178821b6 | |||
e95eb0497c | |||
3c3f7bb95a | |||
032ee95716 | |||
682fd0bc2a | |||
dfa8e66f34 | |||
8f51d22303 | |||
d41de84262 | |||
1bd20791b8 | |||
03aeab44cd | |||
6d723b6355 | |||
7b91bb699f | |||
14e33cc496 | |||
6f3bba99b0 | |||
2d848843d0 | |||
63b493fa9c | |||
001a37b8ef | |||
69d6884517 | |||
db73af3bdd | |||
59547efab2 | |||
f4336f9777 | |||
bec3ac52a9 | |||
ea37e81ece | |||
6a20783d48 | |||
21af75f410 | |||
fc884adc9f | |||
960d3f7c62 | |||
6520aebcdf | |||
1ee9b644aa | |||
2f36701fef | |||
b18f8e4059 | |||
8145abb744 | |||
9dd52178b9 | |||
cf242f81e1 | |||
a629792818 | |||
34dd78810d | |||
e1c504226c | |||
200a22228f | |||
bc10136331 | |||
06df6e0767 | |||
ba029b71f5 | |||
082802ddbe | |||
d5f1df0400 | |||
d00881e611 | |||
72bc7ec07b | |||
89b5aa266e | |||
926c0d5833 | |||
80e2568113 | |||
3b6417eff2 | |||
6b9ddca711 | |||
2812a6dff1 | |||
1991862a42 | |||
40e4d5c203 | |||
49e9731184 | |||
a4e85f254f | |||
4f47aeadcf | |||
e0c1356fea | |||
0d9b3d2499 | |||
d73bf70868 | |||
8e5d15ead9 | |||
b8c28e6d21 | |||
9ea5e436fe | |||
b4c310638a | |||
159341ff3c | |||
29338b9b17 | |||
0eda8913b0 | |||
5ca50630e4 | |||
d0bfb262bf | |||
4f14f15ade | |||
d89a24fd11 | |||
a5859e3c82 | |||
dd2fa3fbd7 | |||
33e5d65785 | |||
d60ed77dbe | |||
e15c6816b5 | |||
4a4fe4b40d | |||
d221532e0d | |||
4881789970 | |||
be1e6fe988 | |||
f61e51e506 | |||
eba511749b | |||
5bc2a8909d | |||
de4c57a0cd | |||
e368c3c98a | |||
f3e0959be8 | |||
d17ca1d97a | |||
e9376e3782 | |||
7c217a7e33 | |||
a437fcbca1 | |||
1dcfecd66f | |||
6db4646336 | |||
8a6298e3fd | |||
194705c124 | |||
f4d5969003 | |||
8607bd2c89 | |||
9d92069a4b | |||
5614729eab | |||
fab30dc5a7 | |||
fd20b9febf | |||
ee6de661c8 | |||
d52ec8d36f | |||
790e77b00c | |||
37dfb4df02 | |||
42feea3ad5 | |||
4f14903538 | |||
bbc750d731 | |||
08dd01942f | |||
6ae3918679 | |||
351144e763 | |||
aea4c0c61b | |||
7b9e935db7 | |||
048b165d76 | |||
ebe3012c69 | |||
8ccb6c0cb5 | |||
a5dbed9525 | |||
811ddd903f | |||
beb455308f | |||
f948809bcd | |||
7ceb9cd4cb | |||
57f1e037ef | |||
5c309131ad | |||
6ca8d58e43 | |||
e3211b95e2 | |||
b5e9e03f64 | |||
98bd8a983b | |||
27a559834f | |||
f4996659ef | |||
e05684d5d1 | |||
4a7d23c0d9 | |||
1d44b6d9c6 | |||
2cfc7ac2c5 | |||
811a183af2 | |||
fb0755eb89 | |||
2e8b896f3b | |||
017f31ca83 | |||
4021237888 | |||
7ed3846c5f | |||
4692cc297a | |||
7f95ab9439 | |||
49a9b7ccb0 | |||
0735e2c588 | |||
5b22246c41 | |||
2e1f633f40 | |||
8887cea718 | |||
061da1b4bf | |||
80dc8fbe65 | |||
28a0efe488 | |||
3d08b1f9f2 | |||
3d855020eb | |||
c6d0168d2f | |||
d52213002e | |||
ec9290f41f | |||
6b91796e5a | |||
9f9ea569d5 | |||
4bd1150a0e | |||
8b62e2c467 | |||
7ec262a2e4 | |||
d32d5976ee | |||
58cff6513a | |||
783f229a6a | |||
2651a0c53b | |||
0ced3a7dd9 | |||
a56555eee4 | |||
cee7870aad | |||
aaf06da8e1 | |||
51a26a3cba | |||
bce77180bc | |||
8c66bbc89f | |||
762da4c859 | |||
daba940b45 | |||
79e61a62c7 | |||
06fe98323a | |||
5f820c53f5 | |||
c69f1f6569 | |||
e360037fda | |||
ea866e0136 | |||
c3231327f9 | |||
03e90eccd3 | |||
64482931a3 | |||
cce4901a5d | |||
3adb103fc4 | |||
b6ffb97a04 | |||
5bdbd9e2e4 | |||
49cfff8a2f | |||
6d48a100ca | |||
4104169c19 | |||
4cb7c941a2 | |||
b3fb53f6d8 | |||
f729c44f88 | |||
8b9769b816 | |||
f4966b0348 | |||
9a02859f6b | |||
e96dd07521 | |||
a610eff8f0 | |||
df2fc4a036 | |||
c41f04d92d | |||
5e647099cd | |||
011af9c7a8 | |||
630e507564 | |||
fa2598084f | |||
f79743ee93 | |||
2828fec316 | |||
bd14722791 | |||
d22b49cfa8 | |||
595051b0fe | |||
238395a3da | |||
0313d81204 | |||
f5cecb9e30 | |||
7e5fa6ce41 | |||
0ab2ae03ce | |||
95236daf41 | |||
294ce01bc3 | |||
13565d1c7a | |||
a8aa7d3370 | |||
01bab62190 | |||
2768ab38e6 | |||
54b24ac37f | |||
c67e89f1dd | |||
4ba44d3ac3 | |||
33b8ede492 | |||
dbc1b94124 | |||
8631cf6376 | |||
df4d547e2b | |||
006b71b496 | |||
5f03b0d89c | |||
6dc1ea0030 | |||
ff08754610 | |||
d1a6c0ad3d | |||
0260868968 | |||
b1f72dcb81 | |||
b0f353819b | |||
8f8d019861 | |||
21a7392493 | |||
0d5db15f87 | |||
431fde0d76 | |||
e022bf3081 | |||
c25a4f69ec | |||
82bdb248b9 | |||
b27114eaad | |||
051eb4a417 | |||
482704af2c | |||
af4229920d | |||
6f5fb7e0bb | |||
7628510b87 | |||
dd965d886a | |||
7e54577c54 | |||
537ad3a5f8 | |||
6a8697fc3a | |||
94582496ef | |||
17ef5eae0f | |||
d5b6d4e8ee | |||
05190bc9e2 | |||
d211dd2d01 | |||
590547e407 | |||
2ad04c5c46 | |||
189569ccdf | |||
2872eeea09 | |||
c0cfeaa35d | |||
2fd780996c | |||
b390bb8ea5 | |||
847829e617 | |||
0f29da00de | |||
9b2a6de841 | |||
17a27c9922 | |||
6c9071b22b | |||
abfe42b7c1 | |||
72ae124418 | |||
bee6e7ba37 | |||
8079ffc742 | |||
6d6e33491b | |||
a8697a14a3 | |||
e2adac937a | |||
b4708c5d10 | |||
597abde115 | |||
2a824bbb8d | |||
9691eb0d08 | |||
4888e18fd2 | |||
0aa92a7913 | |||
db53e2156b | |||
1cce0f204e | |||
ce41c49a0e | |||
b8570e5eef | |||
1f24a2349d | |||
ca95460218 | |||
e801cc4cbf | |||
2c4c8de8b5 | |||
0b4461265c | |||
c008d55f26 | |||
9b990aecea | |||
299fa6afda | |||
c03e927565 | |||
bb6c553afa | |||
33d78ed757 | |||
84272ddd1e | |||
2f0fbbd3cb | |||
5bc414fd59 | |||
2eaeadb92c | |||
d8df6eccb1 | |||
db64b717eb | |||
1afe36a525 | |||
aa692f6978 | |||
c706824222 | |||
3ca6245fc2 | |||
2dd82aad13 | |||
3c4867a276 | |||
bae157cdb4 | |||
3b818ff1af | |||
5d12be2983 | |||
31a4e693e0 | |||
e49db9a4cb | |||
54142e61fe | |||
cd5ca0e302 | |||
95da900213 | |||
b5be4e0dd8 | |||
0c135aa89e | |||
e11ee4dafe | |||
05573f65f9 | |||
d986c808e3 | |||
5df63b00c2 | |||
903bb5af5e | |||
cc8453d4a8 | |||
800d4c1ec1 | |||
b4f97eefcf | |||
29f6de2590 | |||
23e5c4a7b1 | |||
e15717cb04 | |||
b995fc568a | |||
442d949371 | |||
263d0e6036 | |||
7c7d43021e | |||
5cdc7d7207 | |||
1bcbd1517f | |||
b72da45ae9 | |||
01041e43ac | |||
4c1a659f16 | |||
2e02f0b237 | |||
77f93d87f9 | |||
45c0f19a9d | |||
7c09deb143 | |||
449d406eab | |||
083ce238d8 | |||
5f9ffb8aad | |||
92bc3d5aa8 | |||
49ab8928b1 | |||
391efcb9bc | |||
963ad375e8 | |||
0a5ded2036 | |||
4843c7f05c | |||
6adbda2359 | |||
425cf7e0d6 | |||
8f5dd5aab5 | |||
733ae285f1 | |||
2e1c8ce34f | |||
c965bc38d1 | |||
37266ea095 | |||
8caac538c9 | |||
7c7f711bb4 | |||
d78897eb74 | |||
438c11af4f | |||
38df54baff | |||
98d187d133 | |||
5352cca058 | |||
3381909afd | |||
7219641859 | |||
f63851d95d | |||
e72301d062 | |||
2302e1009b | |||
40fea6cc7f | |||
5458c43f21 | |||
f78bec43d6 | |||
88876fb8f4 | |||
c71aec8882 | |||
ddfba0d864 | |||
ca9c0b22c1 | |||
6844d0a242 | |||
fd9319de27 | |||
726be70af3 | |||
19c9ecb3e7 | |||
f01a786e59 | |||
59f9bcc7d0 | |||
2796a2adb5 | |||
e07b191293 | |||
9bf650f5fc | |||
334795b263 | |||
51a6f216af | |||
238a2775f4 | |||
fec970d7d6 | |||
e642d50c47 | |||
fafcdac00a | |||
1785aa28ea | |||
f22c332cab | |||
b3bf523e1e | |||
06b2e11164 | |||
7972f07801 | |||
d89af7cc5b | |||
31a0c6ffb2 | |||
668a3b3a96 | |||
3938c61297 | |||
4f3bcd245d | |||
129c95f123 | |||
e2cdf27d40 | |||
4156365b18 | |||
d3ccddd8db | |||
13075a8704 | |||
e7d9f53a93 | |||
dc6dfd4aa1 | |||
0fba09b1e8 | |||
f08b9e85ec | |||
95fcc73c74 | |||
73492d8102 | |||
c69dd22ecf | |||
17b6c523a2 | |||
6c3f7604fe | |||
94f88f08e9 | |||
47327524be | |||
3b96419739 | |||
b7c9b4e9b4 | |||
13adb45444 | |||
b8fbee578e | |||
c1fb42b537 | |||
dcc12ec3ea | |||
8c554076b2 | |||
a10fbdf3a5 | |||
f246209685 | |||
41c561bd1d | |||
fc7d5463c3 | |||
3c2ce266f6 | |||
306cb87d67 | |||
23cda74487 | |||
3ceee63dfc | |||
4e5a6fe97b | |||
b3b1971dad | |||
2699f35b62 | |||
7a14583d6a | |||
660f6a1648 | |||
482fcb7102 | |||
b6cdb07e3f | |||
0875e7ee12 | |||
cb6482ebae | |||
87ea077281 | |||
c1aa4cf6b5 | |||
f5b6b1785f | |||
2553a150d1 | |||
b149d377dc | |||
0209159c5c | |||
e31820eb00 | |||
c4d69c27a4 | |||
3ee53b7436 | |||
64ec0963e1 | |||
27c4ed719c | |||
4f4b0cb3a8 | |||
48d312da0b | |||
1fe4b75ac7 | |||
c580fafc62 | |||
58040ecb10 | |||
2960a9b8f0 | |||
f52bb8eb89 | |||
ae0dc548ae | |||
051b85d08b | |||
d89ca0a2ef | |||
f1f640c1f6 | |||
9319aa7d1f | |||
656e62628e | |||
ba27adf255 | |||
88ca75e883 | |||
67c23b357f | |||
4a5271e2a7 | |||
fec5ad664c | |||
3cea5fb431 | |||
7fa44fba54 | |||
d6b5a29fdc | |||
a4a49d40f0 | |||
28fa85f05c | |||
1066e1ca2e | |||
39307f4313 | |||
a316ee3d48 | |||
569622099d | |||
017701867d | |||
c3d62bd337 | |||
dc9e9e705c | |||
9eee6683fa | |||
1265c7a072 | |||
c601541249 | |||
ae1184320f | |||
384e4c4f43 | |||
76a2b2498a | |||
2ab21b15cf | |||
7acdf7a19b | |||
af8716fcb1 | |||
5f2c66b729 | |||
e030f02431 | |||
bdeb75f4e4 | |||
4ce114986d | |||
8035bf3fcd | |||
85bf3ec7e8 | |||
0f17615b10 | |||
0c8145803e | |||
b2e0c3db97 | |||
ca283fcfff | |||
1d55070daf | |||
32fd75bdae | |||
99ad702163 | |||
6e3a9c2a78 | |||
ad1d4dfe23 | |||
14ba71005f | |||
22c4c0eb2c | |||
44f8d369c3 | |||
c0e6da144e | |||
51a1ae72ca | |||
79bbc92467 | |||
ae5be31c89 | |||
eebe25a378 | |||
0f3da4ec81 | |||
0b77dc1172 | |||
37cf47bc17 | |||
4cce2e04cb | |||
5465ac4e5c | |||
dd4d5a81ee | |||
a05e1914e3 | |||
ed79ee5d0f | |||
28e05e549d | |||
eaab7c5235 | |||
0552b3db82 | |||
c813e1854d | |||
32036df057 | |||
394829ee36 | |||
2a389f1ede | |||
3167f6c3e6 | |||
89c5f4b820 | |||
1c1169e5ce | |||
d5d34c5381 | |||
c0efbb22cc | |||
9f30e52713 | |||
1fd36c91d6 | |||
e663163de8 | |||
4827b90c3d | |||
e274c864f9 | |||
f4bc182954 | |||
3365be219c | |||
10708b3abd | |||
c1e939f1e3 | |||
21d53dabec | |||
a9417dbba6 | |||
4ca7b107eb | |||
61024bcee9 | |||
ea1b8749a6 | |||
2fcab1f1b1 | |||
bbd716383a | |||
6e1a0ab06c | |||
181942153b | |||
fe04af4a2b | |||
4240a1eb6a | |||
32349c1ddf | |||
a94d3d6b40 | |||
f916cda0f1 | |||
a8f0f1af15 | |||
0cf3a95f58 | |||
a89a526fda | |||
4d1e43e7b3 | |||
4f9749d09e | |||
7614f9aad3 | |||
97c0e42512 | |||
565bc0775d | |||
e6a3fa2899 | |||
2d82279d98 | |||
c5559a4ceb | |||
2572a537ab | |||
58db049496 | |||
8f309fcfd7 | |||
11461051f3 | |||
a4aa571870 | |||
e4086a8892 | |||
c45e4ddf90 | |||
675effd317 | |||
a4f67c9ab4 | |||
2538a29788 | |||
81d5802092 | |||
436edfde66 | |||
00c1cd56b8 | |||
a63154b581 | |||
53fe7ee983 | |||
6fb4098c16 | |||
7a024e8733 | |||
835e239be5 | |||
df8538c3b4 | |||
f832fe0de3 | |||
ebdb38bd57 | |||
e3201a9b99 | |||
eb50b84266 | |||
b3d778ff56 | |||
00861c406a | |||
01c8784bab | |||
3aa299e48a | |||
d1ce244135 | |||
c91754614b | |||
70b1ae4812 | |||
336e08aebf | |||
18134cdf01 | |||
5b89cbd042 | |||
74aca86b62 | |||
e5abaa4549 | |||
eb0eb71e86 | |||
4e73b0a4cf | |||
140074208f | |||
fa19d3da14 | |||
3d6657b483 | |||
f9b5e05974 | |||
ad4027779f | |||
98ec0b837f | |||
1afa3df316 | |||
d83aa1ef5b | |||
b610ec734e | |||
abf587377c | |||
437349bd27 | |||
000539d6a6 | |||
b4bef25a22 | |||
579e400a5d | |||
8af2b12fc0 | |||
bad4330330 | |||
42596752d3 | |||
16238c590b | |||
9f38dc3b6a | |||
485637d99a | |||
de14ff0b75 | |||
f947c37bd6 | |||
77eec0f696 | |||
18323f9f51 | |||
2cd2b6842d | |||
09f815903f | |||
c108478039 | |||
74289e43b7 | |||
2779f9ba09 | |||
59a8e556f0 | |||
074b137b5c | |||
3cb2540794 | |||
02c9934896 | |||
b2e1c95bca | |||
8c9e3ea6b6 | |||
db441607ad | |||
91c56783dc | |||
2c288eeeea | |||
57a1ea91fc | |||
06138a3927 | |||
84b053e672 | |||
0fe0cbc4ad | |||
62e6ce8363 | |||
a4f3ec6580 | |||
8b4e996b7e | |||
964540d30f | |||
fa69f4488f | |||
42c2876188 | |||
715244ff1b | |||
2333cd9095 | |||
c8225db4fe | |||
6741ca096b | |||
a897a7b3a2 | |||
0f8932e712 | |||
78023ef0fd | |||
d171f34e4e | |||
aa0dc4fa35 | |||
25f48592c0 | |||
398ac304d2 | |||
58a62f8272 | |||
86752c9a7e | |||
f9a7828d02 | |||
c97ff69148 | |||
1735bbcf8a | |||
9ae8ca65df | |||
00599cd24e | |||
6d5618a1f7 | |||
a1202a875d | |||
98946b4aa3 | |||
41b6bb77b6 | |||
e70a14ca56 | |||
b099da1156 | |||
01d1f922c2 | |||
47a80d67a8 | |||
16e3549455 | |||
be8c6b50ba | |||
a38fcf50ca | |||
82f6c7b3fe | |||
5586d2c104 | |||
62dc9fee2a | |||
ac96fca6dc | |||
25a6ceff10 | |||
b3e1d39d0f | |||
2833b7f22a | |||
cbdd305b69 | |||
b88890817e | |||
f66ab7d40b | |||
4cb3694cd5 | |||
a05d4c8bd9 | |||
22f87a74b2 | |||
ba57282879 | |||
9ccba6fba6 | |||
4f01c1166f | |||
0a51e7ad3d | |||
e541b922dc | |||
604abd5f9a | |||
7b311eae75 | |||
d4eb72cd99 | |||
b515215f4b | |||
a16686dfbf | |||
4275703941 | |||
c3342984ea | |||
ed4bdb5b33 | |||
0f0902c932 | |||
6508055b43 | |||
abc66511d8 | |||
9ed36c47b5 | |||
fd1b2a8470 | |||
8058749ab5 | |||
8737617e5f | |||
7e4f43f1e2 | |||
12b1b2afd6 | |||
0f9ac60fcd | |||
8c87f2948c | |||
e0fb817256 | |||
cdd2d94ba1 | |||
d5b7645cd2 | |||
9af5c1603e | |||
1035939309 | |||
3b542c04f6 | |||
a809b7c285 | |||
e883277400 | |||
23dfdc0933 | |||
edc24fff5b | |||
6cdccdf66b | |||
a4c9168551 | |||
821a1b7c3a | |||
b2b4256972 | |||
d2f46e4637 | |||
303fc293ba | |||
36c145da26 | |||
c822c74f42 | |||
dda4054d34 | |||
5b2546fdbc | |||
c11e3993ea | |||
02a382a99a | |||
c6c8f5cdf6 | |||
84842aed3c | |||
d9ced11cd1 | |||
25c90782dc | |||
e789c429cd | |||
93de471836 | |||
8b58e7dd13 | |||
b571bfa43d | |||
088d1c4647 | |||
f280c01802 | |||
1be10b310d | |||
a0469f3145 | |||
fcd81f03b3 | |||
76604d84d8 | |||
af822febbe | |||
8e207c3119 | |||
b6f8c8aab5 | |||
36f7cbd3e9 | |||
3b2643d949 | |||
9fd8bf1741 | |||
d5c9c5ba96 | |||
c8e27921ab | |||
6eaba07801 | |||
41929e0c72 | |||
4fcaca1a6e | |||
0e3c7f32d7 | |||
1c94625840 | |||
32f89f9dce | |||
234735a562 | |||
8b916eb854 | |||
29e1790c93 | |||
ac4c799a74 | |||
7c62883c37 | |||
02018253bf | |||
2aec884009 | |||
b3321ff030 | |||
16c1094875 | |||
5763d50409 | |||
ad43297358 | |||
b17800e0ef | |||
89c80d2997 | |||
6485b8744f | |||
a3a96b6b55 | |||
5bce3c6fdd | |||
5fa0c98d05 | |||
b166013770 | |||
02fe849046 | |||
d42393c83a |
@ -1,25 +0,0 @@
|
|||||||
**/.dockerignore
|
|
||||||
**/.env
|
|
||||||
**/.git
|
|
||||||
**/.gitignore
|
|
||||||
**/.project
|
|
||||||
**/.settings
|
|
||||||
**/.toolstarget
|
|
||||||
**/.vs
|
|
||||||
**/.vscode
|
|
||||||
**/.idea
|
|
||||||
**/*.*proj.user
|
|
||||||
**/*.dbmdl
|
|
||||||
**/*.jfm
|
|
||||||
**/azds.yaml
|
|
||||||
**/bin
|
|
||||||
**/charts
|
|
||||||
**/docker-compose*
|
|
||||||
**/Dockerfile*
|
|
||||||
**/node_modules
|
|
||||||
**/npm-debug.log
|
|
||||||
**/obj
|
|
||||||
**/secrets.dev.yaml
|
|
||||||
**/values.dev.yaml
|
|
||||||
LICENSE
|
|
||||||
README.md
|
|
21
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
21
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: File a bug report
|
||||||
|
title: "[It broke]: "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: What is broken?
|
||||||
|
description: What happened? How did we get here?
|
||||||
|
placeholder: The place where you tell me what you expected to happen, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Log-output
|
||||||
|
description: The output of `docker logs tranga-api`
|
||||||
|
render: C#
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional stuff
|
||||||
|
description: Screenshots, anything you think might help
|
23
.github/ISSUE_TEMPLATE/new_connector.yml
vendored
Normal file
23
.github/ISSUE_TEMPLATE/new_connector.yml
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
name: New Connector Request
|
||||||
|
description: Request a new site to be added
|
||||||
|
title: "[New Connector]: "
|
||||||
|
labels: ["New Connector"]
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Website-Link
|
||||||
|
placeholder: https://
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Is the Website free to access?
|
||||||
|
description: We can't support pay-to-use sites, or captcha-proxied sites as Cloudflare.
|
||||||
|
options:
|
||||||
|
- label: The Website is freely accessible.
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Anything else?
|
||||||
|
validations:
|
||||||
|
required: false
|
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Maintain dependencies for GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
45
.github/workflows/docker-image-cuttingedge.yml
vendored
Normal file
45
.github/workflows/docker-image-cuttingedge.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "cuttingedge" ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# https://github.com/docker/setup-qemu-action#usage
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3.6.0
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v3.10.0
|
||||||
|
|
||||||
|
# https://github.com/docker/login-action#docker-hub
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
# https://github.com/docker/build-push-action#multi-platform-image
|
||||||
|
- name: Build and push API
|
||||||
|
uses: docker/build-push-action@v6.15.0
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile
|
||||||
|
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
glax/tranga-api:cuttingedge
|
45
.github/workflows/docker-image-master.yml
vendored
Normal file
45
.github/workflows/docker-image-master.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# https://github.com/docker/setup-qemu-action#usage
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3.6.0
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v3.10.0
|
||||||
|
|
||||||
|
# https://github.com/docker/login-action#docker-hub
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
# https://github.com/docker/build-push-action#multi-platform-image
|
||||||
|
- name: Build and push API
|
||||||
|
uses: docker/build-push-action@v6.15.0
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile
|
||||||
|
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
glax/tranga-api:latest
|
45
.github/workflows/docker-image-serverv2.yml
vendored
Normal file
45
.github/workflows/docker-image-serverv2.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "postgres-Server-V2" ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# https://github.com/docker/setup-qemu-action#usage
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3.6.0
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v3.10.0
|
||||||
|
|
||||||
|
# https://github.com/docker/login-action#docker-hub
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
# https://github.com/docker/build-push-action#multi-platform-image
|
||||||
|
- name: Build and push API
|
||||||
|
uses: docker/build-push-action@v6.15.0
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile
|
||||||
|
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
glax/tranga-api:Server-V2
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -19,3 +19,9 @@ riderModule.iml
|
|||||||
/.idea
|
/.idea
|
||||||
cover.jpg
|
cover.jpg
|
||||||
cover.png
|
cover.png
|
||||||
|
/.vscode
|
||||||
|
/.vs/
|
||||||
|
Tranga/Properties/launchSettings.json
|
||||||
|
/Manga
|
||||||
|
/settings
|
||||||
|
*.DotSettings.user
|
38
API/API.csproj
Normal file
38
API/API.csproj
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||||
|
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
|
||||||
|
<PackageReference Include="log4net" Version="3.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
||||||
|
<PackageReference Include="PuppeteerSharp" Version="20.1.3" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
|
||||||
|
<PackageReference Include="Soenneker.Utils.String.NeedlemanWunsch" Version="3.0.929" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="7.3.1" />
|
||||||
|
<PackageReference Include="System.Drawing.Common" Version="9.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Migrations\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
6
API/API.http
Normal file
6
API/API.http
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@API_HostAddress = http://localhost:5105
|
||||||
|
|
||||||
|
GET {{API_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
5
API/APIEndpointRecords/DownloadAvailableJobsRecord.cs
Normal file
5
API/APIEndpointRecords/DownloadAvailableJobsRecord.cs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace API.APIEndpointRecords;
|
||||||
|
|
||||||
|
public record DownloadAvailableJobsRecord([Required]ulong recurrenceTimeMs, [Required]string localLibraryId);
|
16
API/APIEndpointRecords/GotifyRecord.cs
Normal file
16
API/APIEndpointRecords/GotifyRecord.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
namespace API.APIEndpointRecords;
|
||||||
|
|
||||||
|
public record GotifyRecord(string endpoint, string appToken, int priority)
|
||||||
|
{
|
||||||
|
public bool Validate()
|
||||||
|
{
|
||||||
|
if (endpoint == string.Empty)
|
||||||
|
return false;
|
||||||
|
if (appToken == string.Empty)
|
||||||
|
return false;
|
||||||
|
if (priority < 0 || priority > 10)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
16
API/APIEndpointRecords/LunaseaRecord.cs
Normal file
16
API/APIEndpointRecords/LunaseaRecord.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace API.APIEndpointRecords;
|
||||||
|
|
||||||
|
public record LunaseaRecord(string id)
|
||||||
|
{
|
||||||
|
private static Regex validateRex = new(@"(?:device|user)\/[0-9a-zA-Z\-]+");
|
||||||
|
public bool Validate()
|
||||||
|
{
|
||||||
|
if (id == string.Empty)
|
||||||
|
return false;
|
||||||
|
if (!validateRex.IsMatch(id))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
3
API/APIEndpointRecords/ModifyJobRecord.cs
Normal file
3
API/APIEndpointRecords/ModifyJobRecord.cs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
namespace API.APIEndpointRecords;
|
||||||
|
|
||||||
|
public record ModifyJobRecord(ulong? RecurrenceMs, bool? Enabled);
|
13
API/APIEndpointRecords/NewLibraryRecord.cs
Normal file
13
API/APIEndpointRecords/NewLibraryRecord.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
namespace API.APIEndpointRecords;
|
||||||
|
|
||||||
|
public record NewLibraryRecord(string path, string name)
|
||||||
|
{
|
||||||
|
public bool Validate()
|
||||||
|
{
|
||||||
|
if (path.Length < 1) //TODO Better Path validation
|
||||||
|
return false;
|
||||||
|
if (name.Length < 1)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
17
API/APIEndpointRecords/NtfyRecord.cs
Normal file
17
API/APIEndpointRecords/NtfyRecord.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
namespace API.APIEndpointRecords;
|
||||||
|
|
||||||
|
public record NtfyRecord(string endpoint, string username, string password, string topic, int priority)
|
||||||
|
{
|
||||||
|
public bool Validate()
|
||||||
|
{
|
||||||
|
if (endpoint == string.Empty)
|
||||||
|
return false;
|
||||||
|
if (username == string.Empty)
|
||||||
|
return false;
|
||||||
|
if (password == string.Empty)
|
||||||
|
return false;
|
||||||
|
if (priority < 1 || priority > 5)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
373
API/Controllers/JobController.cs
Normal file
373
API/Controllers/JobController.cs
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
using API.APIEndpointRecords;
|
||||||
|
using API.Schema;
|
||||||
|
using API.Schema.Jobs;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using static Microsoft.AspNetCore.Http.StatusCodes;
|
||||||
|
|
||||||
|
namespace API.Controllers;
|
||||||
|
|
||||||
|
[ApiVersion(2)]
|
||||||
|
[ApiController]
|
||||||
|
[Route("v{version:apiVersion}/[controller]")]
|
||||||
|
public class JobController(PgsqlContext context) : Controller
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all Jobs
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType<Job[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetAllJobs()
|
||||||
|
{
|
||||||
|
Job[] ret = context.Jobs.ToArray();
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns Jobs with requested Job-IDs
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ids">Array of Job-IDs</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpPost("WithIDs")]
|
||||||
|
[ProducesResponseType<Job[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetJobs([FromBody]string[] ids)
|
||||||
|
{
|
||||||
|
Job[] ret = context.Jobs.Where(job => ids.Contains(job.JobId)).ToArray();
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all Jobs in requested State
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="JobState">Requested Job-State</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet("State/{JobState}")]
|
||||||
|
[ProducesResponseType<Job[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetJobsInState(JobState JobState)
|
||||||
|
{
|
||||||
|
Job[] jobsInState = context.Jobs.Where(job => job.state == JobState).ToArray();
|
||||||
|
return Ok(jobsInState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all Jobs of requested Type
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="JobType">Requested Job-Type</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet("Type/{JobType}")]
|
||||||
|
[ProducesResponseType<Job[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetJobsOfType(JobType JobType)
|
||||||
|
{
|
||||||
|
Job[] jobsOfType = context.Jobs.Where(job => job.JobType == JobType).ToArray();
|
||||||
|
return Ok(jobsOfType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all Jobs of requested Type and State
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="JobType">Requested Job-Type</param>
|
||||||
|
/// <param name="JobState">Requested Job-State</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet("TypeAndState/{JobType}/{JobState}")]
|
||||||
|
[ProducesResponseType<Job[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetJobsOfType(JobType JobType, JobState JobState)
|
||||||
|
{
|
||||||
|
Job[] jobsOfType = context.Jobs.Where(job => job.JobType == JobType && job.state == JobState).ToArray();
|
||||||
|
return Ok(jobsOfType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return Job with ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="JobId">Job-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">Job with ID could not be found</response>
|
||||||
|
[HttpGet("{JobId}")]
|
||||||
|
[ProducesResponseType<Job>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
public IActionResult GetJob(string JobId)
|
||||||
|
{
|
||||||
|
Job? ret = context.Jobs.Find(JobId);
|
||||||
|
return (ret is not null) switch
|
||||||
|
{
|
||||||
|
true => Ok(ret),
|
||||||
|
false => NotFound()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new DownloadAvailableChaptersJob
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">ID of Manga</param>
|
||||||
|
/// <param name="record">Job-Configuration</param>
|
||||||
|
/// <response code="201">Job-IDs</response>
|
||||||
|
/// <response code="400">Could not find Library with ID</response>
|
||||||
|
/// <response code="404">Could not find Manga with ID</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPut("DownloadAvailableChaptersJob/{MangaId}")]
|
||||||
|
[ProducesResponseType<string[]>(Status201Created, "application/json")]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateDownloadAvailableChaptersJob(string MangaId, [FromBody]DownloadAvailableJobsRecord record)
|
||||||
|
{
|
||||||
|
if (context.Mangas.Find(MangaId) is not { } m)
|
||||||
|
return NotFound();
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LocalLibrary? l = context.LocalLibraries.Find(record.localLibraryId);
|
||||||
|
if (l is null)
|
||||||
|
return BadRequest();
|
||||||
|
m.Library = l;
|
||||||
|
context.SaveChanges();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Job dep = new RetrieveChaptersJob(record.recurrenceTimeMs, MangaId);
|
||||||
|
Job job = new DownloadAvailableChaptersJob(record.recurrenceTimeMs, MangaId, null, [dep.JobId]);
|
||||||
|
return AddJobs([dep, job]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new DownloadSingleChapterJob
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ChapterId">ID of the Chapter</param>
|
||||||
|
/// <response code="201">Job-IDs</response>
|
||||||
|
/// <response code="404">Could not find Chapter with ID</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPut("DownloadSingleChapterJob/{ChapterId}")]
|
||||||
|
[ProducesResponseType<string[]>(Status201Created, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateNewDownloadChapterJob(string ChapterId)
|
||||||
|
{
|
||||||
|
if(context.Chapters.Find(ChapterId) is null)
|
||||||
|
return NotFound();
|
||||||
|
Job job = new DownloadSingleChapterJob(ChapterId);
|
||||||
|
return AddJobs([job]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new UpdateFilesDownloadedJob
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">ID of the Manga</param>
|
||||||
|
/// <response code="201">Job-IDs</response>
|
||||||
|
/// <response code="201">Could not find Manga with ID</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPut("UpdateFilesJob/{MangaId}")]
|
||||||
|
[ProducesResponseType<string[]>(Status201Created, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateUpdateFilesDownloadedJob(string MangaId)
|
||||||
|
{
|
||||||
|
if(context.Mangas.Find(MangaId) is null)
|
||||||
|
return NotFound();
|
||||||
|
Job job = new UpdateFilesDownloadedJob(0, MangaId);
|
||||||
|
return AddJobs([job]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new UpdateMetadataJob for all Manga
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="201">Job-IDs</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPut("UpdateAllFilesJob")]
|
||||||
|
[ProducesResponseType<string[]>(Status201Created, "application/json")]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateUpdateAllFilesDownloadedJob()
|
||||||
|
{
|
||||||
|
List<string> ids = context.Mangas.Select(m => m.MangaId).ToList();
|
||||||
|
List<UpdateFilesDownloadedJob> jobs = ids.Select(id => new UpdateFilesDownloadedJob(0, id)).ToList();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Jobs.AddRange(jobs);
|
||||||
|
context.SaveChanges();
|
||||||
|
return Created();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new UpdateMetadataJob
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">ID of the Manga</param>
|
||||||
|
/// <response code="201">Job-IDs</response>
|
||||||
|
/// <response code="404">Could not find Manga with ID</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPut("UpdateMetadataJob/{MangaId}")]
|
||||||
|
[ProducesResponseType<string[]>(Status201Created, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateUpdateMetadataJob(string MangaId)
|
||||||
|
{
|
||||||
|
if(context.Mangas.Find(MangaId) is null)
|
||||||
|
return NotFound();
|
||||||
|
Job job = new UpdateMetadataJob(0, MangaId);
|
||||||
|
return AddJobs([job]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new UpdateMetadataJob for all Manga
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="201">Job-IDs</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPut("UpdateAllMetadataJob")]
|
||||||
|
[ProducesResponseType<string[]>(Status201Created, "application/json")]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateUpdateAllMetadataJob()
|
||||||
|
{
|
||||||
|
List<string> ids = context.Mangas.Select(m => m.MangaId).ToList();
|
||||||
|
List<UpdateMetadataJob> jobs = ids.Select(id => new UpdateMetadataJob(0, id)).ToList();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Jobs.AddRange(jobs);
|
||||||
|
context.SaveChanges();
|
||||||
|
return Created();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult AddJobs(Job[] jobs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Jobs.AddRange(jobs);
|
||||||
|
context.SaveChanges();
|
||||||
|
return new CreatedResult((string?)null, jobs.Select(j => j.JobId).ToArray());
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete Job with ID and all children
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="JobId">Job-ID</param>
|
||||||
|
/// <response code="200">Job(s) deleted</response>
|
||||||
|
/// <response code="404">Job could not be found</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpDelete("{JobId}")]
|
||||||
|
[ProducesResponseType<string[]>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult DeleteJob(string JobId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Job? ret = context.Jobs.Find(JobId);
|
||||||
|
if(ret is null)
|
||||||
|
return NotFound();
|
||||||
|
IQueryable<Job> children = GetChildJobs(JobId);
|
||||||
|
|
||||||
|
context.RemoveRange(children);
|
||||||
|
context.Remove(ret);
|
||||||
|
context.SaveChanges();
|
||||||
|
return new OkObjectResult(children.Select(x => x.JobId).Append(ret.JobId).ToArray());
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<Job> GetChildJobs(string parentJobId)
|
||||||
|
{
|
||||||
|
IQueryable<Job> children = context.Jobs.Where(j => j.ParentJobId == parentJobId);
|
||||||
|
foreach (Job child in children)
|
||||||
|
foreach (Job grandChild in GetChildJobs(child.JobId))
|
||||||
|
children.Append(grandChild);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modify Job with ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="JobId">Job-ID</param>
|
||||||
|
/// <param name="modifyJobRecord">Fields to modify, set to null to keep previous value</param>
|
||||||
|
/// <response code="202">Job modified</response>
|
||||||
|
/// <response code="400">Malformed request</response>
|
||||||
|
/// <response code="404">Job with ID not found</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPatch("{JobId}")]
|
||||||
|
[ProducesResponseType<Job>(Status202Accepted, "application/json")]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult ModifyJob(string JobId, [FromBody]ModifyJobRecord modifyJobRecord)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Job? ret = context.Jobs.Find(JobId);
|
||||||
|
if(ret is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
ret.RecurrenceMs = modifyJobRecord.RecurrenceMs ?? ret.RecurrenceMs;
|
||||||
|
ret.Enabled = modifyJobRecord.Enabled ?? ret.Enabled;
|
||||||
|
|
||||||
|
context.SaveChanges();
|
||||||
|
return new AcceptedResult(ret.JobId, ret);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the Job with the requested ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="JobId">Job-ID</param>
|
||||||
|
/// <response code="202">Job started</response>
|
||||||
|
/// <response code="404">Job with ID not found</response>
|
||||||
|
/// <response code="409">Job was already running</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPost("{JobId}/Start")]
|
||||||
|
[ProducesResponseType(Status202Accepted)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType(Status409Conflict)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult StartJob(string JobId)
|
||||||
|
{
|
||||||
|
Job? ret = context.Jobs.Find(JobId);
|
||||||
|
if (ret is null)
|
||||||
|
return NotFound();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ret.state >= JobState.Running && ret.state < JobState.Completed)
|
||||||
|
return new ConflictResult();
|
||||||
|
ret.LastExecution = DateTime.UnixEpoch;
|
||||||
|
context.SaveChanges();
|
||||||
|
return Accepted();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the Job with the requested ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="JobId">Job-ID</param>
|
||||||
|
/// <remarks><h1>NOT IMPLEMENTED</h1></remarks>
|
||||||
|
[HttpPost("{JobId}/Stop")]
|
||||||
|
[ProducesResponseType(Status501NotImplemented)]
|
||||||
|
public IActionResult StopJob(string JobId)
|
||||||
|
{
|
||||||
|
return StatusCode(501);
|
||||||
|
}
|
||||||
|
}
|
96
API/Controllers/LibraryConnectorController.cs
Normal file
96
API/Controllers/LibraryConnectorController.cs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
using API.Schema;
|
||||||
|
using API.Schema.LibraryConnectors;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using static Microsoft.AspNetCore.Http.StatusCodes;
|
||||||
|
|
||||||
|
namespace API.Controllers;
|
||||||
|
|
||||||
|
[ApiVersion(2)]
|
||||||
|
[ApiController]
|
||||||
|
[Route("v{v:apiVersion}/[controller]")]
|
||||||
|
public class LibraryConnectorController(PgsqlContext context) : Controller
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all configured Library-Connectors
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType<LibraryConnector[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetAllConnectors()
|
||||||
|
{
|
||||||
|
LibraryConnector[] connectors = context.LibraryConnectors.ToArray();
|
||||||
|
return Ok(connectors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns Library-Connector with requested ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="LibraryControllerId">Library-Connector-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">Connector with ID not found.</response>
|
||||||
|
[HttpGet("{LibraryControllerId}")]
|
||||||
|
[ProducesResponseType<LibraryConnector>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
public IActionResult GetConnector(string LibraryControllerId)
|
||||||
|
{
|
||||||
|
LibraryConnector? ret = context.LibraryConnectors.Find(LibraryControllerId);
|
||||||
|
return (ret is not null) switch
|
||||||
|
{
|
||||||
|
true => Ok(ret),
|
||||||
|
false => NotFound()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new Library-Connector
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryConnector">Library-Connector</param>
|
||||||
|
/// <response code="201"></response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType(Status201Created)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateConnector([FromBody]LibraryConnector libraryConnector)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.LibraryConnectors.Add(libraryConnector);
|
||||||
|
context.SaveChanges();
|
||||||
|
return Created();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the Library-Connector with the requested ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="LibraryControllerId">Library-Connector-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">Connector with ID not found.</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpDelete("{LibraryControllerId}")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult DeleteConnector(string LibraryControllerId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LibraryConnector? ret = context.LibraryConnectors.Find(LibraryControllerId);
|
||||||
|
if (ret is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
context.Remove(ret);
|
||||||
|
context.SaveChanges();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
157
API/Controllers/LocalLibrariesController.cs
Normal file
157
API/Controllers/LocalLibrariesController.cs
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
using API.APIEndpointRecords;
|
||||||
|
using API.Schema;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using static Microsoft.AspNetCore.Http.StatusCodes;
|
||||||
|
|
||||||
|
namespace API.Controllers;
|
||||||
|
|
||||||
|
[ApiVersion(2)]
|
||||||
|
[ApiController]
|
||||||
|
[Route("v{v:apiVersion}/[controller]")]
|
||||||
|
public class LocalLibrariesController(PgsqlContext context) : Controller
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType<LocalLibrary[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetLocalLibraries()
|
||||||
|
{
|
||||||
|
return Ok(context.LocalLibraries);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{LibraryId}")]
|
||||||
|
[ProducesResponseType<LocalLibrary>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
public IActionResult GetLocalLibrary(string LibraryId)
|
||||||
|
{
|
||||||
|
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
|
||||||
|
if (library is null)
|
||||||
|
return NotFound();
|
||||||
|
return Ok(library);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{LibraryId}")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult UpdateLocalLibrary(string LibraryId, [FromBody]NewLibraryRecord record)
|
||||||
|
{
|
||||||
|
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
|
||||||
|
if (library is null)
|
||||||
|
return NotFound();
|
||||||
|
if (record.Validate() == false)
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
library.LibraryName = record.name;
|
||||||
|
library.BasePath = record.path;
|
||||||
|
context.SaveChanges();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{LibraryId}/ChangeBasePath")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult ChangeLibraryBasePath(string LibraryId, [FromBody] string newBasePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
|
||||||
|
if (library is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
if (false) //TODO implement path check
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
library.BasePath = newBasePath;
|
||||||
|
context.SaveChanges();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{LibraryId}/ChangeName")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult ChangeLibraryName(string LibraryId, [FromBody] string newName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
|
||||||
|
if (library is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
if(newName.Length < 1)
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
library.LibraryName = newName;
|
||||||
|
context.SaveChanges();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType<LocalLibrary>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateNewLibrary([FromBody]NewLibraryRecord library)
|
||||||
|
{
|
||||||
|
if (library.Validate() == false)
|
||||||
|
return BadRequest();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LocalLibrary newLibrary = new (library.path, library.name);
|
||||||
|
context.LocalLibraries.Add(newLibrary);
|
||||||
|
context.SaveChanges();
|
||||||
|
|
||||||
|
return Ok(newLibrary);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{LibraryId}")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult DeleteLocalLibrary(string LibraryId)
|
||||||
|
{
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
|
||||||
|
if (library is null)
|
||||||
|
return NotFound();
|
||||||
|
context.Remove(library);
|
||||||
|
context.SaveChanges();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
80
API/Controllers/MangaConnectorController.cs
Normal file
80
API/Controllers/MangaConnectorController.cs
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
using API.Schema;
|
||||||
|
using API.Schema.MangaConnectors;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using static Microsoft.AspNetCore.Http.StatusCodes;
|
||||||
|
|
||||||
|
namespace API.Controllers;
|
||||||
|
|
||||||
|
[ApiVersion(2)]
|
||||||
|
[ApiController]
|
||||||
|
[Route("v{v:apiVersion}/[controller]")]
|
||||||
|
public class MangaConnectorController(PgsqlContext context) : Controller
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get all available Connectors (Scanlation-Sites)
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetConnectors()
|
||||||
|
{
|
||||||
|
MangaConnector[] connectors = context.MangaConnectors.ToArray();
|
||||||
|
return Ok(connectors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all enabled Connectors (Scanlation-Sites)
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet("enabled")]
|
||||||
|
[ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetEnabledConnectors()
|
||||||
|
{
|
||||||
|
MangaConnector[] connectors = context.MangaConnectors.Where(c => c.Enabled == true).ToArray();
|
||||||
|
return Ok(connectors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all disabled Connectors (Scanlation-Sites)
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet("disabled")]
|
||||||
|
[ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetDisabledConnectors()
|
||||||
|
{
|
||||||
|
MangaConnector[] connectors = context.MangaConnectors.Where(c => c.Enabled == false).ToArray();
|
||||||
|
return Ok(connectors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enabled or disables a Connector
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaConnectorName">ID of the connector</param>
|
||||||
|
/// <param name="enabled">Set true to enable</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">Connector with ID not found.</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPatch("{MangaConnectorName}/SetEnabled/{enabled}")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult SetEnabled(string MangaConnectorName, bool enabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MangaConnector? connector = context.MangaConnectors.Find(MangaConnectorName);
|
||||||
|
if (connector is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
connector.Enabled = enabled;
|
||||||
|
context.SaveChanges();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
355
API/Controllers/MangaController.cs
Normal file
355
API/Controllers/MangaController.cs
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
using API.Schema;
|
||||||
|
using API.Schema.Jobs;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
using SixLabors.ImageSharp.Processing.Processors.Transforms;
|
||||||
|
using static Microsoft.AspNetCore.Http.StatusCodes;
|
||||||
|
|
||||||
|
namespace API.Controllers;
|
||||||
|
|
||||||
|
[ApiVersion(2)]
|
||||||
|
[ApiController]
|
||||||
|
[Route("v{v:apiVersion}/[controller]")]
|
||||||
|
public class MangaController(PgsqlContext context) : Controller
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all cached Manga
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetAllManga()
|
||||||
|
{
|
||||||
|
Manga[] ret = context.Mangas.ToArray();
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all cached Manga with IDs
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ids">Array of Manga-IDs</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpPost("WithIDs")]
|
||||||
|
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetManga([FromBody]string[] ids)
|
||||||
|
{
|
||||||
|
Manga[] ret = context.Mangas.Where(m => ids.Contains(m.MangaId)).ToArray();
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return Manga with ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">Manga-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">Manga with ID not found</response>
|
||||||
|
[HttpGet("{MangaId}")]
|
||||||
|
[ProducesResponseType<Manga>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
public IActionResult GetManga(string MangaId)
|
||||||
|
{
|
||||||
|
Manga? ret = context.Mangas.Find(MangaId);
|
||||||
|
if (ret is null)
|
||||||
|
return NotFound();
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete Manga with ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">Manga-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">Manga with ID not found</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpDelete("{MangaId}")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult DeleteManga(string MangaId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Manga? ret = context.Mangas.Find(MangaId);
|
||||||
|
if (ret is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
context.Remove(ret);
|
||||||
|
context.SaveChanges();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns Cover of Manga
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">Manga-ID</param>
|
||||||
|
/// <param name="width">If width is provided, height needs to also be provided</param>
|
||||||
|
/// <param name="height">If height is provided, width needs to also be provided</param>
|
||||||
|
/// <response code="200">JPEG Image</response>
|
||||||
|
/// <response code="204">Cover not loaded</response>
|
||||||
|
/// <response code="400">The formatting-request was invalid</response>
|
||||||
|
/// <response code="404">Manga with ID not found</response>
|
||||||
|
/// <response code="503">Retry later, downloading cover</response>
|
||||||
|
[HttpGet("{MangaId}/Cover")]
|
||||||
|
[ProducesResponseType<byte[]>(Status200OK,"image/jpeg")]
|
||||||
|
[ProducesResponseType(Status204NoContent)]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
|
||||||
|
public IActionResult GetCover(string MangaId, [FromQuery]int? width, [FromQuery]int? height)
|
||||||
|
{
|
||||||
|
DateTime requestStarted = HttpContext.Features.Get<IHttpRequestTimeFeature>()?.RequestTime ?? DateTime.Now;
|
||||||
|
Manga? m = context.Mangas.Find(MangaId);
|
||||||
|
if (m is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(m.CoverFileNameInCache))
|
||||||
|
{
|
||||||
|
List<Job> coverDownloadJobs = context.Jobs.Where(j => j.JobType == JobType.DownloadMangaCoverJob).ToList();
|
||||||
|
if (coverDownloadJobs.Any(j => j is DownloadMangaCoverJob dmc && dmc.MangaId == MangaId))
|
||||||
|
{
|
||||||
|
Response.Headers.Add("Retry-After", $"{TrangaSettings.startNewJobTimeoutMs * coverDownloadJobs.Count() * 2 / 1000:D}");
|
||||||
|
return StatusCode(Status503ServiceUnavailable, TrangaSettings.startNewJobTimeoutMs * coverDownloadJobs.Count() * 2 / 1000);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Image image = Image.Load(m.CoverFileNameInCache);
|
||||||
|
|
||||||
|
if (width is { } w && height is { } h)
|
||||||
|
{
|
||||||
|
if (width < 10 || height < 10 || width > 65535 || height > 65535)
|
||||||
|
return BadRequest();
|
||||||
|
image.Mutate(i => i.ApplyProcessor(new ResizeProcessor(new ResizeOptions()
|
||||||
|
{
|
||||||
|
Mode = ResizeMode.Max,
|
||||||
|
Size = new Size(w, h)
|
||||||
|
}, image.Size)));
|
||||||
|
}
|
||||||
|
|
||||||
|
using MemoryStream ms = new();
|
||||||
|
image.Save(ms, new JpegEncoder(){Quality = 100});
|
||||||
|
return File(ms.GetBuffer(), "image/jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all Chapters of Manga
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">Manga-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">Manga with ID not found</response>
|
||||||
|
[HttpGet("{MangaId}/Chapters")]
|
||||||
|
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
public IActionResult GetChapters(string MangaId)
|
||||||
|
{
|
||||||
|
Manga? m = context.Mangas.Find(MangaId);
|
||||||
|
if (m is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
Chapter[] ret = context.Chapters.Where(c => c.ParentMangaId == m.MangaId).ToArray();
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all downloaded Chapters for Manga with ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">Manga-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="204">No available chapters</response>
|
||||||
|
/// <response code="404">Manga with ID not found.</response>
|
||||||
|
[HttpGet("{MangaId}/Chapters/Downloaded")]
|
||||||
|
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status204NoContent)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
public IActionResult GetChaptersDownloaded(string MangaId)
|
||||||
|
{
|
||||||
|
Manga? m = context.Mangas.Find(MangaId);
|
||||||
|
if (m is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
List<Chapter> chapters = context.Chapters.Where(c => c.ParentMangaId == m.MangaId && c.Downloaded == true).ToList();
|
||||||
|
if (chapters.Count == 0)
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
|
return Ok(chapters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all Chapters not downloaded for Manga with ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">Manga-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="204">No available chapters</response>
|
||||||
|
/// <response code="404">Manga with ID not found.</response>
|
||||||
|
[HttpGet("{MangaId}/Chapters/NotDownloaded")]
|
||||||
|
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status204NoContent)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
public IActionResult GetChaptersNotDownloaded(string MangaId)
|
||||||
|
{
|
||||||
|
Manga? m = context.Mangas.Find(MangaId);
|
||||||
|
if (m is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
List<Chapter> chapters = context.Chapters.Where(c => c.ParentMangaId == m.MangaId && c.Downloaded == false).ToList();
|
||||||
|
if (chapters.Count == 0)
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
|
return Ok(chapters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the latest Chapter of requested Manga available on Website
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">Manga-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="204">No available chapters</response>
|
||||||
|
/// <response code="404">Manga with ID not found.</response>
|
||||||
|
/// <response code="500">Could not retrieve the maximum chapter-number</response>
|
||||||
|
/// <response code="503">Retry after timeout, updating value</response>
|
||||||
|
[HttpGet("{MangaId}/Chapter/LatestAvailable")]
|
||||||
|
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status204NoContent)]
|
||||||
|
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
|
||||||
|
public IActionResult GetLatestChapter(string MangaId)
|
||||||
|
{
|
||||||
|
Manga? m = context.Mangas.Find(MangaId);
|
||||||
|
if (m is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
List<Chapter> chapters = context.Chapters.Where(c => c.ParentMangaId == m.MangaId).ToList();
|
||||||
|
if (chapters.Count == 0)
|
||||||
|
{
|
||||||
|
List<Job> retrieveChapterJobs = context.Jobs.Where(j => j.JobType == JobType.RetrieveChaptersJob).ToList();
|
||||||
|
if (retrieveChapterJobs.Any(j => j is RetrieveChaptersJob rcj && rcj.MangaId == MangaId))
|
||||||
|
{
|
||||||
|
Response.Headers.Add("Retry-After", $"{TrangaSettings.startNewJobTimeoutMs * retrieveChapterJobs.Count() * 2 / 1000:D}");
|
||||||
|
return StatusCode(Status503ServiceUnavailable, TrangaSettings.startNewJobTimeoutMs * retrieveChapterJobs.Count() * 2/ 1000);
|
||||||
|
}else
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Chapter? max = chapters.Max();
|
||||||
|
if (max is null)
|
||||||
|
return StatusCode(500, "Max chapter could not be found");
|
||||||
|
|
||||||
|
return Ok(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the latest Chapter of requested Manga that is downloaded
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">Manga-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="204">No available chapters</response>
|
||||||
|
/// <response code="404">Manga with ID not found.</response>
|
||||||
|
/// <response code="500">Could not retrieve the maximum chapter-number</response>
|
||||||
|
/// <response code="503">Retry after timeout, updating value</response>
|
||||||
|
[HttpGet("{MangaId}/Chapter/LatestDownloaded")]
|
||||||
|
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status204NoContent)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
|
||||||
|
public IActionResult GetLatestChapterDownloaded(string MangaId)
|
||||||
|
{
|
||||||
|
Manga? m = context.Mangas.Find(MangaId);
|
||||||
|
if (m is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
|
||||||
|
List<Chapter> chapters = context.Chapters.Where(c => c.ParentMangaId == m.MangaId && c.Downloaded == true).ToList();
|
||||||
|
if (chapters.Count == 0)
|
||||||
|
{
|
||||||
|
List<Job> retrieveChapterJobs = context.Jobs.Where(j => j.JobType == JobType.RetrieveChaptersJob).ToList();
|
||||||
|
if (retrieveChapterJobs.Any(j => j is RetrieveChaptersJob rcj && rcj.MangaId == MangaId))
|
||||||
|
{
|
||||||
|
Response.Headers.Add("Retry-After", $"{TrangaSettings.startNewJobTimeoutMs * retrieveChapterJobs.Count() * 2 / 1000:D}");
|
||||||
|
return StatusCode(Status503ServiceUnavailable, TrangaSettings.startNewJobTimeoutMs * retrieveChapterJobs.Count() * 2 / 1000);
|
||||||
|
}else
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Chapter? max = chapters.Max();
|
||||||
|
if (max is null)
|
||||||
|
return StatusCode(500, "Max chapter could not be found");
|
||||||
|
|
||||||
|
return Ok(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configure the cut-off for Manga
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">Manga-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">Manga with ID not found.</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPatch("{MangaId}/IgnoreChaptersBefore")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult IgnoreChaptersBefore(string MangaId, [FromBody]float chapterThreshold)
|
||||||
|
{
|
||||||
|
Manga? m = context.Mangas.Find(MangaId);
|
||||||
|
if (m is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
m.IgnoreChapterBefore = chapterThreshold;
|
||||||
|
context.SaveChanges();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Move Manga to different Library
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaId">Manga-ID</param>
|
||||||
|
/// <param name="LibraryId">Library-Id</param>
|
||||||
|
/// <response code="202">Folder is going to be moved</response>
|
||||||
|
/// <response code="404">MangaId or LibraryId not found</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPost("{MangaId}/ChangeLibrary/{LibraryId}")]
|
||||||
|
[ProducesResponseType(Status202Accepted)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult MoveFolder(string MangaId, string LibraryId)
|
||||||
|
{
|
||||||
|
Manga? manga = context.Mangas.Find(MangaId);
|
||||||
|
if (manga is null)
|
||||||
|
return NotFound();
|
||||||
|
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
|
||||||
|
if (library is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
MoveMangaLibraryJob dep = new (MangaId, LibraryId);
|
||||||
|
UpdateFilesDownloadedJob up = new (0, manga.MangaId, null, [dep.JobId]);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Jobs.AddRange([dep, up]);
|
||||||
|
context.SaveChanges();
|
||||||
|
return Accepted();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
189
API/Controllers/NotificationConnectorController.cs
Normal file
189
API/Controllers/NotificationConnectorController.cs
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
using System.Text;
|
||||||
|
using API.APIEndpointRecords;
|
||||||
|
using API.Schema;
|
||||||
|
using API.Schema.NotificationConnectors;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using static Microsoft.AspNetCore.Http.StatusCodes;
|
||||||
|
|
||||||
|
namespace API.Controllers;
|
||||||
|
|
||||||
|
[ApiVersion(2)]
|
||||||
|
[ApiController]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Route("v{v:apiVersion}/[controller]")]
|
||||||
|
public class NotificationConnectorController(PgsqlContext context) : Controller
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all configured Notification-Connectors
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType<NotificationConnector[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetAllConnectors()
|
||||||
|
{
|
||||||
|
NotificationConnector[] ret = context.NotificationConnectors.ToArray();
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns Notification-Connector with requested ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="NotificationConnectorId">Notification-Connector-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">NotificationConnector with ID not found</response>
|
||||||
|
[HttpGet("{NotificationConnectorId}")]
|
||||||
|
[ProducesResponseType<NotificationConnector>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
public IActionResult GetConnector(string NotificationConnectorId)
|
||||||
|
{
|
||||||
|
NotificationConnector? ret = context.NotificationConnectors.Find(NotificationConnectorId);
|
||||||
|
return (ret is not null) switch
|
||||||
|
{
|
||||||
|
true => Ok(ret),
|
||||||
|
false => NotFound()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new REST-Notification-Connector
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Formatting placeholders: "%title" and "%text" can be placed in url, header-values and body and will be replaced when notifications are sent</remarks>
|
||||||
|
/// <param name="notificationConnector">Notification-Connector</param>
|
||||||
|
/// <response code="201">ID of new connector</response>
|
||||||
|
/// <response code="409">A NotificationConnector with name already exists</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType<string>(Status201Created, "application/json")]
|
||||||
|
[ProducesResponseType(Status409Conflict)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateConnector([FromBody]NotificationConnector notificationConnector)
|
||||||
|
{
|
||||||
|
if (context.NotificationConnectors.Find(notificationConnector.Name) is not null)
|
||||||
|
return Conflict();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.NotificationConnectors.Add(notificationConnector);
|
||||||
|
context.SaveChanges();
|
||||||
|
return Created(notificationConnector.Name, notificationConnector);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new Gotify-Notification-Connector
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Priority needs to be between 0 and 10</remarks>
|
||||||
|
/// <response code="201">ID of new connector</response>
|
||||||
|
/// <response code="400"></response>
|
||||||
|
/// <response code="409">A NotificationConnector with name already exists</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPut("Gotify")]
|
||||||
|
[ProducesResponseType<string>(Status201Created, "application/json")]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
[ProducesResponseType(Status409Conflict)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateGotifyConnector([FromBody]GotifyRecord gotifyData)
|
||||||
|
{
|
||||||
|
if(!gotifyData.Validate())
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
NotificationConnector gotifyConnector = new NotificationConnector(TokenGen.CreateToken("Gotify"),
|
||||||
|
gotifyData.endpoint,
|
||||||
|
new Dictionary<string, string>() { { "X-Gotify-Key", gotifyData.appToken } },
|
||||||
|
"POST",
|
||||||
|
$"{{\"message\": \"%text\", \"title\": \"%title\", \"priority\": {gotifyData.priority}}}");
|
||||||
|
return CreateConnector(gotifyConnector);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new Ntfy-Notification-Connector
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Priority needs to be between 1 and 5</remarks>
|
||||||
|
/// <response code="201">ID of new connector</response>
|
||||||
|
/// <response code="400"></response>
|
||||||
|
/// <response code="409">A NotificationConnector with name already exists</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPut("Ntfy")]
|
||||||
|
[ProducesResponseType<string>(Status201Created, "application/json")]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
[ProducesResponseType(Status409Conflict)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateNtfyConnector([FromBody]NtfyRecord ntfyRecord)
|
||||||
|
{
|
||||||
|
if(!ntfyRecord.Validate())
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
string authHeader = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ntfyRecord.username}:{ntfyRecord.password}"));
|
||||||
|
string auth = Convert.ToBase64String(Encoding.UTF8.GetBytes(authHeader)).Replace("=","");
|
||||||
|
|
||||||
|
NotificationConnector ntfyConnector = new NotificationConnector(TokenGen.CreateToken("Ntfy"),
|
||||||
|
$"{ntfyRecord.endpoint}?auth={auth}",
|
||||||
|
new Dictionary<string, string>()
|
||||||
|
{
|
||||||
|
{"Title", "%title"},
|
||||||
|
{"Priority", ntfyRecord.priority.ToString()},
|
||||||
|
},
|
||||||
|
"POST",
|
||||||
|
"%text");
|
||||||
|
return CreateConnector(ntfyConnector);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new Lunasea-Notification-Connector
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>https://docs.lunasea.app/lunasea/notifications/custom-notifications for id. Either device/:device_id or user/:user_id</remarks>
|
||||||
|
/// <response code="201">ID of new connector</response>
|
||||||
|
/// <response code="400"></response>
|
||||||
|
/// <response code="409">A NotificationConnector with name already exists</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPut("Lunasea")]
|
||||||
|
[ProducesResponseType<string>(Status201Created, "application/json")]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
[ProducesResponseType(Status409Conflict)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult CreateLunaseaConnector([FromBody]LunaseaRecord lunaseaRecord)
|
||||||
|
{
|
||||||
|
if(!lunaseaRecord.Validate())
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
NotificationConnector lunaseaConnector = new NotificationConnector(TokenGen.CreateToken("Lunasea"),
|
||||||
|
$"https://notify.lunasea.app/v1/custom/{lunaseaRecord.id}",
|
||||||
|
new Dictionary<string, string>(),
|
||||||
|
"POST",
|
||||||
|
"{\"title\": \"%title\", \"body\": \"%text\"}");
|
||||||
|
return CreateConnector(lunaseaConnector);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the Notification-Connector with the requested ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="NotificationConnectorId">Notification-Connector-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">NotificationConnector with ID not found</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpDelete("{NotificationConnectorId}")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult DeleteConnector(string NotificationConnectorId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
NotificationConnector? ret = context.NotificationConnectors.Find(NotificationConnectorId);
|
||||||
|
if(ret is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
context.Remove(ret);
|
||||||
|
context.SaveChanges();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
103
API/Controllers/QueryController.cs
Normal file
103
API/Controllers/QueryController.cs
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
using API.Schema;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using static Microsoft.AspNetCore.Http.StatusCodes;
|
||||||
|
|
||||||
|
namespace API.Controllers;
|
||||||
|
|
||||||
|
[ApiVersion(2)]
|
||||||
|
[ApiController]
|
||||||
|
[Route("v{v:apiVersion}/[controller]")]
|
||||||
|
public class QueryController(PgsqlContext context) : Controller
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the Author-Information for Author-ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="AuthorId">Author-Id</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">Author with ID not found</response>
|
||||||
|
[HttpGet("Author/{AuthorId}")]
|
||||||
|
[ProducesResponseType<Author>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
public IActionResult GetAuthor(string AuthorId)
|
||||||
|
{
|
||||||
|
Author? ret = context.Authors.Find(AuthorId);
|
||||||
|
if (ret is null)
|
||||||
|
return NotFound();
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all Mangas which where Authored by Author with AuthorId
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="AuthorId">Author-ID</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet("Mangas/WithAuthorId/{AuthorId}")]
|
||||||
|
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetMangaWithAuthorIds(string AuthorId)
|
||||||
|
{
|
||||||
|
return Ok(context.Mangas.Where(m => m.AuthorIds.Contains(AuthorId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns Link-Information for Link-Id
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="LinkId"></param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">Link with ID not found</response>
|
||||||
|
[HttpGet("Link/{LinkId}")]
|
||||||
|
[ProducesResponseType<Link>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
public IActionResult GetLink(string LinkId)
|
||||||
|
{
|
||||||
|
Link? ret = context.Links.Find(LinkId);
|
||||||
|
if (ret is null)
|
||||||
|
return NotFound();
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns AltTitle-Information for AltTitle-Id
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="AltTitleId"></param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">AltTitle with ID not found</response>
|
||||||
|
[HttpGet("AltTitle/{AltTitleId}")]
|
||||||
|
[ProducesResponseType<MangaAltTitle>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
public IActionResult GetAltTitle(string AltTitleId)
|
||||||
|
{
|
||||||
|
MangaAltTitle? ret = context.AltTitles.Find(AltTitleId);
|
||||||
|
if (ret is null)
|
||||||
|
return NotFound();
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all Manga with Tag
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Tag"></param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet("Mangas/WithTag/{Tag}")]
|
||||||
|
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetMangasWithTag(string Tag)
|
||||||
|
{
|
||||||
|
return Ok(context.Mangas.Where(m => m.Tags.Contains(Tag)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns Chapter-Information for Chapter-Id
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ChapterId"></param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">Chapter with ID not found</response>
|
||||||
|
[HttpGet("Chapter/{ChapterId}")]
|
||||||
|
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetChapter(string ChapterId)
|
||||||
|
{
|
||||||
|
Chapter? ret = context.Chapters.Find(ChapterId);
|
||||||
|
if (ret is null)
|
||||||
|
return NotFound();
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
}
|
200
API/Controllers/SearchController.cs
Normal file
200
API/Controllers/SearchController.cs
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
using API.Schema;
|
||||||
|
using API.Schema.Jobs;
|
||||||
|
using API.Schema.MangaConnectors;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using static Microsoft.AspNetCore.Http.StatusCodes;
|
||||||
|
|
||||||
|
namespace API.Controllers;
|
||||||
|
|
||||||
|
[ApiVersion(2)]
|
||||||
|
[ApiController]
|
||||||
|
[Route("v{v:apiVersion}/[controller]")]
|
||||||
|
public class SearchController(PgsqlContext context) : Controller
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initiate a search for a Manga on all Connectors
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Name/Title of the Manga</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPost("Name")]
|
||||||
|
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult SearchMangaGlobal([FromBody]string name)
|
||||||
|
{
|
||||||
|
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> allManga = new();
|
||||||
|
foreach (MangaConnector contextMangaConnector in context.MangaConnectors.Where(connector => connector.Enabled))
|
||||||
|
allManga.AddRange(contextMangaConnector.GetManga(name));
|
||||||
|
|
||||||
|
List<Manga> retMangas = new();
|
||||||
|
foreach ((Manga? manga, List<Author>? authors, List<MangaTag>? tags, List<Link>? links, List<MangaAltTitle>? altTitles) in allManga)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Manga? add = AddMangaToContext(manga, authors, tags, links, altTitles);
|
||||||
|
if(add is not null)
|
||||||
|
retMangas.Add(add);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(retMangas.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initiate a search for a Manga on a specific Connector
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MangaConnectorName">Manga-Connector-ID</param>
|
||||||
|
/// <param name="name">Name/Title of the Manga</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="404">MangaConnector with ID not found</response>
|
||||||
|
/// <response code="406">MangaConnector with ID is disabled</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPost("{MangaConnectorName}")]
|
||||||
|
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType(Status406NotAcceptable)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult SearchManga(string MangaConnectorName, [FromBody]string name)
|
||||||
|
{
|
||||||
|
MangaConnector? connector = context.MangaConnectors.Find(MangaConnectorName);
|
||||||
|
if (connector is null)
|
||||||
|
return NotFound();
|
||||||
|
else if (connector.Enabled is false)
|
||||||
|
return StatusCode(406);
|
||||||
|
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] mangas = connector.GetManga(name);
|
||||||
|
List<Manga> retMangas = new();
|
||||||
|
foreach ((Manga? manga, List<Author>? authors, List<MangaTag>? tags, List<Link>? links, List<MangaAltTitle>? altTitles) in mangas)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Manga? add = AddMangaToContext(manga, authors, tags, links, altTitles);
|
||||||
|
if(add is not null)
|
||||||
|
retMangas.Add(add);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(retMangas.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns Manga from MangaConnector associated with URL
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="url">Manga-Page URL</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="300">Multiple connectors found for URL</response>
|
||||||
|
/// <response code="400">No Manga at URL</response>
|
||||||
|
/// <response code="404">No connector found for URL</response>
|
||||||
|
/// <response code="500">Error during Database Operation</response>
|
||||||
|
[HttpPost("Url")]
|
||||||
|
[ProducesResponseType<Manga>(Status200OK, "application/json")]
|
||||||
|
[ProducesResponseType(Status300MultipleChoices)]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
[ProducesResponseType(Status404NotFound)]
|
||||||
|
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
|
||||||
|
public IActionResult GetMangaFromUrl([FromBody]string url)
|
||||||
|
{
|
||||||
|
List<MangaConnector> connectors = context.MangaConnectors.AsEnumerable().Where(c => c.ValidateUrl(url)).ToList();
|
||||||
|
if (connectors.Count == 0)
|
||||||
|
return NotFound();
|
||||||
|
else if (connectors.Count > 1)
|
||||||
|
return StatusCode(Status300MultipleChoices);
|
||||||
|
|
||||||
|
(Manga manga, List<Author>? authors, List<MangaTag>? tags, List<Link>? links, List<MangaAltTitle>? altTitles)? x = connectors.First().GetMangaFromUrl(url);
|
||||||
|
if (x is null)
|
||||||
|
return BadRequest();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Manga? add = AddMangaToContext(x.Value.manga, x.Value.authors, x.Value.tags, x.Value.links, x.Value.altTitles);
|
||||||
|
if (add is not null)
|
||||||
|
return Ok(add);
|
||||||
|
return StatusCode(500);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Manga? AddMangaToContext(Manga? manga, List<Author>? authors, List<MangaTag>? tags, List<Link>? links,
|
||||||
|
List<MangaAltTitle>? altTitles)
|
||||||
|
{
|
||||||
|
if (manga is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Manga? existing = context.Mangas.Find(manga.MangaId);
|
||||||
|
|
||||||
|
if (tags is not null)
|
||||||
|
{
|
||||||
|
IEnumerable<MangaTag> mergedTags = tags.Select(mt =>
|
||||||
|
{
|
||||||
|
MangaTag? inDb = context.Tags.Find(mt.Tag);
|
||||||
|
return inDb ?? mt;
|
||||||
|
});
|
||||||
|
manga.MangaTags = mergedTags.ToList();
|
||||||
|
IEnumerable<MangaTag> newTags = manga.MangaTags
|
||||||
|
.Where(mt => !context.Tags.Select(t => t.Tag).Contains(mt.Tag));
|
||||||
|
context.Tags.AddRange(newTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authors is not null)
|
||||||
|
{
|
||||||
|
IEnumerable<Author> mergedAuthors = authors.Select(ma =>
|
||||||
|
{
|
||||||
|
Author? inDb = context.Authors.Find(ma.AuthorId);
|
||||||
|
return inDb ?? ma;
|
||||||
|
});
|
||||||
|
manga.Authors = mergedAuthors.ToList();
|
||||||
|
IEnumerable<Author> newAuthors = manga.Authors
|
||||||
|
.Where(ma => !context.Authors.Select(a => a.AuthorId).Contains(ma.AuthorId));
|
||||||
|
context.Authors.AddRange(newAuthors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (links is not null)
|
||||||
|
{
|
||||||
|
IEnumerable<Link> mergedLinks = links.Select(ml =>
|
||||||
|
{
|
||||||
|
Link? inDb = context.Links.Find(ml.LinkId);
|
||||||
|
return inDb ?? ml;
|
||||||
|
});
|
||||||
|
manga.Links = mergedLinks.ToList();
|
||||||
|
IEnumerable<Link> newLinks = manga.Links
|
||||||
|
.Where(ml => !context.Links.Select(l => l.LinkId).Contains(ml.LinkId));
|
||||||
|
context.Links.AddRange(newLinks);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (altTitles is not null)
|
||||||
|
{
|
||||||
|
IEnumerable<MangaAltTitle> mergedAltTitles = altTitles.Select(mat =>
|
||||||
|
{
|
||||||
|
MangaAltTitle? inDb = context.AltTitles.Find(mat.AltTitleId);
|
||||||
|
return inDb ?? mat;
|
||||||
|
});
|
||||||
|
manga.AltTitles = mergedAltTitles.ToList();
|
||||||
|
IEnumerable<MangaAltTitle> newAltTitles = manga.AltTitles
|
||||||
|
.Where(mat => !context.AltTitles.Select(at => at.AltTitleId).Contains(mat.AltTitleId));
|
||||||
|
context.AltTitles.AddRange(newAltTitles);
|
||||||
|
}
|
||||||
|
|
||||||
|
existing?.UpdateWithInfo(manga);
|
||||||
|
if(existing is not null)
|
||||||
|
context.Mangas.Update(existing);
|
||||||
|
else
|
||||||
|
context.Mangas.Add(manga);
|
||||||
|
|
||||||
|
context.Jobs.Add(new DownloadMangaCoverJob(manga.MangaId));
|
||||||
|
context.Jobs.Add(new RetrieveChaptersJob(0, manga.MangaId));
|
||||||
|
|
||||||
|
context.SaveChanges();
|
||||||
|
return existing ?? manga;
|
||||||
|
}
|
||||||
|
}
|
202
API/Controllers/SettingsController.cs
Normal file
202
API/Controllers/SettingsController.cs
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
using API.MangaDownloadClients;
|
||||||
|
using API.Schema;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using static Microsoft.AspNetCore.Http.StatusCodes;
|
||||||
|
|
||||||
|
namespace API.Controllers;
|
||||||
|
|
||||||
|
[ApiVersion(2)]
|
||||||
|
[ApiController]
|
||||||
|
[Route("v{v:apiVersion}/[controller]")]
|
||||||
|
public class SettingsController(PgsqlContext context) : Controller
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get all Settings
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType<JObject>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetSettings()
|
||||||
|
{
|
||||||
|
return Ok(JObject.Parse(TrangaSettings.Serialize()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the current UserAgent used by Tranga
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet("UserAgent")]
|
||||||
|
[ProducesResponseType<string>(Status200OK, "text/plain")]
|
||||||
|
public IActionResult GetUserAgent()
|
||||||
|
{
|
||||||
|
return Ok(TrangaSettings.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set a new UserAgent
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpPatch("UserAgent")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
public IActionResult SetUserAgent([FromBody]string userAgent)
|
||||||
|
{
|
||||||
|
TrangaSettings.UpdateUserAgent(userAgent);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset the UserAgent to default
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpDelete("UserAgent")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
public IActionResult ResetUserAgent()
|
||||||
|
{
|
||||||
|
TrangaSettings.UpdateUserAgent(TrangaSettings.DefaultUserAgent);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all Request-Limits
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet("RequestLimits")]
|
||||||
|
[ProducesResponseType<Dictionary<RequestType,int>>(Status200OK, "application/json")]
|
||||||
|
public IActionResult GetRequestLimits()
|
||||||
|
{
|
||||||
|
return Ok(TrangaSettings.requestLimits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update all Request-Limits to new values
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks><h1>NOT IMPLEMENTED</h1></remarks>
|
||||||
|
[HttpPatch("RequestLimits")]
|
||||||
|
[ProducesResponseType(Status501NotImplemented)]
|
||||||
|
public IActionResult SetRequestLimits()
|
||||||
|
{
|
||||||
|
return StatusCode(501);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates a Request-Limit value
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="RequestType">Type of Request</param>
|
||||||
|
/// <param name="requestLimit">New limit in Requests/Minute</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="400">Limit needs to be greater than 0</response>
|
||||||
|
[HttpPatch("RequestLimits/{RequestType}")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
public IActionResult SetRequestLimit(RequestType RequestType, [FromBody]int requestLimit)
|
||||||
|
{
|
||||||
|
if (requestLimit <= 0)
|
||||||
|
return BadRequest();
|
||||||
|
TrangaSettings.UpdateRequestLimit(RequestType, requestLimit);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset Request-Limit
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpDelete("RequestLimits/{RequestType}")]
|
||||||
|
[ProducesResponseType<string>(Status200OK)]
|
||||||
|
public IActionResult ResetRequestLimits(RequestType RequestType)
|
||||||
|
{
|
||||||
|
TrangaSettings.UpdateRequestLimit(RequestType, TrangaSettings.DefaultRequestLimits[RequestType]);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset Request-Limit
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpDelete("RequestLimits")]
|
||||||
|
[ProducesResponseType<string>(Status200OK)]
|
||||||
|
public IActionResult ResetRequestLimits()
|
||||||
|
{
|
||||||
|
TrangaSettings.ResetRequestLimits();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns Level of Image-Compression for Images
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200">JPEG compression-level as Integer</response>
|
||||||
|
[HttpGet("ImageCompression")]
|
||||||
|
[ProducesResponseType<int>(Status200OK, "text/plain")]
|
||||||
|
public IActionResult GetImageCompression()
|
||||||
|
{
|
||||||
|
return Ok(TrangaSettings.compression);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set the Image-Compression-Level for Images
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="level">100 to disable, 0-99 for JPEG compression-Level</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
/// <response code="400">Level outside permitted range</response>
|
||||||
|
[HttpPatch("ImageCompression")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
[ProducesResponseType(Status400BadRequest)]
|
||||||
|
public IActionResult SetImageCompression([FromBody]int level)
|
||||||
|
{
|
||||||
|
if (level < 1 || level > 100)
|
||||||
|
return BadRequest();
|
||||||
|
TrangaSettings.UpdateCompressImages(level);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get state of Black/White-Image setting
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200">True if enabled</response>
|
||||||
|
[HttpGet("BWImages")]
|
||||||
|
[ProducesResponseType<bool>(Status200OK, "text/plain")]
|
||||||
|
public IActionResult GetBwImagesToggle()
|
||||||
|
{
|
||||||
|
return Ok(TrangaSettings.bwImages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable/Disable conversion of Images to Black and White
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="enabled">true to enable</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpPatch("BWImages")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
public IActionResult SetBwImagesToggle([FromBody]bool enabled)
|
||||||
|
{
|
||||||
|
TrangaSettings.UpdateBwImages(enabled);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get state of April Fools Mode
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>April Fools Mode disables all downloads on April 1st</remarks>
|
||||||
|
/// <response code="200">True if enabled</response>
|
||||||
|
[HttpGet("AprilFoolsMode")]
|
||||||
|
[ProducesResponseType<bool>(Status200OK, "text/plain")]
|
||||||
|
public IActionResult GetAprilFoolsMode()
|
||||||
|
{
|
||||||
|
return Ok(TrangaSettings.aprilFoolsMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable/Disable April Fools Mode
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>April Fools Mode disables all downloads on April 1st</remarks>
|
||||||
|
/// <param name="enabled">true to enable</param>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpPatch("AprilFoolsMode")]
|
||||||
|
[ProducesResponseType(Status200OK)]
|
||||||
|
public IActionResult SetAprilFoolsMode([FromBody]bool enabled)
|
||||||
|
{
|
||||||
|
TrangaSettings.UpdateAprilFoolsMode(enabled);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
35
API/HttpRequestTimeFeature.cs
Normal file
35
API/HttpRequestTimeFeature.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
namespace API;
|
||||||
|
|
||||||
|
public interface IHttpRequestTimeFeature
|
||||||
|
{
|
||||||
|
DateTime RequestTime { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HttpRequestTimeFeature : IHttpRequestTimeFeature
|
||||||
|
{
|
||||||
|
public DateTime RequestTime { get; }
|
||||||
|
|
||||||
|
public HttpRequestTimeFeature()
|
||||||
|
{
|
||||||
|
RequestTime = DateTime.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RequestTimeMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
public RequestTimeMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var httpRequestTimeFeature = new HttpRequestTimeFeature();
|
||||||
|
context.Features.Set<IHttpRequestTimeFeature>(httpRequestTimeFeature);
|
||||||
|
|
||||||
|
// Call the next delegate/middleware in the pipeline
|
||||||
|
return this._next(context);
|
||||||
|
}
|
||||||
|
}
|
110
API/MangaDownloadClients/ChromiumDownloadClient.cs
Normal file
110
API/MangaDownloadClients/ChromiumDownloadClient.cs
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
using PuppeteerSharp;
|
||||||
|
|
||||||
|
namespace API.MangaDownloadClients;
|
||||||
|
|
||||||
|
internal class ChromiumDownloadClient : DownloadClient
|
||||||
|
{
|
||||||
|
private static IBrowser? _browser;
|
||||||
|
private readonly HttpDownloadClient _httpDownloadClient;
|
||||||
|
private readonly Thread _closeStalePagesThread;
|
||||||
|
private readonly List<KeyValuePair<IPage, DateTime>> _openPages = new ();
|
||||||
|
|
||||||
|
private static async Task<IBrowser> StartBrowser()
|
||||||
|
{
|
||||||
|
return await Puppeteer.LaunchAsync(new LaunchOptions
|
||||||
|
{
|
||||||
|
Headless = true,
|
||||||
|
Args = new [] {
|
||||||
|
"--disable-gpu",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--no-sandbox"},
|
||||||
|
Timeout = 30000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChromiumDownloadClient()
|
||||||
|
{
|
||||||
|
_httpDownloadClient = new();
|
||||||
|
if(_browser is null)
|
||||||
|
_browser = StartBrowser().Result;
|
||||||
|
_closeStalePagesThread = new Thread(CheckStalePages);
|
||||||
|
_closeStalePagesThread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CheckStalePages()
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
Thread.Sleep(TimeSpan.FromHours(1));
|
||||||
|
foreach ((IPage? key, DateTime value) in _openPages.Where(kv => kv.Value.Subtract(DateTime.Now) > TimeSpan.FromHours(1)))
|
||||||
|
{
|
||||||
|
key.CloseAsync().Wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Regex _imageUrlRex = new(@"https?:\/\/.*\.(?:p?jpe?g|gif|a?png|bmp|avif|webp)(\?.*)?");
|
||||||
|
internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null)
|
||||||
|
{
|
||||||
|
return _imageUrlRex.IsMatch(url)
|
||||||
|
? _httpDownloadClient.MakeRequestInternal(url, referrer)
|
||||||
|
: MakeRequestBrowser(url, referrer, clickButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RequestResult MakeRequestBrowser(string url, string? referrer = null, string? clickButton = null)
|
||||||
|
{
|
||||||
|
if (_browser is null)
|
||||||
|
return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null);
|
||||||
|
IPage page = _browser.NewPageAsync().Result;
|
||||||
|
_openPages.Add(new(page, DateTime.Now));
|
||||||
|
page.SetExtraHttpHeadersAsync(new() { { "Referer", referrer } });
|
||||||
|
page.DefaultTimeout = 30000;
|
||||||
|
IResponse response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = page.GoToAsync(url, WaitUntilNavigation.Networkidle0).Result;
|
||||||
|
//Log($"Page loaded. {url}");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
//Log($"Could not load Page {url}\n{e.Message}");
|
||||||
|
page.CloseAsync();
|
||||||
|
_openPages.Remove(_openPages.Find(i => i.Key == page));
|
||||||
|
return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream stream = Stream.Null;
|
||||||
|
HtmlDocument? document = null;
|
||||||
|
|
||||||
|
if (response.Headers.TryGetValue("Content-Type", out string? content))
|
||||||
|
{
|
||||||
|
if (content.Contains("text/html"))
|
||||||
|
{
|
||||||
|
if (clickButton is not null && page.QuerySelectorAsync(clickButton).Result is not null)
|
||||||
|
page.ClickAsync(clickButton).Wait();
|
||||||
|
string htmlString = page.GetContentAsync().Result;
|
||||||
|
stream = new MemoryStream(Encoding.Default.GetBytes(htmlString));
|
||||||
|
document = new ();
|
||||||
|
document.LoadHtml(htmlString);
|
||||||
|
}else if (content.Contains("image"))
|
||||||
|
{
|
||||||
|
stream = new MemoryStream(response.BufferAsync().Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
page.CloseAsync().Wait();
|
||||||
|
_openPages.Remove(_openPages.Find(i => i.Key == page));
|
||||||
|
return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
page.CloseAsync().Wait();
|
||||||
|
_openPages.Remove(_openPages.Find(i => i.Key == page));
|
||||||
|
return new RequestResult(response.Status, document, stream, false, "");
|
||||||
|
}
|
||||||
|
}
|
42
API/MangaDownloadClients/DownloadClient.cs
Normal file
42
API/MangaDownloadClients/DownloadClient.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
using System.Net;
|
||||||
|
using API.Schema;
|
||||||
|
|
||||||
|
namespace API.MangaDownloadClients;
|
||||||
|
|
||||||
|
internal abstract class DownloadClient
|
||||||
|
{
|
||||||
|
private readonly Dictionary<RequestType, DateTime> _lastExecutedRateLimit;
|
||||||
|
|
||||||
|
protected DownloadClient()
|
||||||
|
{
|
||||||
|
this._lastExecutedRateLimit = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public RequestResult MakeRequest(string url, RequestType requestType, string? referrer = null, string? clickButton = null)
|
||||||
|
{
|
||||||
|
if (!TrangaSettings.requestLimits.ContainsKey(requestType))
|
||||||
|
{
|
||||||
|
return new RequestResult(HttpStatusCode.NotAcceptable, null, Stream.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
int rateLimit = TrangaSettings.userAgent == TrangaSettings.DefaultUserAgent
|
||||||
|
? TrangaSettings.DefaultRequestLimits[requestType]
|
||||||
|
: TrangaSettings.requestLimits[requestType];
|
||||||
|
|
||||||
|
TimeSpan timeBetweenRequests = TimeSpan.FromMinutes(1).Divide(rateLimit);
|
||||||
|
_lastExecutedRateLimit.TryAdd(requestType, DateTime.UtcNow.Subtract(timeBetweenRequests));
|
||||||
|
|
||||||
|
TimeSpan rateLimitTimeout = timeBetweenRequests.Subtract(DateTime.UtcNow.Subtract(_lastExecutedRateLimit[requestType]));
|
||||||
|
|
||||||
|
if (rateLimitTimeout > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
Thread.Sleep(rateLimitTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
RequestResult result = MakeRequestInternal(url, referrer, clickButton);
|
||||||
|
_lastExecutedRateLimit[requestType] = DateTime.UtcNow;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal abstract RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null);
|
||||||
|
}
|
73
API/MangaDownloadClients/HttpDownloadClient.cs
Normal file
73
API/MangaDownloadClients/HttpDownloadClient.cs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
using System.Net;
|
||||||
|
using API.Schema;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.MangaDownloadClients;
|
||||||
|
|
||||||
|
internal class HttpDownloadClient : DownloadClient
|
||||||
|
{
|
||||||
|
private static readonly HttpClient Client = new()
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(10)
|
||||||
|
};
|
||||||
|
|
||||||
|
public HttpDownloadClient()
|
||||||
|
{
|
||||||
|
Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", TrangaSettings.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null)
|
||||||
|
{
|
||||||
|
//TODO
|
||||||
|
//if (clickButton is not null)
|
||||||
|
//Log("Can not click button on static site.");
|
||||||
|
HttpResponseMessage? response = null;
|
||||||
|
while (response is null)
|
||||||
|
{
|
||||||
|
HttpRequestMessage requestMessage = new(HttpMethod.Get, url);
|
||||||
|
if (referrer is not null)
|
||||||
|
requestMessage.Headers.Referrer = new Uri(referrer);
|
||||||
|
//Log($"Requesting {requestType} {url}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = Client.Send(requestMessage);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
switch (e)
|
||||||
|
{
|
||||||
|
case TaskCanceledException:
|
||||||
|
return new RequestResult(HttpStatusCode.RequestTimeout, null, Stream.Null);
|
||||||
|
case HttpRequestException:
|
||||||
|
return new RequestResult(HttpStatusCode.BadRequest, null, Stream.Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return new RequestResult(response.StatusCode, null, Stream.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream stream = response.Content.ReadAsStream();
|
||||||
|
|
||||||
|
HtmlDocument? document = null;
|
||||||
|
|
||||||
|
if (response.Content.Headers.ContentType?.MediaType == "text/html")
|
||||||
|
{
|
||||||
|
StreamReader reader = new (stream);
|
||||||
|
document = new ();
|
||||||
|
document.LoadHtml(reader.ReadToEnd());
|
||||||
|
stream.Position = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request has been redirected to another page. For example, it redirects directly to the results when there is only 1 result
|
||||||
|
if (response.RequestMessage is not null && response.RequestMessage.RequestUri is not null)
|
||||||
|
{
|
||||||
|
return new RequestResult(response.StatusCode, document, stream, true,
|
||||||
|
response.RequestMessage.RequestUri.AbsoluteUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RequestResult(response.StatusCode, document, stream);
|
||||||
|
}
|
||||||
|
}
|
27
API/MangaDownloadClients/RequestResult.cs
Normal file
27
API/MangaDownloadClients/RequestResult.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
using System.Net;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.MangaDownloadClients;
|
||||||
|
|
||||||
|
public struct RequestResult
|
||||||
|
{
|
||||||
|
public HttpStatusCode statusCode { get; }
|
||||||
|
public Stream result { get; }
|
||||||
|
public bool hasBeenRedirected { get; }
|
||||||
|
public string? redirectedToUrl { get; }
|
||||||
|
public HtmlDocument? htmlDocument { get; }
|
||||||
|
|
||||||
|
public RequestResult(HttpStatusCode statusCode, HtmlDocument? htmlDocument, Stream result)
|
||||||
|
{
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.htmlDocument = htmlDocument;
|
||||||
|
this.result = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RequestResult(HttpStatusCode statusCode, HtmlDocument? htmlDocument, Stream result, bool hasBeenRedirected, string redirectedTo)
|
||||||
|
: this(statusCode, htmlDocument, result)
|
||||||
|
{
|
||||||
|
this.hasBeenRedirected = hasBeenRedirected;
|
||||||
|
redirectedToUrl = redirectedTo;
|
||||||
|
}
|
||||||
|
}
|
11
API/MangaDownloadClients/RequestType.cs
Normal file
11
API/MangaDownloadClients/RequestType.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
namespace API.MangaDownloadClients;
|
||||||
|
|
||||||
|
public enum RequestType : byte
|
||||||
|
{
|
||||||
|
Default = 0,
|
||||||
|
MangaDexFeed = 1,
|
||||||
|
MangaImage = 2,
|
||||||
|
MangaCover = 3,
|
||||||
|
MangaDexImage = 5,
|
||||||
|
MangaInfo = 6
|
||||||
|
}
|
821
API/Migrations/20250316143014_dev-160325-Initial.Designer.cs
generated
Normal file
821
API/Migrations/20250316143014_dev-160325-Initial.Designer.cs
generated
Normal file
@ -0,0 +1,821 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(PgsqlContext))]
|
||||||
|
[Migration("20250316143014_dev-160325-Initial")]
|
||||||
|
partial class dev160325Initial
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.3")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Author", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("AuthorId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("AuthorName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.HasKey("AuthorId");
|
||||||
|
|
||||||
|
b.ToTable("Authors");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("ChapterId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ChapterNumber")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<bool>("Downloaded")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("ParentMangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int?>("VolumeNumber")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("ChapterId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentMangaId");
|
||||||
|
|
||||||
|
b.ToTable("Chapters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("JobId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("DependsOnJobsIds")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<byte>("JobType")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastExecution")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ParentJobId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RecurrenceMs")
|
||||||
|
.HasColumnType("numeric(20,0)");
|
||||||
|
|
||||||
|
b.Property<byte>("state")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.HasKey("JobId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentJobId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs");
|
||||||
|
|
||||||
|
b.HasDiscriminator<byte>("JobType");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LibraryConnectors.LibraryConnector", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LibraryConnectorId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Auth")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("BaseUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<byte>("LibraryType")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.HasKey("LibraryConnectorId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryConnectors");
|
||||||
|
|
||||||
|
b.HasDiscriminator<byte>("LibraryType");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Link", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LinkId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("LinkProvider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("LinkUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("LinkId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Links");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LocalLibrary", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LocalLibraryId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("BasePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("LibraryName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.HasKey("LocalLibraryId");
|
||||||
|
|
||||||
|
b.ToTable("LocalLibraries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Manga", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("CoverFileNameInCache")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CoverUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("DirectoryName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("IdOnConnectorSite")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<float>("IgnoreChapterBefore")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<string>("LibraryLocalLibraryId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaConnectorId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalLanguage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<byte>("ReleaseStatus")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.Property<string>("WebsiteUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<long>("Year")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("MangaId");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryLocalLibraryId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaConnectorId");
|
||||||
|
|
||||||
|
b.ToTable("Mangas");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("AltTitleId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("AltTitleId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("AltTitles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaConnector", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("BaseUris")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("IconUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("SupportedLanguages")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("MangaConnectors");
|
||||||
|
|
||||||
|
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Tag")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("Tag");
|
||||||
|
|
||||||
|
b.ToTable("Tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("NotificationId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Date")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<byte>("Urgency")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.HasKey("NotificationId");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.NotificationConnectors.NotificationConnector", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, string>>("Headers")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("hstore");
|
||||||
|
|
||||||
|
b.Property<string>("HttpMethod")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("NotificationConnectors");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AuthorManga", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("AuthorsAuthorId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("AuthorsAuthorId", "MangaId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("AuthorManga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobJob", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("DependsOnJobsJobId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("JobId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("DependsOnJobsJobId", "JobId");
|
||||||
|
|
||||||
|
b.HasIndex("JobId");
|
||||||
|
|
||||||
|
b.ToTable("JobJob");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MangaMangaTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaTagsTag")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("MangaId", "MangaTagsTag");
|
||||||
|
|
||||||
|
b.HasIndex("MangaTagsTag");
|
||||||
|
|
||||||
|
b.ToTable("MangaMangaTag");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("DownloadAvailableChaptersJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)1);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)4);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("ChapterId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("ChapterId");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)0);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.MoveFileOrFolderJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("FromLocation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("ToLocation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)3);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("RetrieveChaptersJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)5);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("UpdateFilesDownloadedJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)6);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("UpdateMetadataJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)2);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LibraryConnectors.Kavita", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)1);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LibraryConnectors.Komga", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)0);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.AsuraToon", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("AsuraToon");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Bato", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Bato");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaDex", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("MangaDex");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaHere", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("MangaHere");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaKatana", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("MangaKatana");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Manganato", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Manganato");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Mangaworld", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Mangaworld");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.ManhuaPlus", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("ManhuaPlus");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Weebcentral", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Weebcentral");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "ParentManga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentMangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ParentManga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Jobs.Job", "ParentJob")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentJobId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.Navigation("ParentJob");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Link", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany("Links")
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Manga", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.LocalLibrary", "Library")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryLocalLibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaConnectorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Library");
|
||||||
|
|
||||||
|
b.Navigation("MangaConnector");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany("AltTitles")
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AuthorManga", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Author", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AuthorsAuthorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Jobs.Job", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DependsOnJobsJobId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.Jobs.Job", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("JobId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MangaMangaTag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.MangaTag", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaTagsTag")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Chapter", "Chapter")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChapterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Chapter");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Manga", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("AltTitles");
|
||||||
|
|
||||||
|
b.Navigation("Links");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
478
API/Migrations/20250316143014_dev-160325-Initial.cs
Normal file
478
API/Migrations/20250316143014_dev-160325-Initial.cs
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class dev160325Initial : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:PostgresExtension:hstore", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Authors",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
AuthorId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
AuthorName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Authors", x => x.AuthorId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "LibraryConnectors",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
LibraryConnectorId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
LibraryType = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
|
BaseUrl = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
Auth = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_LibraryConnectors", x => x.LibraryConnectorId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "LocalLibraries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
LocalLibraryId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
BasePath = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
LibraryName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_LocalLibraries", x => x.LocalLibraryId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "MangaConnectors",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Name = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
SupportedLanguages = table.Column<string[]>(type: "text[]", maxLength: 8, nullable: false),
|
||||||
|
IconUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||||
|
BaseUris = table.Column<string[]>(type: "text[]", maxLength: 256, nullable: false),
|
||||||
|
Enabled = table.Column<bool>(type: "boolean", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_MangaConnectors", x => x.Name);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "NotificationConnectors",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
Url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||||
|
Headers = table.Column<Dictionary<string, string>>(type: "hstore", nullable: false),
|
||||||
|
HttpMethod = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
|
||||||
|
Body = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_NotificationConnectors", x => x.Name);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Notifications",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
NotificationId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
Urgency = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
Message = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||||
|
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Notifications", x => x.NotificationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Tags",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Tag = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Tags", x => x.Tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Mangas",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
IdOnConnectorSite = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "text", nullable: false),
|
||||||
|
WebsiteUrl = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
CoverUrl = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CoverFileNameInCache = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Year = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
OriginalLanguage = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
|
||||||
|
ReleaseStatus = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
|
DirectoryName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
LibraryLocalLibraryId = table.Column<string>(type: "character varying(64)", nullable: true),
|
||||||
|
IgnoreChapterBefore = table.Column<float>(type: "real", nullable: false),
|
||||||
|
MangaConnectorId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Mangas", x => x.MangaId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Mangas_LocalLibraries_LibraryLocalLibraryId",
|
||||||
|
column: x => x.LibraryLocalLibraryId,
|
||||||
|
principalTable: "LocalLibraries",
|
||||||
|
principalColumn: "LocalLibraryId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Mangas_MangaConnectors_MangaConnectorId",
|
||||||
|
column: x => x.MangaConnectorId,
|
||||||
|
principalTable: "MangaConnectors",
|
||||||
|
principalColumn: "Name",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AltTitles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
AltTitleId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
Language = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
|
||||||
|
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
MangaId = table.Column<string>(type: "character varying(64)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AltTitles", x => x.AltTitleId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AltTitles_Mangas_MangaId",
|
||||||
|
column: x => x.MangaId,
|
||||||
|
principalTable: "Mangas",
|
||||||
|
principalColumn: "MangaId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AuthorManga",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
AuthorsAuthorId = table.Column<string>(type: "character varying(64)", nullable: false),
|
||||||
|
MangaId = table.Column<string>(type: "character varying(64)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AuthorManga", x => new { x.AuthorsAuthorId, x.MangaId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AuthorManga_Authors_AuthorsAuthorId",
|
||||||
|
column: x => x.AuthorsAuthorId,
|
||||||
|
principalTable: "Authors",
|
||||||
|
principalColumn: "AuthorId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AuthorManga_Mangas_MangaId",
|
||||||
|
column: x => x.MangaId,
|
||||||
|
principalTable: "Mangas",
|
||||||
|
principalColumn: "MangaId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Chapters",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ChapterId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
VolumeNumber = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
ChapterNumber = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||||
|
Url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||||
|
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
FileName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
Downloaded = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
ParentMangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Chapters", x => x.ChapterId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Chapters_Mangas_ParentMangaId",
|
||||||
|
column: x => x.ParentMangaId,
|
||||||
|
principalTable: "Mangas",
|
||||||
|
principalColumn: "MangaId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Links",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
LinkId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
LinkProvider = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
LinkUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||||
|
MangaId = table.Column<string>(type: "character varying(64)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Links", x => x.LinkId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Links_Mangas_MangaId",
|
||||||
|
column: x => x.MangaId,
|
||||||
|
principalTable: "Mangas",
|
||||||
|
principalColumn: "MangaId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "MangaMangaTag",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
MangaId = table.Column<string>(type: "character varying(64)", nullable: false),
|
||||||
|
MangaTagsTag = table.Column<string>(type: "character varying(64)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_MangaMangaTag", x => new { x.MangaId, x.MangaTagsTag });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_MangaMangaTag_Mangas_MangaId",
|
||||||
|
column: x => x.MangaId,
|
||||||
|
principalTable: "Mangas",
|
||||||
|
principalColumn: "MangaId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_MangaMangaTag_Tags_MangaTagsTag",
|
||||||
|
column: x => x.MangaTagsTag,
|
||||||
|
principalTable: "Tags",
|
||||||
|
principalColumn: "Tag",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Jobs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
JobId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
ParentJobId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
DependsOnJobsIds = table.Column<string[]>(type: "text[]", maxLength: 64, nullable: true),
|
||||||
|
JobType = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
|
RecurrenceMs = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
|
||||||
|
LastExecution = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
state = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
|
Enabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
DownloadAvailableChaptersJob_MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
ChapterId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
FromLocation = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
ToLocation = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
RetrieveChaptersJob_MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
UpdateFilesDownloadedJob_MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
UpdateMetadataJob_MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Jobs", x => x.JobId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Jobs_Chapters_ChapterId",
|
||||||
|
column: x => x.ChapterId,
|
||||||
|
principalTable: "Chapters",
|
||||||
|
principalColumn: "ChapterId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Jobs_Jobs_ParentJobId",
|
||||||
|
column: x => x.ParentJobId,
|
||||||
|
principalTable: "Jobs",
|
||||||
|
principalColumn: "JobId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Jobs_Mangas_DownloadAvailableChaptersJob_MangaId",
|
||||||
|
column: x => x.DownloadAvailableChaptersJob_MangaId,
|
||||||
|
principalTable: "Mangas",
|
||||||
|
principalColumn: "MangaId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Jobs_Mangas_MangaId",
|
||||||
|
column: x => x.MangaId,
|
||||||
|
principalTable: "Mangas",
|
||||||
|
principalColumn: "MangaId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Jobs_Mangas_RetrieveChaptersJob_MangaId",
|
||||||
|
column: x => x.RetrieveChaptersJob_MangaId,
|
||||||
|
principalTable: "Mangas",
|
||||||
|
principalColumn: "MangaId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Jobs_Mangas_UpdateFilesDownloadedJob_MangaId",
|
||||||
|
column: x => x.UpdateFilesDownloadedJob_MangaId,
|
||||||
|
principalTable: "Mangas",
|
||||||
|
principalColumn: "MangaId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Jobs_Mangas_UpdateMetadataJob_MangaId",
|
||||||
|
column: x => x.UpdateMetadataJob_MangaId,
|
||||||
|
principalTable: "Mangas",
|
||||||
|
principalColumn: "MangaId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "JobJob",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
DependsOnJobsJobId = table.Column<string>(type: "character varying(64)", nullable: false),
|
||||||
|
JobId = table.Column<string>(type: "character varying(64)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_JobJob", x => new { x.DependsOnJobsJobId, x.JobId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_JobJob_Jobs_DependsOnJobsJobId",
|
||||||
|
column: x => x.DependsOnJobsJobId,
|
||||||
|
principalTable: "Jobs",
|
||||||
|
principalColumn: "JobId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_JobJob_Jobs_JobId",
|
||||||
|
column: x => x.JobId,
|
||||||
|
principalTable: "Jobs",
|
||||||
|
principalColumn: "JobId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AltTitles_MangaId",
|
||||||
|
table: "AltTitles",
|
||||||
|
column: "MangaId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AuthorManga_MangaId",
|
||||||
|
table: "AuthorManga",
|
||||||
|
column: "MangaId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Chapters_ParentMangaId",
|
||||||
|
table: "Chapters",
|
||||||
|
column: "ParentMangaId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_JobJob_JobId",
|
||||||
|
table: "JobJob",
|
||||||
|
column: "JobId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Jobs_ChapterId",
|
||||||
|
table: "Jobs",
|
||||||
|
column: "ChapterId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Jobs_DownloadAvailableChaptersJob_MangaId",
|
||||||
|
table: "Jobs",
|
||||||
|
column: "DownloadAvailableChaptersJob_MangaId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Jobs_MangaId",
|
||||||
|
table: "Jobs",
|
||||||
|
column: "MangaId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Jobs_ParentJobId",
|
||||||
|
table: "Jobs",
|
||||||
|
column: "ParentJobId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Jobs_RetrieveChaptersJob_MangaId",
|
||||||
|
table: "Jobs",
|
||||||
|
column: "RetrieveChaptersJob_MangaId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Jobs_UpdateFilesDownloadedJob_MangaId",
|
||||||
|
table: "Jobs",
|
||||||
|
column: "UpdateFilesDownloadedJob_MangaId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Jobs_UpdateMetadataJob_MangaId",
|
||||||
|
table: "Jobs",
|
||||||
|
column: "UpdateMetadataJob_MangaId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Links_MangaId",
|
||||||
|
table: "Links",
|
||||||
|
column: "MangaId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_MangaMangaTag_MangaTagsTag",
|
||||||
|
table: "MangaMangaTag",
|
||||||
|
column: "MangaTagsTag");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Mangas_LibraryLocalLibraryId",
|
||||||
|
table: "Mangas",
|
||||||
|
column: "LibraryLocalLibraryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Mangas_MangaConnectorId",
|
||||||
|
table: "Mangas",
|
||||||
|
column: "MangaConnectorId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AltTitles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AuthorManga");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "JobJob");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "LibraryConnectors");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Links");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "MangaMangaTag");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "NotificationConnectors");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Notifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Authors");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Tags");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Chapters");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Mangas");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "LocalLibraries");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "MangaConnectors");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
821
API/Migrations/20250316150158_dev-160325-2.Designer.cs
generated
Normal file
821
API/Migrations/20250316150158_dev-160325-2.Designer.cs
generated
Normal file
@ -0,0 +1,821 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(PgsqlContext))]
|
||||||
|
[Migration("20250316150158_dev-160325-2")]
|
||||||
|
partial class dev1603252
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.3")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Author", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("AuthorId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("AuthorName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.HasKey("AuthorId");
|
||||||
|
|
||||||
|
b.ToTable("Authors");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("ChapterId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ChapterNumber")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<bool>("Downloaded")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("ParentMangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int?>("VolumeNumber")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("ChapterId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentMangaId");
|
||||||
|
|
||||||
|
b.ToTable("Chapters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("JobId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("DependsOnJobsIds")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<byte>("JobType")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastExecution")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ParentJobId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RecurrenceMs")
|
||||||
|
.HasColumnType("numeric(20,0)");
|
||||||
|
|
||||||
|
b.Property<byte>("state")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.HasKey("JobId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentJobId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs");
|
||||||
|
|
||||||
|
b.HasDiscriminator<byte>("JobType");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LibraryConnectors.LibraryConnector", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LibraryConnectorId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Auth")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("BaseUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<byte>("LibraryType")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.HasKey("LibraryConnectorId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryConnectors");
|
||||||
|
|
||||||
|
b.HasDiscriminator<byte>("LibraryType");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Link", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LinkId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("LinkProvider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("LinkUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("LinkId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Links");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LocalLibrary", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LocalLibraryId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("BasePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("LibraryName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.HasKey("LocalLibraryId");
|
||||||
|
|
||||||
|
b.ToTable("LocalLibraries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Manga", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("CoverFileNameInCache")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CoverUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("DirectoryName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("IdOnConnectorSite")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<float>("IgnoreChapterBefore")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<string>("LibraryLocalLibraryId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaConnectorId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalLanguage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<byte>("ReleaseStatus")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.Property<string>("WebsiteUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<long>("Year")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("MangaId");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryLocalLibraryId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaConnectorId");
|
||||||
|
|
||||||
|
b.ToTable("Mangas");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("AltTitleId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("AltTitleId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("AltTitles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaConnector", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("BaseUris")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("IconUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("SupportedLanguages")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("MangaConnectors");
|
||||||
|
|
||||||
|
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Tag")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("Tag");
|
||||||
|
|
||||||
|
b.ToTable("Tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("NotificationId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Date")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<byte>("Urgency")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.HasKey("NotificationId");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.NotificationConnectors.NotificationConnector", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, string>>("Headers")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("hstore");
|
||||||
|
|
||||||
|
b.Property<string>("HttpMethod")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("NotificationConnectors");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AuthorManga", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("AuthorsAuthorId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("AuthorsAuthorId", "MangaId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("AuthorManga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobJob", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("DependsOnJobsJobId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("JobId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("DependsOnJobsJobId", "JobId");
|
||||||
|
|
||||||
|
b.HasIndex("JobId");
|
||||||
|
|
||||||
|
b.ToTable("JobJob");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MangaMangaTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaTagsTag")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("MangaId", "MangaTagsTag");
|
||||||
|
|
||||||
|
b.HasIndex("MangaTagsTag");
|
||||||
|
|
||||||
|
b.ToTable("MangaMangaTag");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("DownloadAvailableChaptersJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)1);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)4);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("ChapterId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("ChapterId");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)0);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.MoveFileOrFolderJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("FromLocation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("ToLocation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)3);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("RetrieveChaptersJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)5);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("UpdateFilesDownloadedJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)6);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("UpdateMetadataJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)2);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LibraryConnectors.Kavita", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)1);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LibraryConnectors.Komga", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)0);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.AsuraToon", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("AsuraToon");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Bato", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Bato");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaDex", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("MangaDex");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaHere", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("MangaHere");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaKatana", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("MangaKatana");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Manganato", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Manganato");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Mangaworld", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Mangaworld");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.ManhuaPlus", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("ManhuaPlus");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Weebcentral", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Weebcentral");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "ParentManga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentMangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ParentManga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Jobs.Job", "ParentJob")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentJobId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.Navigation("ParentJob");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Link", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany("Links")
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Manga", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.LocalLibrary", "Library")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryLocalLibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaConnectorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Library");
|
||||||
|
|
||||||
|
b.Navigation("MangaConnector");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany("AltTitles")
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AuthorManga", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Author", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AuthorsAuthorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Jobs.Job", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DependsOnJobsJobId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.Jobs.Job", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("JobId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MangaMangaTag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.MangaTag", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaTagsTag")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Chapter", "Chapter")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChapterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Chapter");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Manga", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("AltTitles");
|
||||||
|
|
||||||
|
b.Navigation("Links");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
42
API/Migrations/20250316150158_dev-160325-2.cs
Normal file
42
API/Migrations/20250316150158_dev-160325-2.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class dev1603252 : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Mangas_LocalLibraries_LibraryLocalLibraryId",
|
||||||
|
table: "Mangas");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Mangas_LocalLibraries_LibraryLocalLibraryId",
|
||||||
|
table: "Mangas",
|
||||||
|
column: "LibraryLocalLibraryId",
|
||||||
|
principalTable: "LocalLibraries",
|
||||||
|
principalColumn: "LocalLibraryId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Mangas_LocalLibraries_LibraryLocalLibraryId",
|
||||||
|
table: "Mangas");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Mangas_LocalLibraries_LibraryLocalLibraryId",
|
||||||
|
table: "Mangas",
|
||||||
|
column: "LibraryLocalLibraryId",
|
||||||
|
principalTable: "LocalLibraries",
|
||||||
|
principalColumn: "LocalLibraryId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
818
API/Migrations/PgsqlContextModelSnapshot.cs
Normal file
818
API/Migrations/PgsqlContextModelSnapshot.cs
Normal file
@ -0,0 +1,818 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(PgsqlContext))]
|
||||||
|
partial class PgsqlContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.3")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Author", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("AuthorId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("AuthorName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.HasKey("AuthorId");
|
||||||
|
|
||||||
|
b.ToTable("Authors");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("ChapterId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ChapterNumber")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<bool>("Downloaded")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("ParentMangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int?>("VolumeNumber")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("ChapterId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentMangaId");
|
||||||
|
|
||||||
|
b.ToTable("Chapters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("JobId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("DependsOnJobsIds")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<byte>("JobType")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastExecution")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ParentJobId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RecurrenceMs")
|
||||||
|
.HasColumnType("numeric(20,0)");
|
||||||
|
|
||||||
|
b.Property<byte>("state")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.HasKey("JobId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentJobId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs");
|
||||||
|
|
||||||
|
b.HasDiscriminator<byte>("JobType");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LibraryConnectors.LibraryConnector", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LibraryConnectorId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Auth")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("BaseUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<byte>("LibraryType")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.HasKey("LibraryConnectorId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryConnectors");
|
||||||
|
|
||||||
|
b.HasDiscriminator<byte>("LibraryType");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Link", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LinkId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("LinkProvider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("LinkUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("LinkId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Links");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LocalLibrary", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LocalLibraryId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("BasePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("LibraryName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.HasKey("LocalLibraryId");
|
||||||
|
|
||||||
|
b.ToTable("LocalLibraries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Manga", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("CoverFileNameInCache")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CoverUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("DirectoryName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("IdOnConnectorSite")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<float>("IgnoreChapterBefore")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<string>("LibraryLocalLibraryId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaConnectorId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalLanguage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<byte>("ReleaseStatus")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.Property<string>("WebsiteUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<long>("Year")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("MangaId");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryLocalLibraryId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaConnectorId");
|
||||||
|
|
||||||
|
b.ToTable("Mangas");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("AltTitleId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("AltTitleId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("AltTitles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaConnector", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("BaseUris")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("IconUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("SupportedLanguages")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("MangaConnectors");
|
||||||
|
|
||||||
|
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Tag")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("Tag");
|
||||||
|
|
||||||
|
b.ToTable("Tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("NotificationId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Date")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<byte>("Urgency")
|
||||||
|
.HasColumnType("smallint");
|
||||||
|
|
||||||
|
b.HasKey("NotificationId");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.NotificationConnectors.NotificationConnector", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, string>>("Headers")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("hstore");
|
||||||
|
|
||||||
|
b.Property<string>("HttpMethod")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("NotificationConnectors");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AuthorManga", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("AuthorsAuthorId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("AuthorsAuthorId", "MangaId");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("AuthorManga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobJob", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("DependsOnJobsJobId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("JobId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("DependsOnJobsJobId", "JobId");
|
||||||
|
|
||||||
|
b.HasIndex("JobId");
|
||||||
|
|
||||||
|
b.ToTable("JobJob");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MangaMangaTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MangaTagsTag")
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("MangaId", "MangaTagsTag");
|
||||||
|
|
||||||
|
b.HasIndex("MangaTagsTag");
|
||||||
|
|
||||||
|
b.ToTable("MangaMangaTag");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("DownloadAvailableChaptersJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)1);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)4);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("ChapterId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("ChapterId");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)0);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.MoveFileOrFolderJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("FromLocation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("ToLocation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)3);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("RetrieveChaptersJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)5);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("UpdateFilesDownloadedJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)6);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.Jobs.Job");
|
||||||
|
|
||||||
|
b.Property<string>("MangaId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasIndex("MangaId");
|
||||||
|
|
||||||
|
b.ToTable("Jobs", t =>
|
||||||
|
{
|
||||||
|
t.Property("MangaId")
|
||||||
|
.HasColumnName("UpdateMetadataJob_MangaId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)2);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LibraryConnectors.Kavita", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)1);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.LibraryConnectors.Komga", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue((byte)0);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.AsuraToon", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("AsuraToon");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Bato", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Bato");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaDex", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("MangaDex");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaHere", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("MangaHere");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.MangaKatana", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("MangaKatana");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Manganato", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Manganato");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Mangaworld", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Mangaworld");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.ManhuaPlus", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("ManhuaPlus");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaConnectors.Weebcentral", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("Weebcentral");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Chapter", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "ParentManga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentMangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ParentManga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Jobs.Job", "ParentJob")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentJobId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.Navigation("ParentJob");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Link", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany("Links")
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Manga", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.LocalLibrary", "Library")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryLocalLibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaConnectorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Library");
|
||||||
|
|
||||||
|
b.Navigation("MangaConnector");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany("AltTitles")
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AuthorManga", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Author", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AuthorsAuthorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Jobs.Job", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DependsOnJobsJobId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.Jobs.Job", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("JobId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MangaMangaTag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Schema.MangaTag", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaTagsTag")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Chapter", "Chapter")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChapterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Chapter");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Schema.Manga", "Manga")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MangaId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Manga");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Schema.Manga", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("AltTitles");
|
||||||
|
|
||||||
|
b.Navigation("Links");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
46
API/NamedSwaggerGenOptions.cs
Normal file
46
API/NamedSwaggerGenOptions.cs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
using Asp.Versioning.ApiExplorer;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
|
|
||||||
|
namespace API;
|
||||||
|
|
||||||
|
public class NamedSwaggerGenOptions : IConfigureNamedOptions<SwaggerGenOptions>
|
||||||
|
{
|
||||||
|
private readonly IApiVersionDescriptionProvider provider;
|
||||||
|
public NamedSwaggerGenOptions(IApiVersionDescriptionProvider provider)
|
||||||
|
{
|
||||||
|
this.provider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure(string? name, SwaggerGenOptions options)
|
||||||
|
{
|
||||||
|
Configure(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure(SwaggerGenOptions options)
|
||||||
|
{
|
||||||
|
// add swagger document for every API version discovered
|
||||||
|
foreach (var description in provider.ApiVersionDescriptions)
|
||||||
|
{
|
||||||
|
options.SwaggerDoc(
|
||||||
|
description.GroupName,
|
||||||
|
CreateVersionInfo(description));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OpenApiInfo CreateVersionInfo(
|
||||||
|
ApiVersionDescription description)
|
||||||
|
{
|
||||||
|
var info = new OpenApiInfo()
|
||||||
|
{
|
||||||
|
Title = "Test API " + description.GroupName,
|
||||||
|
Version = description.ApiVersion.ToString()
|
||||||
|
};
|
||||||
|
if (description.IsDeprecated)
|
||||||
|
{
|
||||||
|
info.Description += " This API version has been deprecated.";
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
147
API/Program.cs
Normal file
147
API/Program.cs
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using API;
|
||||||
|
using API.Schema;
|
||||||
|
using API.Schema.Jobs;
|
||||||
|
using API.Schema.MangaConnectors;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Asp.Versioning.Builder;
|
||||||
|
using Asp.Versioning.Conventions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Converters;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("AllowAll",
|
||||||
|
policy =>
|
||||||
|
{
|
||||||
|
policy
|
||||||
|
.AllowAnyOrigin()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowAnyHeader();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddApiVersioning(option =>
|
||||||
|
{
|
||||||
|
option.AssumeDefaultVersionWhenUnspecified = true;
|
||||||
|
option.DefaultApiVersion = new ApiVersion(2);
|
||||||
|
option.ReportApiVersions = true;
|
||||||
|
option.ApiVersionReader = ApiVersionReader.Combine(
|
||||||
|
new UrlSegmentApiVersionReader(),
|
||||||
|
new QueryStringApiVersionReader("api-version"),
|
||||||
|
new HeaderApiVersionReader("X-Version"),
|
||||||
|
new MediaTypeApiVersionReader("x-version"));
|
||||||
|
})
|
||||||
|
.AddMvc(options =>
|
||||||
|
{
|
||||||
|
options.Conventions.Add(new VersionByNamespaceConvention());
|
||||||
|
})
|
||||||
|
.AddApiExplorer(options =>
|
||||||
|
{
|
||||||
|
options.GroupNameFormat = "'v'V";
|
||||||
|
options.SubstituteApiVersionInUrl = true;
|
||||||
|
});
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGenNewtonsoftSupport();
|
||||||
|
builder.Services.AddSwaggerGen(opt =>
|
||||||
|
{
|
||||||
|
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||||
|
opt.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
|
||||||
|
});
|
||||||
|
builder.Services.ConfigureOptions<NamedSwaggerGenOptions>();
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<PgsqlContext>(options =>
|
||||||
|
options.UseNpgsql($"Host={Environment.GetEnvironmentVariable("POSTGRES_HOST")??"localhost:5432"}; " +
|
||||||
|
$"Database={Environment.GetEnvironmentVariable("POSTGRES_DB")??"postgres"}; " +
|
||||||
|
$"Username={Environment.GetEnvironmentVariable("POSTGRES_USER")??"postgres"}; " +
|
||||||
|
$"Password={Environment.GetEnvironmentVariable("POSTGRES_PASSWORD")??"postgres"}"));
|
||||||
|
|
||||||
|
builder.Services.AddControllers(options =>
|
||||||
|
{
|
||||||
|
options.AllowEmptyInputInBodyModelBinding = true;
|
||||||
|
});
|
||||||
|
builder.Services.AddControllers().AddNewtonsoftJson(opts =>
|
||||||
|
{
|
||||||
|
opts.SerializerSettings.Converters.Add(new StringEnumConverter());
|
||||||
|
opts.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.WebHost.UseUrls("http://*:6531");
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
ApiVersionSet apiVersionSet = app.NewApiVersionSet()
|
||||||
|
.HasApiVersion(new ApiVersion(2))
|
||||||
|
.ReportApiVersions()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
|
||||||
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
|
app.MapControllers()
|
||||||
|
.WithApiVersionSet(apiVersionSet)
|
||||||
|
.MapToApiVersion(2);
|
||||||
|
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI(options =>
|
||||||
|
{
|
||||||
|
options.SwaggerEndpoint(
|
||||||
|
$"/swagger/v2/swagger.json", "v2");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
//app.UseMiddleware<RequestTimeMiddleware>();
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<PgsqlContext>();
|
||||||
|
db.Database.Migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
PgsqlContext context = scope.ServiceProvider.GetService<PgsqlContext>()!;
|
||||||
|
|
||||||
|
MangaConnector[] connectors =
|
||||||
|
[
|
||||||
|
new AsuraToon(),
|
||||||
|
new Bato(),
|
||||||
|
new MangaDex(),
|
||||||
|
new MangaHere(),
|
||||||
|
new MangaKatana(),
|
||||||
|
new Mangaworld(),
|
||||||
|
new ManhuaPlus(),
|
||||||
|
new Weebcentral(),
|
||||||
|
new Manganato(),
|
||||||
|
new Global(scope.ServiceProvider.GetService<PgsqlContext>()!)
|
||||||
|
];
|
||||||
|
MangaConnector[] newConnectors = connectors.Where(c => !context.MangaConnectors.Contains(c)).ToArray();
|
||||||
|
context.MangaConnectors.AddRange(newConnectors);
|
||||||
|
|
||||||
|
context.Jobs.AddRange(context.Mangas.AsEnumerable().Select(m => new UpdateFilesDownloadedJob(0, m.MangaId)));
|
||||||
|
|
||||||
|
context.Jobs.RemoveRange(context.Jobs.Where(j => j.state == JobState.Completed && j.RecurrenceMs < 1));
|
||||||
|
|
||||||
|
if (!context.LocalLibraries.Any())
|
||||||
|
context.LocalLibraries.Add(new LocalLibrary(TrangaSettings.downloadLocation, "Default Library"));
|
||||||
|
|
||||||
|
string[] emojis = { "(•‿•)", "(づ \u25d5‿\u25d5 )づ", "( \u02d8\u25bd\u02d8)っ\u2668", "=\uff3e\u25cf \u22cf \u25cf\uff3e=", "(ΦωΦ)", "(\u272a\u3268\u272a)", "( ノ・o・ )ノ", "(〜^\u2207^ )〜", "~(\u2267ω\u2266)~","૮ \u00b4• ﻌ \u00b4• ა", "(\u02c3ᆺ\u02c2)", "(=\ud83d\udf66 \u0f1d \ud83d\udf66=)"};
|
||||||
|
context.Notifications.Add(new Notification("Tranga Started", emojis[Random.Shared.Next(0, emojis.Length - 1)], NotificationUrgency.High));
|
||||||
|
|
||||||
|
context.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TrangaSettings.Load();
|
||||||
|
Tranga.StartLogger();
|
||||||
|
Tranga.JobStarterThread.Start(app.Services);
|
||||||
|
Tranga.NotificationSenderThread.Start(app.Services.CreateScope().ServiceProvider.GetService<PgsqlContext>());
|
||||||
|
|
||||||
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
|
app.Run();
|
47
API/Properties/launchSettings.json
Normal file
47
API/Properties/launchSettings.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||||
|
"iisSettings": {
|
||||||
|
"windowsAuthentication": false,
|
||||||
|
"anonymousAuthentication": true,
|
||||||
|
"iisExpress": {
|
||||||
|
"applicationUrl": "http://localhost:5976",
|
||||||
|
"sslPort": 44332,
|
||||||
|
"environmentVariables": {
|
||||||
|
"POSTGRES_Host": "localhost:5432"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "swagger",
|
||||||
|
"applicationUrl": "http://localhost:5287",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"POSTGRES_Host": "localhost:5432"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "swagger",
|
||||||
|
"applicationUrl": "https://localhost:7206;http://localhost:5287",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"POSTGRES_Host": "localhost:5432"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"IIS Express": {
|
||||||
|
"commandName": "IISExpress",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "swagger",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"POSTGRES_Host": "localhost:5432"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
API/Schema/Author.cs
Normal file
15
API/Schema/Author.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Schema;
|
||||||
|
|
||||||
|
[PrimaryKey("AuthorId")]
|
||||||
|
public class Author(string authorName)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string AuthorId { get; init; } = TokenGen.CreateToken(typeof(Author), authorName);
|
||||||
|
[StringLength(128)]
|
||||||
|
[Required]
|
||||||
|
public string AuthorName { get; init; } = authorName;
|
||||||
|
}
|
149
API/Schema/Chapter.cs
Normal file
149
API/Schema/Chapter.cs
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using API.Schema.Jobs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace API.Schema;
|
||||||
|
|
||||||
|
[PrimaryKey("ChapterId")]
|
||||||
|
public class Chapter : IComparable<Chapter>
|
||||||
|
{
|
||||||
|
public Chapter(Manga parentManga, string url, string chapterNumber, int? volumeNumber = null, string? title = null)
|
||||||
|
: this(parentManga.MangaId, url, chapterNumber, volumeNumber, title)
|
||||||
|
{
|
||||||
|
ParentManga = parentManga;
|
||||||
|
FileName = GetArchiveFilePath(parentManga.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Chapter(string parentMangaId, string url, string chapterNumber,
|
||||||
|
int? volumeNumber = null, string? title = null)
|
||||||
|
{
|
||||||
|
ChapterId = TokenGen.CreateToken(typeof(Chapter), parentMangaId, (volumeNumber ?? 0).ToString(), chapterNumber);
|
||||||
|
ParentMangaId = parentMangaId;
|
||||||
|
Url = url;
|
||||||
|
ChapterNumber = chapterNumber;
|
||||||
|
VolumeNumber = volumeNumber;
|
||||||
|
Title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string ChapterId { get; init; }
|
||||||
|
public int? VolumeNumber { get; private set; }
|
||||||
|
[StringLength(10)]
|
||||||
|
[Required]
|
||||||
|
public string ChapterNumber { get; private set; }
|
||||||
|
|
||||||
|
[StringLength(2048)]
|
||||||
|
[Required]
|
||||||
|
[Url]
|
||||||
|
public string Url { get; internal set; }
|
||||||
|
[StringLength(256)]
|
||||||
|
public string? Title { get; private set; }
|
||||||
|
[StringLength(256)]
|
||||||
|
[Required]
|
||||||
|
public string FileName { get; private set; }
|
||||||
|
[JsonIgnore]
|
||||||
|
[NotMapped]
|
||||||
|
public string? FullArchiveFilePath => ParentManga is { } m ? Path.Join(m.FullDirectoryPath, FileName) : null;
|
||||||
|
[Required]
|
||||||
|
public bool Downloaded { get; internal set; } = false;
|
||||||
|
[Required]
|
||||||
|
[StringLength(64)]
|
||||||
|
public string ParentMangaId { get; internal set; }
|
||||||
|
[JsonIgnore]
|
||||||
|
public Manga? ParentManga { get; init; }
|
||||||
|
|
||||||
|
public int CompareTo(Chapter? other)
|
||||||
|
{
|
||||||
|
if (other is not { } otherChapter)
|
||||||
|
throw new ArgumentException($"{other} can not be compared to {this}");
|
||||||
|
return VolumeNumber?.CompareTo(otherChapter.VolumeNumber) switch
|
||||||
|
{
|
||||||
|
< 0 => -1,
|
||||||
|
> 0 => 1,
|
||||||
|
_ => CompareChapterNumbers(ChapterNumber, otherChapter.ChapterNumber)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public MoveFileOrFolderJob? UpdateChapterNumber(string chapterNumber)
|
||||||
|
{
|
||||||
|
ChapterNumber = chapterNumber;
|
||||||
|
return UpdateArchiveFileName();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MoveFileOrFolderJob? UpdateVolumeNumber(int? volumeNumber)
|
||||||
|
{
|
||||||
|
VolumeNumber = volumeNumber;
|
||||||
|
return UpdateArchiveFileName();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MoveFileOrFolderJob? UpdateTitle(string? title)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
return UpdateArchiveFileName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MoveFileOrFolderJob? UpdateArchiveFileName()
|
||||||
|
{
|
||||||
|
string? oldPath = FullArchiveFilePath;
|
||||||
|
if (oldPath is null)
|
||||||
|
return null;
|
||||||
|
string newPath = GetArchiveFilePath();
|
||||||
|
FileName = newPath;
|
||||||
|
return Downloaded ? new MoveFileOrFolderJob(oldPath, newPath) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the filesystem if an archive at the ArchiveFilePath exists
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>True if archive exists on disk</returns>
|
||||||
|
public bool IsDownloaded()
|
||||||
|
{
|
||||||
|
string path = GetArchiveFilePath();
|
||||||
|
return File.Exists(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetArchiveFilePath(string? parentMangaName = null)
|
||||||
|
{
|
||||||
|
return $"{parentMangaName ?? ParentManga?.Name ?? ""} - Vol.{VolumeNumber ?? 0} Ch.{ChapterNumber}{(Title is null ? "" : $" - {Title}")}.cbz";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CompareChapterNumbers(string ch1, string ch2)
|
||||||
|
{
|
||||||
|
int[] ch1Arr = ch1.Split('.').Select(c => int.TryParse(c, out int result) ? result : -1).ToArray();
|
||||||
|
int[] ch2Arr = ch2.Split('.').Select(c => int.TryParse(c, out int result) ? result : -1).ToArray();
|
||||||
|
|
||||||
|
if (ch1Arr.Contains(-1) || ch2Arr.Contains(-1))
|
||||||
|
throw new ArgumentException("Chapter number is not in correct format");
|
||||||
|
|
||||||
|
int i = 0, j = 0;
|
||||||
|
|
||||||
|
while (i < ch1Arr.Length && j < ch2Arr.Length)
|
||||||
|
{
|
||||||
|
if (ch1Arr[i] < ch2Arr[j])
|
||||||
|
return -1;
|
||||||
|
if (ch1Arr[i] > ch2Arr[j])
|
||||||
|
return 1;
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string GetComicInfoXmlString()
|
||||||
|
{
|
||||||
|
XElement comicInfo = new("ComicInfo",
|
||||||
|
new XElement("Tags", string.Join(',', ParentManga.MangaTags.Select(tag => tag.Tag))),
|
||||||
|
new XElement("LanguageISO", ParentManga.OriginalLanguage),
|
||||||
|
new XElement("Title", Title),
|
||||||
|
new XElement("Writer", string.Join(',', ParentManga.Authors.Select(author => author.AuthorName))),
|
||||||
|
new XElement("Volume", VolumeNumber),
|
||||||
|
new XElement("Number", ChapterNumber)
|
||||||
|
);
|
||||||
|
return comicInfo.ToString();
|
||||||
|
}
|
||||||
|
}
|
21
API/Schema/Jobs/DownloadAvailableChaptersJob.cs
Normal file
21
API/Schema/Jobs/DownloadAvailableChaptersJob.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace API.Schema.Jobs;
|
||||||
|
|
||||||
|
public class DownloadAvailableChaptersJob(ulong recurrenceMs, string mangaId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
|
||||||
|
: Job(TokenGen.CreateToken(typeof(DownloadAvailableChaptersJob)), JobType.DownloadAvailableChaptersJob, recurrenceMs, parentJobId, dependsOnJobsIds)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string MangaId { get; init; } = mangaId;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public Manga? Manga { get; init; }
|
||||||
|
|
||||||
|
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
|
||||||
|
{
|
||||||
|
return context.Chapters.Where(c => c.ParentMangaId == MangaId).AsEnumerable()
|
||||||
|
.Select(chapter => new DownloadSingleChapterJob(chapter.ChapterId, this.JobId));
|
||||||
|
}
|
||||||
|
}
|
25
API/Schema/Jobs/DownloadMangaCoverJob.cs
Normal file
25
API/Schema/Jobs/DownloadMangaCoverJob.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace API.Schema.Jobs;
|
||||||
|
|
||||||
|
public class DownloadMangaCoverJob(string mangaId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
|
||||||
|
: Job(TokenGen.CreateToken(typeof(DownloadMangaCoverJob)), JobType.DownloadMangaCoverJob, 0, parentJobId, dependsOnJobsIds)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string MangaId { get; init; } = mangaId;
|
||||||
|
[JsonIgnore]
|
||||||
|
public Manga? Manga { get; init; }
|
||||||
|
|
||||||
|
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
|
||||||
|
{
|
||||||
|
Manga? manga = Manga ?? context.Mangas.Find(this.MangaId);
|
||||||
|
if (manga is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
manga.CoverFileNameInCache = manga.SaveCoverImageToCache();
|
||||||
|
context.SaveChanges();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
131
API/Schema/Jobs/DownloadSingleChapterJob.cs
Normal file
131
API/Schema/Jobs/DownloadSingleChapterJob.cs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using API.Schema.MangaConnectors;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
using SixLabors.ImageSharp.Processing.Processors.Binarization;
|
||||||
|
using static System.IO.UnixFileMode;
|
||||||
|
|
||||||
|
namespace API.Schema.Jobs;
|
||||||
|
|
||||||
|
public class DownloadSingleChapterJob(string chapterId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
|
||||||
|
: Job(TokenGen.CreateToken(typeof(DownloadSingleChapterJob)), JobType.DownloadSingleChapterJob, 0, parentJobId, dependsOnJobsIds)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string ChapterId { get; init; } = chapterId;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public Chapter? Chapter { get; init; }
|
||||||
|
|
||||||
|
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
|
||||||
|
{
|
||||||
|
Chapter chapter = Chapter ?? context.Chapters.Find(ChapterId)!;
|
||||||
|
Manga manga = chapter.ParentManga ?? context.Mangas.Find(chapter.ParentMangaId)!;
|
||||||
|
MangaConnector connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId)!;
|
||||||
|
string[] imageUrls = connector.GetChapterImageUrls(chapter);
|
||||||
|
string saveArchiveFilePath = chapter.FullArchiveFilePath;
|
||||||
|
|
||||||
|
//Check if Publication Directory already exists
|
||||||
|
string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!;
|
||||||
|
if (!Directory.Exists(directoryPath))
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
Directory.CreateDirectory(directoryPath,
|
||||||
|
UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute );
|
||||||
|
else
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
|
||||||
|
if (File.Exists(saveArchiveFilePath)) //Don't download twice. Redownload
|
||||||
|
File.Delete(saveArchiveFilePath);
|
||||||
|
|
||||||
|
//Create a temporary folder to store images
|
||||||
|
string tempFolder = Directory.CreateTempSubdirectory("trangatemp").FullName;
|
||||||
|
|
||||||
|
int chapterNum = 0;
|
||||||
|
//Download all Images to temporary Folder
|
||||||
|
if (imageUrls.Length == 0)
|
||||||
|
{
|
||||||
|
Directory.Delete(tempFolder, true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string imageUrl in imageUrls)
|
||||||
|
{
|
||||||
|
string extension = imageUrl.Split('.')[^1].Split('?')[0];
|
||||||
|
string imagePath = Path.Join(tempFolder, $"{chapterNum++}.{extension}");
|
||||||
|
bool status = DownloadImage(imageUrl, imagePath);
|
||||||
|
if (status is false)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
CopyCoverFromCacheToDownloadLocation(manga);
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString());
|
||||||
|
|
||||||
|
//ZIP-it and ship-it
|
||||||
|
ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath);
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
File.SetUnixFileMode(saveArchiveFilePath, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherExecute);
|
||||||
|
Directory.Delete(tempFolder, true); //Cleanup
|
||||||
|
|
||||||
|
chapter.Downloaded = true;
|
||||||
|
context.SaveChanges();
|
||||||
|
|
||||||
|
return [new UpdateFilesDownloadedJob(0, manga.MangaId, this.JobId)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessImage(string imagePath)
|
||||||
|
{
|
||||||
|
if (!TrangaSettings.bwImages && TrangaSettings.compression == 100)
|
||||||
|
return;
|
||||||
|
using Image image = Image.Load(imagePath);
|
||||||
|
File.Delete(imagePath);
|
||||||
|
if(TrangaSettings.bwImages)
|
||||||
|
image.Mutate(i => i.ApplyProcessor(new AdaptiveThresholdProcessor()));
|
||||||
|
image.SaveAsJpeg(imagePath, new JpegEncoder()
|
||||||
|
{
|
||||||
|
Quality = TrangaSettings.compression
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopyCoverFromCacheToDownloadLocation(Manga manga)
|
||||||
|
{
|
||||||
|
//Check if Publication already has a Folder and cover
|
||||||
|
string publicationFolder = manga.CreatePublicationFolder();
|
||||||
|
DirectoryInfo dirInfo = new (publicationFolder);
|
||||||
|
if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover", StringComparison.InvariantCultureIgnoreCase)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? fileInCache = manga.CoverFileNameInCache ?? manga.SaveCoverImageToCache();
|
||||||
|
if (fileInCache is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
|
||||||
|
File.Copy(fileInCache, newFilePath, true);
|
||||||
|
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | UserRead | UserWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DownloadImage(string imageUrl, string savePath)
|
||||||
|
{
|
||||||
|
HttpDownloadClient downloadClient = new();
|
||||||
|
RequestResult requestResult = downloadClient.MakeRequest(imageUrl, RequestType.MangaImage);
|
||||||
|
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return false;
|
||||||
|
if (requestResult.result == Stream.Null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
FileStream fs = new (savePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
requestResult.result.CopyTo(fs);
|
||||||
|
fs.Close();
|
||||||
|
ProcessImage(savePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
69
API/Schema/Jobs/Job.cs
Normal file
69
API/Schema/Jobs/Job.cs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace API.Schema.Jobs;
|
||||||
|
|
||||||
|
[PrimaryKey("JobId")]
|
||||||
|
public abstract class Job
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string JobId { get; init; }
|
||||||
|
[StringLength(64)]
|
||||||
|
public string? ParentJobId { get; init; }
|
||||||
|
[JsonIgnore]
|
||||||
|
public Job? ParentJob { get; init; }
|
||||||
|
[StringLength(64)]
|
||||||
|
public ICollection<string>? DependsOnJobsIds { get; init; }
|
||||||
|
[JsonIgnore]
|
||||||
|
public ICollection<Job>? DependsOnJobs { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public JobType JobType { get; init; }
|
||||||
|
[Required]
|
||||||
|
public ulong RecurrenceMs { get; set; }
|
||||||
|
[Required]
|
||||||
|
public DateTime LastExecution { get; internal set; } = DateTime.UnixEpoch;
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
[Required]
|
||||||
|
public DateTime NextExecution => LastExecution.AddMilliseconds(RecurrenceMs);
|
||||||
|
[Required]
|
||||||
|
public JobState state { get; internal set; } = JobState.Waiting;
|
||||||
|
[Required]
|
||||||
|
public bool Enabled { get; internal set; } = true;
|
||||||
|
|
||||||
|
public Job(string jobId, JobType jobType, ulong recurrenceMs, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
|
||||||
|
: this(jobId, jobType, recurrenceMs, parentJob?.JobId, dependsOnJobs?.Select(j => j.JobId).ToList())
|
||||||
|
{
|
||||||
|
this.ParentJob = parentJob;
|
||||||
|
this.DependsOnJobs = dependsOnJobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Job(string jobId, JobType jobType, ulong recurrenceMs, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
|
||||||
|
{
|
||||||
|
JobId = jobId;
|
||||||
|
ParentJobId = parentJobId;
|
||||||
|
DependsOnJobsIds = dependsOnJobsIds;
|
||||||
|
JobType = jobType;
|
||||||
|
RecurrenceMs = recurrenceMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<Job> Run(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
using IServiceScope scope = serviceProvider.CreateScope();
|
||||||
|
PgsqlContext context = scope.ServiceProvider.GetRequiredService<PgsqlContext>();
|
||||||
|
|
||||||
|
this.state = JobState.Running;
|
||||||
|
context.SaveChanges();
|
||||||
|
Job[] newJobs = RunInternal(context).ToArray();
|
||||||
|
this.state = JobState.Completed;
|
||||||
|
context.Jobs.AddRange(newJobs);
|
||||||
|
context.SaveChanges();
|
||||||
|
return newJobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract IEnumerable<Job> RunInternal(PgsqlContext context);
|
||||||
|
}
|
13
API/Schema/Jobs/JobState.cs
Normal file
13
API/Schema/Jobs/JobState.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
namespace API.Schema.Jobs;
|
||||||
|
|
||||||
|
public enum JobState : byte
|
||||||
|
{
|
||||||
|
//Values 0-63 Preparation Stages
|
||||||
|
Waiting = 0,
|
||||||
|
//64-127 Running Stages
|
||||||
|
Running = 64,
|
||||||
|
//128-191 Completion Stages
|
||||||
|
Completed = 128,
|
||||||
|
//192-255 Error stages
|
||||||
|
Failed = 192
|
||||||
|
}
|
14
API/Schema/Jobs/JobType.cs
Normal file
14
API/Schema/Jobs/JobType.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
namespace API.Schema.Jobs;
|
||||||
|
|
||||||
|
|
||||||
|
public enum JobType : byte
|
||||||
|
{
|
||||||
|
DownloadSingleChapterJob = 0,
|
||||||
|
DownloadAvailableChaptersJob = 1,
|
||||||
|
UpdateMetaDataJob = 2,
|
||||||
|
MoveFileOrFolderJob = 3,
|
||||||
|
DownloadMangaCoverJob = 4,
|
||||||
|
RetrieveChaptersJob = 5,
|
||||||
|
UpdateFilesDownloadedJob = 6,
|
||||||
|
MoveMangaLibraryJob = 7
|
||||||
|
}
|
46
API/Schema/Jobs/MoveFileOrFolderJob.cs
Normal file
46
API/Schema/Jobs/MoveFileOrFolderJob.cs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace API.Schema.Jobs;
|
||||||
|
|
||||||
|
public class MoveFileOrFolderJob(string fromLocation, string toLocation, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
|
||||||
|
: Job(TokenGen.CreateToken(typeof(MoveFileOrFolderJob)), JobType.MoveFileOrFolderJob, 0, parentJobId, dependsOnJobsIds)
|
||||||
|
{
|
||||||
|
[StringLength(256)]
|
||||||
|
[Required]
|
||||||
|
public string FromLocation { get; init; } = fromLocation;
|
||||||
|
[StringLength(256)]
|
||||||
|
[Required]
|
||||||
|
public string ToLocation { get; init; } = toLocation;
|
||||||
|
|
||||||
|
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FileInfo fi = new FileInfo(FromLocation);
|
||||||
|
if (!fi.Exists)
|
||||||
|
return [];
|
||||||
|
if (File.Exists(ToLocation))//Do not override existing
|
||||||
|
return [];
|
||||||
|
if(fi.Attributes.HasFlag(FileAttributes.Directory))
|
||||||
|
MoveDirectory(fi, ToLocation);
|
||||||
|
else
|
||||||
|
MoveFile(fi, ToLocation);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MoveDirectory(FileInfo from, string toLocation)
|
||||||
|
{
|
||||||
|
Directory.Move(from.FullName, toLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MoveFile(FileInfo from, string toLocation)
|
||||||
|
{
|
||||||
|
File.Move(from.FullName, toLocation);
|
||||||
|
}
|
||||||
|
}
|
30
API/Schema/Jobs/MoveMangaLibraryJob.cs
Normal file
30
API/Schema/Jobs/MoveMangaLibraryJob.cs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace API.Schema.Jobs;
|
||||||
|
|
||||||
|
public class MoveMangaLibraryJob(string mangaId, string toLibraryId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
|
||||||
|
: Job(TokenGen.CreateToken(typeof(MoveMangaLibraryJob)), JobType.MoveMangaLibraryJob, 0, parentJobId, dependsOnJobsIds)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string MangaId { get; init; } = mangaId;
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string ToLibraryId { get; init; } = toLibraryId;
|
||||||
|
|
||||||
|
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
|
||||||
|
{
|
||||||
|
Manga? manga = context.Mangas.Find(MangaId);
|
||||||
|
if(manga is null)
|
||||||
|
throw new KeyNotFoundException();
|
||||||
|
LocalLibrary? library = context.LocalLibraries.Find(ToLibraryId);
|
||||||
|
if(library is null)
|
||||||
|
throw new KeyNotFoundException();
|
||||||
|
Chapter[] chapters = context.Chapters.Where(c => c.ParentMangaId == MangaId).ToArray();
|
||||||
|
Dictionary<Chapter, string> oldPath = chapters.ToDictionary(c => c, c => c.FullArchiveFilePath!);
|
||||||
|
manga.Library = library;
|
||||||
|
context.SaveChanges();
|
||||||
|
|
||||||
|
return chapters.Select(c => new MoveFileOrFolderJob(oldPath[c], c.FullArchiveFilePath!));
|
||||||
|
}
|
||||||
|
}
|
37
API/Schema/Jobs/RetrieveChaptersJob.cs
Normal file
37
API/Schema/Jobs/RetrieveChaptersJob.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using API.Schema.MangaConnectors;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace API.Schema.Jobs;
|
||||||
|
|
||||||
|
public class RetrieveChaptersJob(ulong recurrenceMs, string mangaId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
|
||||||
|
: Job(TokenGen.CreateToken(typeof(RetrieveChaptersJob)), JobType.RetrieveChaptersJob, recurrenceMs, parentJobId, dependsOnJobsIds)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string MangaId { get; init; } = mangaId;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public Manga? Manga { get; init; }
|
||||||
|
|
||||||
|
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
* For some reason, directly using Manga from above instead of finding it again causes DBContext to consider
|
||||||
|
* Manga as a new entity and Postgres throws a Duplicate PK exception.
|
||||||
|
* m.MangaConnector does not have this issue (IDK why).
|
||||||
|
*/
|
||||||
|
Manga m = context.Mangas.Find(MangaId)!;
|
||||||
|
MangaConnector connector = context.MangaConnectors.Find(m.MangaConnectorId)!;
|
||||||
|
// This gets all chapters that are not downloaded
|
||||||
|
Chapter[] allNewChapters = connector.GetNewChapters(m).DistinctBy(c => c.ChapterId).ToArray();
|
||||||
|
|
||||||
|
// This filters out chapters that are not downloaded but already exist in the DB
|
||||||
|
string[] chapterIds = context.Chapters.Where(chapter => chapter.ParentMangaId == m.MangaId).Select(chapter => chapter.ChapterId).ToArray();
|
||||||
|
Chapter[] newChapters = allNewChapters.Where(chapter => !chapterIds.Contains(chapter.ChapterId)).ToArray();
|
||||||
|
context.Chapters.AddRange(newChapters);
|
||||||
|
context.SaveChanges();
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
25
API/Schema/Jobs/UpdateFilesDownloadedJob.cs
Normal file
25
API/Schema/Jobs/UpdateFilesDownloadedJob.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace API.Schema.Jobs;
|
||||||
|
|
||||||
|
public class UpdateFilesDownloadedJob(ulong recurrenceMs, string mangaId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
|
||||||
|
: Job(TokenGen.CreateToken(typeof(UpdateFilesDownloadedJob)), JobType.UpdateFilesDownloadedJob, recurrenceMs, parentJobId, dependsOnJobsIds)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string MangaId { get; init; } = mangaId;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public virtual Manga? Manga { get; init; }
|
||||||
|
|
||||||
|
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
|
||||||
|
{
|
||||||
|
IQueryable<Chapter> chapters = context.Chapters.Where(c => c.ParentMangaId == MangaId);
|
||||||
|
foreach (Chapter chapter in chapters)
|
||||||
|
chapter.Downloaded = chapter.IsDownloaded();
|
||||||
|
|
||||||
|
context.SaveChanges();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
26
API/Schema/Jobs/UpdateMetadataJob.cs
Normal file
26
API/Schema/Jobs/UpdateMetadataJob.cs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace API.Schema.Jobs;
|
||||||
|
|
||||||
|
public class UpdateMetadataJob(ulong recurrenceMs, string mangaId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
|
||||||
|
: Job(TokenGen.CreateToken(typeof(UpdateMetadataJob)), JobType.UpdateMetaDataJob, recurrenceMs, parentJobId, dependsOnJobsIds)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string MangaId { get; init; } = mangaId;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public virtual Manga? Manga { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates all data related to Manga.
|
||||||
|
/// Retrieves data from Mangaconnector
|
||||||
|
/// Updates Chapter-info
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context"></param>
|
||||||
|
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
|
||||||
|
{
|
||||||
|
return [];//TODO
|
||||||
|
}
|
||||||
|
}
|
@ -1,22 +1,21 @@
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json;
|
||||||
using Logging;
|
using System.Text.Json.Nodes;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
|
||||||
|
|
||||||
namespace Tranga.LibraryManagers;
|
namespace API.Schema.LibraryConnectors;
|
||||||
|
|
||||||
public class Kavita : LibraryManager
|
public class Kavita : LibraryConnector
|
||||||
{
|
{
|
||||||
|
|
||||||
public Kavita(string baseUrl, string username, string password, Logger? logger) : base(baseUrl, GetToken(baseUrl, username, password), logger, LibraryType.Kavita)
|
public Kavita(string baseUrl, string auth) : base(TokenGen.CreateToken(typeof(Kavita), baseUrl), LibraryType.Kavita, baseUrl, auth)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
[JsonConstructor]
|
public Kavita(string baseUrl, string username, string password) :
|
||||||
public Kavita(string baseUrl, string auth, Logger? logger) : base(baseUrl, auth, logger, LibraryType.Kavita)
|
this(baseUrl, GetToken(baseUrl, username, password))
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static string GetToken(string baseUrl, string username, string password)
|
private static string GetToken(string baseUrl, string username, string password)
|
||||||
{
|
{
|
||||||
HttpClient client = new()
|
HttpClient client = new()
|
||||||
@ -32,19 +31,37 @@ public class Kavita : LibraryManager
|
|||||||
RequestUri = new Uri($"{baseUrl}/api/Account/login"),
|
RequestUri = new Uri($"{baseUrl}/api/Account/login"),
|
||||||
Content = new StringContent($"{{\"username\":\"{username}\",\"password\":\"{password}\"}}", System.Text.Encoding.UTF8, "application/json")
|
Content = new StringContent($"{{\"username\":\"{username}\",\"password\":\"{password}\"}}", System.Text.Encoding.UTF8, "application/json")
|
||||||
};
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
HttpResponseMessage response = client.Send(requestMessage);
|
HttpResponseMessage response = client.Send(requestMessage);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(response.Content.ReadAsStream());
|
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(response.Content.ReadAsStream());
|
||||||
if (result is not null)
|
if (result is not null)
|
||||||
return result!["token"]!.GetValue<string>();
|
return result["token"]!.GetValue<string>();
|
||||||
else return "";
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (HttpRequestException e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void UpdateLibrary()
|
protected override void UpdateLibraryInternal()
|
||||||
{
|
{
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Updating Libraries");
|
|
||||||
foreach (KavitaLibrary lib in GetLibraries())
|
foreach (KavitaLibrary lib in GetLibraries())
|
||||||
NetClient.MakePost($"{baseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", auth, logger);
|
NetClient.MakePost($"{BaseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", Auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override bool Test()
|
||||||
|
{
|
||||||
|
foreach (KavitaLibrary lib in GetLibraries())
|
||||||
|
if (NetClient.MakePost($"{BaseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", Auth))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -53,27 +70,26 @@ public class Kavita : LibraryManager
|
|||||||
/// <returns>Array of KavitaLibrary</returns>
|
/// <returns>Array of KavitaLibrary</returns>
|
||||||
private IEnumerable<KavitaLibrary> GetLibraries()
|
private IEnumerable<KavitaLibrary> GetLibraries()
|
||||||
{
|
{
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Getting Libraries");
|
Stream data = NetClient.MakeRequest($"{BaseUrl}/api/Library/libraries", "Bearer", Auth);
|
||||||
Stream data = NetClient.MakeRequest($"{baseUrl}/api/Library", "Bearer", auth, logger);
|
|
||||||
if (data == Stream.Null)
|
if (data == Stream.Null)
|
||||||
{
|
{
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"No libraries returned");
|
|
||||||
return Array.Empty<KavitaLibrary>();
|
return Array.Empty<KavitaLibrary>();
|
||||||
}
|
}
|
||||||
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
|
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
|
||||||
if (result is null)
|
if (result is null)
|
||||||
{
|
{
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"No libraries returned");
|
|
||||||
return Array.Empty<KavitaLibrary>();
|
return Array.Empty<KavitaLibrary>();
|
||||||
}
|
}
|
||||||
|
|
||||||
HashSet<KavitaLibrary> ret = new();
|
List<KavitaLibrary> ret = new();
|
||||||
|
|
||||||
foreach (JsonNode? jsonNode in result)
|
foreach (JsonNode? jsonNode in result)
|
||||||
{
|
{
|
||||||
var jObject = (JsonObject?)jsonNode;
|
JsonObject? jObject = (JsonObject?)jsonNode;
|
||||||
|
if(jObject is null)
|
||||||
|
continue;
|
||||||
int libraryId = jObject!["id"]!.GetValue<int>();
|
int libraryId = jObject!["id"]!.GetValue<int>();
|
||||||
string libraryName = jObject!["name"]!.GetValue<string>();
|
string libraryName = jObject["name"]!.GetValue<string>();
|
||||||
ret.Add(new KavitaLibrary(libraryId, libraryName));
|
ret.Add(new KavitaLibrary(libraryId, libraryName));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +99,7 @@ public class Kavita : LibraryManager
|
|||||||
private struct KavitaLibrary
|
private struct KavitaLibrary
|
||||||
{
|
{
|
||||||
public int id { get; }
|
public int id { get; }
|
||||||
|
// ReSharper disable once UnusedAutoPropertyAccessor.Local
|
||||||
public string name { get; }
|
public string name { get; }
|
||||||
|
|
||||||
public KavitaLibrary(int id, string name)
|
public KavitaLibrary(int id, string name)
|
74
API/Schema/LibraryConnectors/Komga.cs
Normal file
74
API/Schema/LibraryConnectors/Komga.cs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace API.Schema.LibraryConnectors;
|
||||||
|
|
||||||
|
public class Komga : LibraryConnector
|
||||||
|
{
|
||||||
|
public Komga(string baseUrl, string auth) : base(TokenGen.CreateToken(typeof(Komga), baseUrl), LibraryType.Komga,
|
||||||
|
baseUrl, auth)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Komga(string baseUrl, string username, string password)
|
||||||
|
: this(baseUrl, Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{username}:{password}")))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void UpdateLibraryInternal()
|
||||||
|
{
|
||||||
|
foreach (KomgaLibrary lib in GetLibraries())
|
||||||
|
NetClient.MakePost($"{BaseUrl}/api/v1/libraries/{lib.id}/scan", "Basic", Auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override bool Test()
|
||||||
|
{
|
||||||
|
foreach (KomgaLibrary lib in GetLibraries())
|
||||||
|
if (NetClient.MakePost($"{BaseUrl}/api/v1/libraries/{lib.id}/scan", "Basic", Auth))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches all libraries available to the user
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Array of KomgaLibraries</returns>
|
||||||
|
private IEnumerable<KomgaLibrary> GetLibraries()
|
||||||
|
{
|
||||||
|
Stream data = NetClient.MakeRequest($"{BaseUrl}/api/v1/libraries", "Basic", Auth);
|
||||||
|
if (data == Stream.Null)
|
||||||
|
{
|
||||||
|
return Array.Empty<KomgaLibrary>();
|
||||||
|
}
|
||||||
|
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return Array.Empty<KomgaLibrary>();
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<KomgaLibrary> ret = new();
|
||||||
|
|
||||||
|
foreach (JsonNode? jsonNode in result)
|
||||||
|
{
|
||||||
|
var jObject = (JsonObject?)jsonNode;
|
||||||
|
string libraryId = jObject!["id"]!.GetValue<string>();
|
||||||
|
string libraryName = jObject["name"]!.GetValue<string>();
|
||||||
|
ret.Add(new KomgaLibrary(libraryId, libraryName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct KomgaLibrary
|
||||||
|
{
|
||||||
|
public string id { get; }
|
||||||
|
// ReSharper disable once UnusedAutoPropertyAccessor.Local
|
||||||
|
public string name { get; }
|
||||||
|
|
||||||
|
public KomgaLibrary(string id, string name)
|
||||||
|
{
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
25
API/Schema/LibraryConnectors/LibraryConnector.cs
Normal file
25
API/Schema/LibraryConnectors/LibraryConnector.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Schema.LibraryConnectors;
|
||||||
|
|
||||||
|
[PrimaryKey("LibraryConnectorId")]
|
||||||
|
public abstract class LibraryConnector(string libraryConnectorId, LibraryType libraryType, string baseUrl, string auth)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string LibraryConnectorId { get; } = libraryConnectorId;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public LibraryType LibraryType { get; init; } = libraryType;
|
||||||
|
[StringLength(256)]
|
||||||
|
[Required]
|
||||||
|
[Url]
|
||||||
|
public string BaseUrl { get; init; } = baseUrl;
|
||||||
|
[StringLength(256)]
|
||||||
|
[Required]
|
||||||
|
public string Auth { get; init; } = auth;
|
||||||
|
|
||||||
|
protected abstract void UpdateLibraryInternal();
|
||||||
|
internal abstract bool Test();
|
||||||
|
}
|
7
API/Schema/LibraryConnectors/LibraryType.cs
Normal file
7
API/Schema/LibraryConnectors/LibraryType.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace API.Schema.LibraryConnectors;
|
||||||
|
|
||||||
|
public enum LibraryType : byte
|
||||||
|
{
|
||||||
|
Komga = 0,
|
||||||
|
Kavita = 1
|
||||||
|
}
|
69
API/Schema/LibraryConnectors/NetClient.cs
Normal file
69
API/Schema/LibraryConnectors/NetClient.cs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace API.Schema.LibraryConnectors;
|
||||||
|
|
||||||
|
public class NetClient
|
||||||
|
{
|
||||||
|
public static Stream MakeRequest(string url, string authScheme, string auth)
|
||||||
|
{
|
||||||
|
HttpClient client = new();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(authScheme, auth);
|
||||||
|
|
||||||
|
HttpRequestMessage requestMessage = new ()
|
||||||
|
{
|
||||||
|
Method = HttpMethod.Get,
|
||||||
|
RequestUri = new Uri(url)
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
|
||||||
|
HttpResponseMessage response = client.Send(requestMessage);
|
||||||
|
|
||||||
|
if (response.StatusCode is HttpStatusCode.Unauthorized &&
|
||||||
|
response.RequestMessage!.RequestUri!.AbsoluteUri != url)
|
||||||
|
return MakeRequest(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth);
|
||||||
|
else if (response.IsSuccessStatusCode)
|
||||||
|
return response.Content.ReadAsStream();
|
||||||
|
else
|
||||||
|
return Stream.Null;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
switch (e)
|
||||||
|
{
|
||||||
|
case HttpRequestException:
|
||||||
|
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
return Stream.Null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool MakePost(string url, string authScheme, string auth)
|
||||||
|
{
|
||||||
|
HttpClient client = new()
|
||||||
|
{
|
||||||
|
DefaultRequestHeaders =
|
||||||
|
{
|
||||||
|
{ "Accept", "application/json" },
|
||||||
|
{ "Authorization", new AuthenticationHeaderValue(authScheme, auth).ToString() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
HttpRequestMessage requestMessage = new ()
|
||||||
|
{
|
||||||
|
Method = HttpMethod.Post,
|
||||||
|
RequestUri = new Uri(url)
|
||||||
|
};
|
||||||
|
HttpResponseMessage response = client.Send(requestMessage);
|
||||||
|
|
||||||
|
if(response.StatusCode is HttpStatusCode.Unauthorized && response.RequestMessage!.RequestUri!.AbsoluteUri != url)
|
||||||
|
return MakePost(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth);
|
||||||
|
else if (response.IsSuccessStatusCode)
|
||||||
|
return true;
|
||||||
|
else
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
19
API/Schema/Link.cs
Normal file
19
API/Schema/Link.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Schema;
|
||||||
|
|
||||||
|
[PrimaryKey("LinkId")]
|
||||||
|
public class Link(string linkProvider, string linkUrl)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string LinkId { get; init; } = TokenGen.CreateToken(typeof(Link), linkProvider, linkUrl);
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string LinkProvider { get; init; } = linkProvider;
|
||||||
|
[StringLength(2048)]
|
||||||
|
[Required]
|
||||||
|
[Url]
|
||||||
|
public string LinkUrl { get; init; } = linkUrl;
|
||||||
|
}
|
17
API/Schema/LocalLibrary.cs
Normal file
17
API/Schema/LocalLibrary.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace API.Schema;
|
||||||
|
|
||||||
|
public class LocalLibrary(string basePath, string libraryName)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string LocalLibraryId { get; init; } = TokenGen.CreateToken(typeof(LocalLibrary), basePath);
|
||||||
|
[StringLength(256)]
|
||||||
|
[Required]
|
||||||
|
public string BasePath { get; internal set; } = basePath;
|
||||||
|
|
||||||
|
[StringLength(512)]
|
||||||
|
[Required]
|
||||||
|
public string LibraryName { get; internal set; } = libraryName;
|
||||||
|
}
|
186
API/Schema/Manga.cs
Normal file
186
API/Schema/Manga.cs
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Net;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using API.Schema.Jobs;
|
||||||
|
using API.Schema.MangaConnectors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using static System.IO.UnixFileMode;
|
||||||
|
|
||||||
|
namespace API.Schema;
|
||||||
|
|
||||||
|
[PrimaryKey("MangaId")]
|
||||||
|
public class Manga
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string MangaId { get; init; }
|
||||||
|
[StringLength(128)]
|
||||||
|
[Required]
|
||||||
|
public string IdOnConnectorSite { get; init; }
|
||||||
|
[StringLength(256)]
|
||||||
|
[Required]
|
||||||
|
public string Name { get; internal set; }
|
||||||
|
[Required]
|
||||||
|
public string Description { get; internal set; }
|
||||||
|
[Url]
|
||||||
|
[StringLength(256)]
|
||||||
|
[Required]
|
||||||
|
public string WebsiteUrl { get; internal set; }
|
||||||
|
[JsonIgnore]
|
||||||
|
[Url]
|
||||||
|
public string CoverUrl { get; internal set; }
|
||||||
|
[JsonIgnore]
|
||||||
|
public string? CoverFileNameInCache { get; internal set; }
|
||||||
|
[Required]
|
||||||
|
public uint Year { get; internal set; }
|
||||||
|
[StringLength(8)]
|
||||||
|
[Required]
|
||||||
|
public string? OriginalLanguage { get; internal set; }
|
||||||
|
[Required]
|
||||||
|
public MangaReleaseStatus ReleaseStatus { get; internal set; }
|
||||||
|
[StringLength(256)]
|
||||||
|
[Required]
|
||||||
|
public string DirectoryName { get; private set; }
|
||||||
|
public LocalLibrary? Library { get; internal set; }
|
||||||
|
[JsonIgnore]
|
||||||
|
[NotMapped]
|
||||||
|
public string LibraryPath => Library is null ? TrangaSettings.downloadLocation : Library.BasePath;
|
||||||
|
[JsonIgnore]
|
||||||
|
[NotMapped]
|
||||||
|
public string FullDirectoryPath => Path.Join(LibraryPath, DirectoryName);
|
||||||
|
[Required]
|
||||||
|
public float IgnoreChapterBefore { get; internal set; }
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string MangaConnectorId { get; private set; }
|
||||||
|
[JsonIgnore] public MangaConnector? MangaConnector { get; private set; }
|
||||||
|
|
||||||
|
[JsonIgnore] public ICollection<Author>? Authors { get; internal set; }
|
||||||
|
[NotMapped]
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public IEnumerable<string> AuthorIds => Authors?.Select(a => a.AuthorId) ?? [];
|
||||||
|
|
||||||
|
[JsonIgnore] public ICollection<MangaTag>? MangaTags { get; internal set; }
|
||||||
|
[NotMapped]
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public IEnumerable<string> Tags => MangaTags?.Select(t => t.Tag) ?? [];
|
||||||
|
|
||||||
|
|
||||||
|
[JsonIgnore] public ICollection<Link>? Links { get; internal set; }
|
||||||
|
[NotMapped]
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public IEnumerable<string> LinkIds => Links?.Select(l => l.LinkId) ?? [];
|
||||||
|
|
||||||
|
[JsonIgnore] public ICollection<MangaAltTitle>? AltTitles { get; internal set; }
|
||||||
|
[NotMapped]
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public IEnumerable<string> AltTitleIds => AltTitles?.Select(a => a.AltTitleId) ?? [];
|
||||||
|
|
||||||
|
public Manga(string idOnConnectorSite, string name, string description, string websiteUrl, string coverUrl,
|
||||||
|
string? coverFileNameInCache, uint year, string? originalLanguage, MangaReleaseStatus releaseStatus,
|
||||||
|
float ignoreChapterBefore, MangaConnector mangaConnector, ICollection<Author> authors,
|
||||||
|
ICollection<MangaTag> mangaTags, ICollection<Link> links, ICollection<MangaAltTitle> altTitles,
|
||||||
|
LocalLibrary? library = null)
|
||||||
|
: this(idOnConnectorSite, name, description, websiteUrl, coverUrl, coverFileNameInCache, year, originalLanguage,
|
||||||
|
releaseStatus, ignoreChapterBefore, mangaConnector.Name)
|
||||||
|
{
|
||||||
|
this.Authors = authors;
|
||||||
|
this.MangaTags = mangaTags;
|
||||||
|
this.Links = links;
|
||||||
|
this.AltTitles = altTitles;
|
||||||
|
this.Library = library;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Manga(string idOnConnectorSite, string name, string description, string websiteUrl, string coverUrl,
|
||||||
|
string? coverFileNameInCache, uint year, string? originalLanguage, MangaReleaseStatus releaseStatus,
|
||||||
|
float ignoreChapterBefore, string mangaConnectorId)
|
||||||
|
{
|
||||||
|
MangaId = TokenGen.CreateToken(typeof(Manga), mangaConnectorId, idOnConnectorSite);
|
||||||
|
IdOnConnectorSite = idOnConnectorSite;
|
||||||
|
Name = name;
|
||||||
|
Description = description;
|
||||||
|
WebsiteUrl = websiteUrl;
|
||||||
|
CoverUrl = coverUrl;
|
||||||
|
CoverFileNameInCache = coverFileNameInCache;
|
||||||
|
Year = year;
|
||||||
|
OriginalLanguage = originalLanguage;
|
||||||
|
ReleaseStatus = releaseStatus;
|
||||||
|
IgnoreChapterBefore = ignoreChapterBefore;
|
||||||
|
MangaConnectorId = mangaConnectorId;
|
||||||
|
DirectoryName = BuildFolderName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MoveFileOrFolderJob UpdateFolderName(string downloadLocation, string newName)
|
||||||
|
{
|
||||||
|
string oldName = this.DirectoryName;
|
||||||
|
this.DirectoryName = newName;
|
||||||
|
return new MoveFileOrFolderJob(Path.Join(downloadLocation, oldName), Path.Join(downloadLocation, this.DirectoryName));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void UpdateWithInfo(Manga other)
|
||||||
|
{
|
||||||
|
this.Name = other.Name;
|
||||||
|
this.Year = other.Year;
|
||||||
|
this.Description = other.Description;
|
||||||
|
this.CoverUrl = other.CoverUrl;
|
||||||
|
this.OriginalLanguage = other.OriginalLanguage;
|
||||||
|
this.Authors = other.Authors;
|
||||||
|
this.Links = other.Links;
|
||||||
|
this.MangaTags = other.MangaTags;
|
||||||
|
this.AltTitles = other.AltTitles;
|
||||||
|
this.ReleaseStatus = other.ReleaseStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildFolderName(string mangaName)
|
||||||
|
{
|
||||||
|
return mangaName;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string? SaveCoverImageToCache(int retries = 3)
|
||||||
|
{
|
||||||
|
if(retries < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Regex urlRex = new (@"https?:\/\/((?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]+)\/(?:.+\/)*(.+\.([a-zA-Z]+))");
|
||||||
|
//https?:\/\/[a-zA-Z0-9-]+\.([a-zA-Z0-9-]+\.[a-zA-Z0-9]+)\/(?:.+\/)*(.+\.([a-zA-Z]+)) for only second level domains
|
||||||
|
Match match = urlRex.Match(CoverUrl);
|
||||||
|
string filename = $"{match.Groups[1].Value}-{MangaId}.{match.Groups[3].Value}";
|
||||||
|
string saveImagePath = Path.Join(TrangaSettings.coverImageCache, filename);
|
||||||
|
|
||||||
|
if (File.Exists(saveImagePath))
|
||||||
|
return saveImagePath;
|
||||||
|
|
||||||
|
RequestResult coverResult = new HttpDownloadClient().MakeRequest(CoverUrl, RequestType.MangaCover, $"https://{match.Groups[1].Value}");
|
||||||
|
if (coverResult.statusCode is < HttpStatusCode.OK or >= HttpStatusCode.Ambiguous)
|
||||||
|
return SaveCoverImageToCache(--retries);
|
||||||
|
|
||||||
|
using MemoryStream ms = new();
|
||||||
|
coverResult.result.CopyTo(ms);
|
||||||
|
Directory.CreateDirectory(TrangaSettings.coverImageCache);
|
||||||
|
File.WriteAllBytes(saveImagePath, ms.ToArray());
|
||||||
|
|
||||||
|
return saveImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreatePublicationFolder()
|
||||||
|
{
|
||||||
|
string publicationFolder = Path.Join(LibraryPath, this.DirectoryName);
|
||||||
|
if(!Directory.Exists(publicationFolder))
|
||||||
|
Directory.CreateDirectory(publicationFolder);
|
||||||
|
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
File.SetUnixFileMode(publicationFolder, GroupRead | GroupWrite | GroupExecute | OtherRead | OtherWrite | OtherExecute | UserRead | UserWrite | UserExecute);
|
||||||
|
return publicationFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO onchanges create job to update metadata files in archives, etc.
|
||||||
|
}
|
19
API/Schema/MangaAltTitle.cs
Normal file
19
API/Schema/MangaAltTitle.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Schema;
|
||||||
|
|
||||||
|
[PrimaryKey("AltTitleId")]
|
||||||
|
public class MangaAltTitle(string language, string title)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string AltTitleId { get; init; } = TokenGen.CreateToken("AltTitle", language, title);
|
||||||
|
[StringLength(8)]
|
||||||
|
[Required]
|
||||||
|
public string Language { get; init; } = language;
|
||||||
|
[StringLength(256)]
|
||||||
|
[Required]
|
||||||
|
public string Title { get; set; } = title;
|
||||||
|
}
|
190
API/Schema/MangaConnectors/AsuraToon.cs
Normal file
190
API/Schema/MangaConnectors/AsuraToon.cs
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
public class AsuraToon : MangaConnector
|
||||||
|
{
|
||||||
|
|
||||||
|
public AsuraToon() : base("AsuraToon", ["en"], ["asuracomic.net"], "https://asuracomic.net/images/logo.webp")
|
||||||
|
{
|
||||||
|
this.downloadClient = new ChromiumDownloadClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
|
||||||
|
string requestUrl = $"https://asuracomic.net/series?name={sanitizedTitle}";
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
|
||||||
|
{
|
||||||
|
return GetMangaFromUrl($"https://asuracomic.net/series/{publicationId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
|
||||||
|
{
|
||||||
|
RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return null;
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
HtmlNodeCollection mangaList = document.DocumentNode.SelectNodes("//a[starts-with(@href,'series')]");
|
||||||
|
if (mangaList is null || mangaList.Count < 1)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
IEnumerable<string> urls = mangaList.Select(a => $"https://asuracomic.net/{a.GetAttributeValue("href", "")}");
|
||||||
|
|
||||||
|
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
|
||||||
|
if (manga is { } x)
|
||||||
|
ret.Add(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
|
||||||
|
{
|
||||||
|
string? originalLanguage = null;
|
||||||
|
Dictionary<string, string> altTitles = new(), links = new();
|
||||||
|
|
||||||
|
HtmlNodeCollection genreNodes = document.DocumentNode.SelectNodes("//h3[text()='Genres']/../div/button");
|
||||||
|
string[] tags = genreNodes.Select(b => b.InnerText).ToArray();
|
||||||
|
List<MangaTag> mangaTags = tags.Select(t => new MangaTag(t)).ToList();
|
||||||
|
|
||||||
|
HtmlNode statusNode = document.DocumentNode.SelectSingleNode("//h3[text()='Status']/../h3[2]");
|
||||||
|
MangaReleaseStatus releaseStatus = statusNode.InnerText.ToLower() switch
|
||||||
|
{
|
||||||
|
"ongoing" => MangaReleaseStatus.Continuing,
|
||||||
|
"hiatus" => MangaReleaseStatus.OnHiatus,
|
||||||
|
"completed" => MangaReleaseStatus.Completed,
|
||||||
|
"dropped" => MangaReleaseStatus.Cancelled,
|
||||||
|
"season end" => MangaReleaseStatus.Continuing,
|
||||||
|
"coming soon" => MangaReleaseStatus.Unreleased,
|
||||||
|
_ => MangaReleaseStatus.Unreleased
|
||||||
|
};
|
||||||
|
|
||||||
|
HtmlNode coverNode =
|
||||||
|
document.DocumentNode.SelectSingleNode("//img[@alt='poster']");
|
||||||
|
string coverUrl = coverNode.GetAttributeValue("src", "");
|
||||||
|
|
||||||
|
HtmlNode titleNode =
|
||||||
|
document.DocumentNode.SelectSingleNode("//title");
|
||||||
|
string sortName = Regex.Match(titleNode.InnerText, @"(.*) - Asura Scans").Groups[1].Value;
|
||||||
|
|
||||||
|
HtmlNode descriptionNode =
|
||||||
|
document.DocumentNode.SelectSingleNode("//h3[starts-with(text(),'Synopsis')]/../span");
|
||||||
|
string description = descriptionNode?.InnerText??"";
|
||||||
|
|
||||||
|
HtmlNodeCollection authorNodes = document.DocumentNode.SelectNodes("//h3[text()='Author']/../h3[not(text()='Author' or text()='_')]");
|
||||||
|
HtmlNodeCollection artistNodes = document.DocumentNode.SelectNodes("//h3[text()='Artist']/../h3[not(text()='Artist' or text()='_')]");
|
||||||
|
IEnumerable<string> authorNames = authorNodes is null ? [] : authorNodes.Select(a => a.InnerText);
|
||||||
|
IEnumerable<string> artistNames = artistNodes is null ? [] : artistNodes.Select(a => a.InnerText);
|
||||||
|
List<string> authorStrings = authorNames.Concat(artistNames).ToList();
|
||||||
|
List<Author> authors = authorStrings.Select(author => new Author(author)).ToList();
|
||||||
|
|
||||||
|
HtmlNode? firstChapterNode = document.DocumentNode.SelectSingleNode("//a[contains(@href, 'chapter/1')]/../following-sibling::h3");
|
||||||
|
uint year = uint.Parse(firstChapterNode?.InnerText.Split(' ')[^1] ?? "2000");
|
||||||
|
|
||||||
|
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
|
||||||
|
originalLanguage, releaseStatus, -1,
|
||||||
|
this,
|
||||||
|
authors,
|
||||||
|
mangaTags,
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
return (manga, authors, mangaTags, [], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Manga manga, string language="en")
|
||||||
|
{
|
||||||
|
string requestUrl = $"https://asuracomic.net/series/{manga.MangaId}";
|
||||||
|
// Leaving this in for verification if the page exists
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
//Return Chapters ordered by Chapter-Number
|
||||||
|
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl);
|
||||||
|
return chapters.Order().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Chapter> ParseChaptersFromHtml(Manga manga, string mangaUrl)
|
||||||
|
{
|
||||||
|
RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default);
|
||||||
|
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return new List<Chapter>();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Chapter> ret = new();
|
||||||
|
|
||||||
|
HtmlNodeCollection chapterURLNodes = result.htmlDocument.DocumentNode.SelectNodes("//a[contains(@href, '/chapter/')]");
|
||||||
|
Regex infoRex = new(@"Chapter ([0-9]+)(.*)?");
|
||||||
|
|
||||||
|
foreach (HtmlNode chapterInfo in chapterURLNodes)
|
||||||
|
{
|
||||||
|
string chapterUrl = chapterInfo.GetAttributeValue("href", "");
|
||||||
|
|
||||||
|
Match match = infoRex.Match(chapterInfo.InnerText);
|
||||||
|
string chapterNumber = new(match.Groups[1].Value);
|
||||||
|
string? chapterName = match.Groups[2].Success && match.Groups[2].Length > 1 ? match.Groups[2].Value : null;
|
||||||
|
string url = $"https://asuracomic.net/series/{chapterUrl}";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ret.Add(new Chapter(manga, url, chapterNumber, null, chapterName));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override string[] GetChapterImageUrls(Chapter chapter)
|
||||||
|
{
|
||||||
|
string requestUrl = chapter.Url;
|
||||||
|
// Leaving this in to check if the page exists
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
|
||||||
|
return imageUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
HtmlNodeCollection images = document.DocumentNode.SelectNodes("//img[contains(@alt, 'chapter page')]");
|
||||||
|
|
||||||
|
return images.Select(i => i.GetAttributeValue("src", "")).ToArray();
|
||||||
|
}
|
||||||
|
}
|
203
API/Schema/MangaConnectors/Bato.cs
Normal file
203
API/Schema/MangaConnectors/Bato.cs
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
public class Bato : MangaConnector
|
||||||
|
{
|
||||||
|
|
||||||
|
public Bato() : base("Bato", ["en"], ["bato.to"], "https://bato.to/amsta/img/batoto/favicon.ico")
|
||||||
|
{
|
||||||
|
this.downloadClient = new HttpDownloadClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
|
||||||
|
string requestUrl = $"https://bato.to/v3x-search?word={sanitizedTitle}&lang=en";
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
|
||||||
|
{
|
||||||
|
return GetMangaFromUrl($"https://bato.to/title/{publicationId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
|
||||||
|
{
|
||||||
|
RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return null;
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
HtmlNode mangaList = document.DocumentNode.SelectSingleNode("//div[@data-hk='0-0-2']");
|
||||||
|
if (!mangaList.ChildNodes.Any(node => node.Name == "div"))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
List<string> urls = mangaList.ChildNodes
|
||||||
|
.Select(node => $"https://bato.to{node.Descendants("div").First().FirstChild.GetAttributeValue("href", "")}").ToList();
|
||||||
|
|
||||||
|
HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
|
||||||
|
if (manga is { } x)
|
||||||
|
ret.Add(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
|
||||||
|
{
|
||||||
|
HtmlNode infoNode = document.DocumentNode.SelectSingleNode("/html/body/div/main/div[1]/div[2]");
|
||||||
|
|
||||||
|
string sortName = infoNode.Descendants("h3").First().InnerText;
|
||||||
|
string description = document.DocumentNode
|
||||||
|
.SelectSingleNode("//div[contains(concat(' ',normalize-space(@class),' '),'prose')]").InnerText;
|
||||||
|
|
||||||
|
string[] altTitlesList = infoNode.ChildNodes[1].ChildNodes[2].InnerText.Split('/');
|
||||||
|
int i = 0;
|
||||||
|
List<MangaAltTitle> altTitles = altTitlesList.Select(a => new MangaAltTitle(i++.ToString(), a)).ToList();
|
||||||
|
|
||||||
|
string coverUrl = document.DocumentNode.SelectNodes("//img")
|
||||||
|
.First(child => child.GetAttributeValue("data-hk", "") == "0-1-0").GetAttributeValue("src", "").Replace("&", "&");
|
||||||
|
|
||||||
|
List<HtmlNode> genreNodes = document.DocumentNode.SelectSingleNode("//b[text()='Genres:']/..").SelectNodes("span").ToList();
|
||||||
|
string[] tags = genreNodes.Select(node => node.FirstChild.InnerText).ToArray();
|
||||||
|
List<MangaTag> mangaTags = tags.Select(s => new MangaTag(s)).ToList();
|
||||||
|
|
||||||
|
List<HtmlNode> authorsNodes = infoNode.ChildNodes[1].ChildNodes[3].Descendants("a").ToList();
|
||||||
|
List<string> authorNames = authorsNodes.Select(node => node.InnerText.Replace("amp;", "")).ToList();
|
||||||
|
List<Author> authors = authorNames.Select(n => new Author(n)).ToList();
|
||||||
|
|
||||||
|
HtmlNode? originalLanguageNode = document.DocumentNode.SelectSingleNode("//span[text()='Tr From']/..");
|
||||||
|
string originalLanguage = originalLanguageNode is not null ? originalLanguageNode.LastChild.InnerText : "";
|
||||||
|
|
||||||
|
if (!uint.TryParse(
|
||||||
|
document.DocumentNode.SelectSingleNode("//span[text()='Original Publication:']/..").LastChild.InnerText.Split('-')[0],
|
||||||
|
out uint year))
|
||||||
|
year = (uint)DateTime.UtcNow.Year;
|
||||||
|
|
||||||
|
string status = document.DocumentNode.SelectSingleNode("//span[text()='Original Publication:']/..")
|
||||||
|
.ChildNodes[2].InnerText;
|
||||||
|
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
|
||||||
|
switch (status.ToLower())
|
||||||
|
{
|
||||||
|
case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
|
||||||
|
case "completed": releaseStatus = MangaReleaseStatus.Completed; break;
|
||||||
|
case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break;
|
||||||
|
case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break;
|
||||||
|
case "pending": releaseStatus = MangaReleaseStatus.Unreleased; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
|
||||||
|
originalLanguage, releaseStatus, -1,
|
||||||
|
this,
|
||||||
|
authors,
|
||||||
|
mangaTags,
|
||||||
|
[],
|
||||||
|
altTitles);
|
||||||
|
|
||||||
|
return (manga, authors, mangaTags, [], altTitles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Manga manga, string language="en")
|
||||||
|
{
|
||||||
|
string requestUrl = $"https://bato.to/title/{manga.MangaId}";
|
||||||
|
// Leaving this in for verification if the page exists
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
//Return Chapters ordered by Chapter-Number
|
||||||
|
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl);
|
||||||
|
return chapters.Order().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Chapter> ParseChaptersFromHtml(Manga manga, string mangaUrl)
|
||||||
|
{
|
||||||
|
RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default);
|
||||||
|
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return new List<Chapter>();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Chapter> ret = new();
|
||||||
|
|
||||||
|
HtmlNode chapterList =
|
||||||
|
result.htmlDocument.DocumentNode.SelectSingleNode("/html/body/div/main/div[3]/astro-island/div/div[2]/div/div/astro-slot");
|
||||||
|
|
||||||
|
Regex numberRex = new(@"\/title\/.+\/([0-9])+(?:-vol_([0-9]+))?-ch_([0-9\.]+)");
|
||||||
|
|
||||||
|
foreach (HtmlNode chapterInfo in chapterList.SelectNodes("div"))
|
||||||
|
{
|
||||||
|
HtmlNode infoNode = chapterInfo.FirstChild.FirstChild;
|
||||||
|
string chapterUrl = infoNode.GetAttributeValue("href", "");
|
||||||
|
|
||||||
|
Match match = numberRex.Match(chapterUrl);
|
||||||
|
string id = match.Groups[1].Value;
|
||||||
|
int? volumeNumber = match.Groups[2].Success ? int.Parse(match.Groups[2].Value) : null;
|
||||||
|
string chapterNumber = new(match.Groups[3].Value);
|
||||||
|
string url = $"https://bato.to{chapterUrl}?load=2";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ret.Add(new Chapter(manga, url, chapterNumber, volumeNumber, null));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override string[] GetChapterImageUrls(Chapter chapter)
|
||||||
|
{
|
||||||
|
string requestUrl = chapter.Url;
|
||||||
|
// Leaving this in to check if the page exists
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
|
||||||
|
return imageUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
HtmlNode images = document.DocumentNode.SelectNodes("//astro-island").First(node =>
|
||||||
|
node.GetAttributeValue("component-url", "").Contains("/_astro/ImageList."));
|
||||||
|
|
||||||
|
string weirdString = images.OuterHtml;
|
||||||
|
string weirdString2 = Regex.Match(weirdString, @"props=\""(.*)}\""").Groups[1].Value;
|
||||||
|
string[] urls = Regex.Matches(weirdString2, @"(https:\/\/[A-z\-0-9\.\?\&\;\=\/]+)\\")
|
||||||
|
.Select(match => match.Groups[1].Value.Replace("&", "&")).ToArray();
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
}
|
55
API/Schema/MangaConnectors/Global.cs
Normal file
55
API/Schema/MangaConnectors/Global.cs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
public class Global : MangaConnector
|
||||||
|
{
|
||||||
|
private PgsqlContext context { get; init; }
|
||||||
|
public Global(PgsqlContext context) : base("Global", ["all"], [""], "")
|
||||||
|
{
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
//Get all enabled Connectors
|
||||||
|
MangaConnector[] enabledConnectors = context.MangaConnectors.Where(c => c.Enabled && c.Name != "Global").ToArray();
|
||||||
|
|
||||||
|
//Create Task for each MangaConnector to search simulatneously
|
||||||
|
Task<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[]>[] tasks =
|
||||||
|
enabledConnectors.Select(c =>
|
||||||
|
new Task<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[]>(() => c.GetManga(publicationTitle))).ToArray();
|
||||||
|
foreach (var task in tasks)
|
||||||
|
task.Start();
|
||||||
|
|
||||||
|
//Wait for all tasks to finish
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Thread.Sleep(50);
|
||||||
|
}while(tasks.Any(t => t.Status < TaskStatus.RanToCompletion));
|
||||||
|
|
||||||
|
//Concatenate all results into one
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ret =
|
||||||
|
tasks.Select(t => t.IsCompletedSuccessfully ? t.Result : []).ToArray().SelectMany(i => i).ToArray();
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
|
||||||
|
{
|
||||||
|
MangaConnector? mc = context.MangaConnectors.ToArray().FirstOrDefault(c => c.ValidateUrl(url));
|
||||||
|
return mc?.GetMangaFromUrl(url) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Manga manga, string language = "en")
|
||||||
|
{
|
||||||
|
return manga.MangaConnector?.GetChapters(manga) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override string[] GetChapterImageUrls(Chapter chapter)
|
||||||
|
{
|
||||||
|
return chapter.ParentManga?.MangaConnector?.GetChapterImageUrls(chapter) ?? [];
|
||||||
|
}
|
||||||
|
}
|
52
API/Schema/MangaConnectors/MangaConnector.cs
Normal file
52
API/Schema/MangaConnectors/MangaConnector.cs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
[PrimaryKey("Name")]
|
||||||
|
public abstract class MangaConnector(string name, string[] supportedLanguages, string[] baseUris, string iconUrl)
|
||||||
|
{
|
||||||
|
[StringLength(32)]
|
||||||
|
[Required]
|
||||||
|
public string Name { get; init; } = name;
|
||||||
|
[StringLength(8)]
|
||||||
|
[Required]
|
||||||
|
public string[] SupportedLanguages { get; init; } = supportedLanguages;
|
||||||
|
[StringLength(2048)]
|
||||||
|
[Required]
|
||||||
|
public string IconUrl { get; init; } = iconUrl;
|
||||||
|
[StringLength(256)]
|
||||||
|
[Required]
|
||||||
|
public string[] BaseUris { get; init; } = baseUris;
|
||||||
|
[Required]
|
||||||
|
public bool Enabled { get; internal set; } = true;
|
||||||
|
|
||||||
|
public abstract (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "");
|
||||||
|
|
||||||
|
public abstract (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url);
|
||||||
|
|
||||||
|
public abstract (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId);
|
||||||
|
|
||||||
|
public abstract Chapter[] GetChapters(Manga manga, string language="en");
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
[NotMapped]
|
||||||
|
internal DownloadClient downloadClient { get; init; } = null!;
|
||||||
|
|
||||||
|
public Chapter[] GetNewChapters(Manga manga)
|
||||||
|
{
|
||||||
|
Chapter[] allChapters = GetChapters(manga);
|
||||||
|
if (allChapters.Length < 1)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return allChapters.Where(chapter => !chapter.IsDownloaded()).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal abstract string[] GetChapterImageUrls(Chapter chapter);
|
||||||
|
|
||||||
|
public bool ValidateUrl(string url) => BaseUris.Any(baseUri => Regex.IsMatch(url, "https?://" + baseUri + "/.*"));
|
||||||
|
}
|
282
API/Schema/MangaConnectors/MangaDex.cs
Normal file
282
API/Schema/MangaConnectors/MangaDex.cs
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||||
|
|
||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
public class MangaDex : MangaConnector
|
||||||
|
{
|
||||||
|
//https://api.mangadex.org/docs/3-enumerations/#language-codes--localization
|
||||||
|
//https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
|
||||||
|
//https://gist.github.com/Josantonius/b455e315bc7f790d14b136d61d9ae469
|
||||||
|
public MangaDex() : base("MangaDex", ["en","pt","pt-br","it","de","ru","aa","ab","ae","af","ak","am","an","ar-ae","ar-bh","ar-dz","ar-eg","ar-iq","ar-jo","ar-kw","ar-lb","ar-ly","ar-ma","ar-om","ar-qa","ar-sa","ar-sy","ar-tn","ar-ye","ar","as","av","ay","az","ba","be","bg","bh","bi","bm","bn","bo","br","bs","ca","ce","ch","co","cr","cs","cu","cv","cy","da","de-at","de-ch","de-de","de-li","de-lu","div","dv","dz","ee","el","en-au","en-bz","en-ca","en-cb","en-gb","en-ie","en-jm","en-nz","en-ph","en-tt","en-us","en-za","en-zw","eo","es-ar","es-bo","es-cl","es-co","es-cr","es-do","es-ec","es-es","es-gt","es-hn","es-la","es-mx","es-ni","es-pa","es-pe","es-pr","es-py","es-sv","es-us","es-uy","es-ve","es","et","eu","fa","ff","fi","fj","fo","fr-be","fr-ca","fr-ch","fr-fr","fr-lu","fr-mc","fr","fy","ga","gd","gl","gn","gu","gv","ha","he","hi","ho","hr-ba","hr-hr","hr","ht","hu","hy","hz","ia","id","ie","ig","ii","ik","in","io","is","it-ch","it-it","iu","iw","ja","ja-ro","ji","jv","jw","ka","kg","ki","kj","kk","kl","km","kn","ko","ko-ro","kr","ks","ku","kv","kw","ky","kz","la","lb","lg","li","ln","lo","ls","lt","lu","lv","mg","mh","mi","mk","ml","mn","mo","mr","ms-bn","ms-my","ms","mt","my","na","nb","nd","ne","ng","nl-be","nl-nl","nl","nn","no","nr","ns","nv","ny","oc","oj","om","or","os","pa","pi","pl","ps","pt-pt","qu-bo","qu-ec","qu-pe","qu","rm","rn","ro","rw","sa","sb","sc","sd","se-fi","se-no","se-se","se","sg","sh","si","sk","sl","sm","sn","so","sq","sr-ba","sr-sp","sr","ss","st","su","sv-fi","sv-se","sv","sw","sx","syr","ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty","ug","uk","ur","us","uz","ve","vi","vo","wa","wo","xh","yi","yo","za","zh-cn","zh-hk","zh-mo","zh-ro","zh-sg","zh-tw","zh","zu"], ["mangadex.org"], "https://mangadex.org/favicon.ico")
|
||||||
|
{
|
||||||
|
this.downloadClient = new HttpDownloadClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
const int limit = 100; //How many values we want returned at once
|
||||||
|
int offset = 0; //"Page"
|
||||||
|
int total = int.MaxValue; //How many total results are there, is updated on first request
|
||||||
|
HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> retManga = new();
|
||||||
|
int loadedPublicationData = 0;
|
||||||
|
List<JsonNode> results = new();
|
||||||
|
|
||||||
|
//Request all search-results
|
||||||
|
while (offset < total) //As long as we haven't requested all "Pages"
|
||||||
|
{
|
||||||
|
//Request next Page
|
||||||
|
RequestResult requestResult = downloadClient.MakeRequest(
|
||||||
|
$"https://api.mangadex.org/manga?limit={limit}&title={publicationTitle}&offset={offset}" +
|
||||||
|
$"&contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica" +
|
||||||
|
$"&contentRating%5B%5D=pornographic" +
|
||||||
|
$"&includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author" +
|
||||||
|
$"&includes%5B%5D=artist&includes%5B%5D=tag", RequestType.MangaInfo);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
break;
|
||||||
|
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
if (result is null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if(result.ContainsKey("total"))
|
||||||
|
total = result["total"]!.GetValue<int>(); //Update the total number of Publications
|
||||||
|
else continue;
|
||||||
|
|
||||||
|
if (result.ContainsKey("data"))
|
||||||
|
results.AddRange(result["data"]!.AsArray()!);//Manga-data-Array
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (JsonNode mangaNode in results)
|
||||||
|
{
|
||||||
|
if(MangaFromJsonObject(mangaNode.AsObject()) is { } manga)
|
||||||
|
retManga.Add(manga); //Add Publication (Manga) to result
|
||||||
|
}
|
||||||
|
return retManga.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
|
||||||
|
{
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest($"https://api.mangadex.org/manga/{publicationId}?includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author&includes%5B%5D=artist&includes%5B%5D=tag", RequestType.MangaInfo);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return null;
|
||||||
|
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
||||||
|
if(result is not null)
|
||||||
|
return MangaFromJsonObject(result["data"]!.AsObject());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
|
||||||
|
{
|
||||||
|
Regex idRex = new (@"https:\/\/mangadex.org\/title\/([A-z0-9-]*)\/.*");
|
||||||
|
string id = idRex.Match(url).Groups[1].Value;
|
||||||
|
return GetMangaFromId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? MangaFromJsonObject(JsonObject manga)
|
||||||
|
{
|
||||||
|
if (!manga.TryGetPropertyValue("id", out JsonNode? idNode))
|
||||||
|
return null;
|
||||||
|
string publicationId = idNode!.GetValue<string>();
|
||||||
|
|
||||||
|
if (!manga.TryGetPropertyValue("attributes", out JsonNode? attributesNode))
|
||||||
|
return null;
|
||||||
|
JsonObject attributes = attributesNode!.AsObject();
|
||||||
|
|
||||||
|
if (!attributes.TryGetPropertyValue("title", out JsonNode? titleNode))
|
||||||
|
return null;
|
||||||
|
string sortName = titleNode!.AsObject().ContainsKey("en") switch
|
||||||
|
{
|
||||||
|
true => titleNode.AsObject()["en"]!.GetValue<string>(),
|
||||||
|
false => titleNode.AsObject().First().Value!.GetValue<string>()
|
||||||
|
};
|
||||||
|
|
||||||
|
Dictionary<string, string> altTitlesDict = new();
|
||||||
|
if (attributes.TryGetPropertyValue("altTitles", out JsonNode? altTitlesNode))
|
||||||
|
{
|
||||||
|
foreach (JsonNode? altTitleNode in altTitlesNode!.AsArray())
|
||||||
|
{
|
||||||
|
JsonObject altTitleNodeObject = altTitleNode!.AsObject();
|
||||||
|
altTitlesDict.TryAdd(altTitleNodeObject.First().Key, altTitleNodeObject.First().Value!.GetValue<string>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<MangaAltTitle> altTitles = altTitlesDict.Select(t => new MangaAltTitle(t.Key, t.Value)).ToList();
|
||||||
|
|
||||||
|
if (!attributes.TryGetPropertyValue("description", out JsonNode? descriptionNode))
|
||||||
|
return null;
|
||||||
|
string description = descriptionNode!.AsObject().ContainsKey("en") switch
|
||||||
|
{
|
||||||
|
true => descriptionNode.AsObject()["en"]!.GetValue<string>(),
|
||||||
|
false => descriptionNode.AsObject().FirstOrDefault().Value?.GetValue<string>() ?? ""
|
||||||
|
};
|
||||||
|
|
||||||
|
Dictionary<string, string> linksDict = new();
|
||||||
|
if (attributes.TryGetPropertyValue("links", out JsonNode? linksNode) && linksNode is not null)
|
||||||
|
foreach (KeyValuePair<string, JsonNode?> linkKv in linksNode!.AsObject())
|
||||||
|
linksDict.TryAdd(linkKv.Key, linkKv.Value.GetValue<string>());
|
||||||
|
List<Link> links = linksDict.Select(x => new Link(x.Key, x.Value)).ToList();
|
||||||
|
|
||||||
|
string? originalLanguage =
|
||||||
|
attributes.TryGetPropertyValue("originalLanguage", out JsonNode? originalLanguageNode) switch
|
||||||
|
{
|
||||||
|
true => originalLanguageNode?.GetValue<string>(),
|
||||||
|
false => null
|
||||||
|
};
|
||||||
|
|
||||||
|
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
|
||||||
|
if (attributes.TryGetPropertyValue("status", out JsonNode? statusNode))
|
||||||
|
{
|
||||||
|
releaseStatus = statusNode?.GetValue<string>().ToLower() switch
|
||||||
|
{
|
||||||
|
"ongoing" => MangaReleaseStatus.Continuing,
|
||||||
|
"completed" => MangaReleaseStatus.Completed,
|
||||||
|
"hiatus" => MangaReleaseStatus.OnHiatus,
|
||||||
|
"cancelled" => MangaReleaseStatus.Cancelled,
|
||||||
|
_ => MangaReleaseStatus.Unreleased
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
uint year = attributes.TryGetPropertyValue("year", out JsonNode? yearNode) switch
|
||||||
|
{
|
||||||
|
true => yearNode?.GetValue<uint>()??0,
|
||||||
|
false => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
HashSet<string> tags = new(128);
|
||||||
|
if (attributes.TryGetPropertyValue("tags", out JsonNode? tagsNode))
|
||||||
|
foreach (JsonNode? tagNode in tagsNode!.AsArray())
|
||||||
|
tags.Add(tagNode!["attributes"]!["name"]!["en"]!.GetValue<string>());
|
||||||
|
List<MangaTag> mangaTags = tags.Select(t => new MangaTag(t)).ToList();
|
||||||
|
|
||||||
|
if (!manga.TryGetPropertyValue("relationships", out JsonNode? relationshipsNode))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
JsonNode? coverNode = relationshipsNode!.AsArray()
|
||||||
|
.FirstOrDefault(rel => rel!["type"]!.GetValue<string>().Equals("cover_art"));
|
||||||
|
if (coverNode is null)
|
||||||
|
return null;
|
||||||
|
string fileName = coverNode["attributes"]!["fileName"]!.GetValue<string>();
|
||||||
|
string coverUrl = $"https://uploads.mangadex.org/covers/{publicationId}/{fileName}";
|
||||||
|
|
||||||
|
List<string> authorNames = new();
|
||||||
|
JsonNode?[] authorNodes = relationshipsNode.AsArray()
|
||||||
|
.Where(rel => rel!["type"]!.GetValue<string>().Equals("author") || rel!["type"]!.GetValue<string>().Equals("artist")).ToArray();
|
||||||
|
foreach (JsonNode? authorNode in authorNodes)
|
||||||
|
{
|
||||||
|
string authorName = authorNode!["attributes"]!["name"]!.GetValue<string>();
|
||||||
|
if(!authorNames.Contains(authorName))
|
||||||
|
authorNames.Add(authorName);
|
||||||
|
}
|
||||||
|
List<Author> authors = authorNames.Select(a => new Author(a)).ToList();
|
||||||
|
|
||||||
|
Manga pub = new (publicationId, sortName, description, $"https://mangadex.org/title/{publicationId}", coverUrl, null, year,
|
||||||
|
originalLanguage, releaseStatus, -1,
|
||||||
|
this,
|
||||||
|
authors,
|
||||||
|
mangaTags,
|
||||||
|
links,
|
||||||
|
altTitles);
|
||||||
|
|
||||||
|
return (pub, authors, mangaTags, links, altTitles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Manga manga, string language="en")
|
||||||
|
{
|
||||||
|
const int limit = 100; //How many values we want returned at once
|
||||||
|
int offset = 0; //"Page"
|
||||||
|
int total = int.MaxValue; //How many total results are there, is updated on first request
|
||||||
|
List<Chapter> chapters = new();
|
||||||
|
//As long as we haven't requested all "Pages"
|
||||||
|
while (offset < total)
|
||||||
|
{
|
||||||
|
//Request next "Page"
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(
|
||||||
|
$"https://api.mangadex.org/manga/{manga.IdOnConnectorSite}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}&contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica&contentRating%5B%5D=pornographic", RequestType.MangaDexFeed);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
break;
|
||||||
|
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
if (result is null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
total = result["total"]!.GetValue<int>();
|
||||||
|
JsonArray chaptersInResult = result["data"]!.AsArray();
|
||||||
|
//Loop through all Chapters in result and extract information from JSON
|
||||||
|
foreach (JsonNode? jsonNode in chaptersInResult)
|
||||||
|
{
|
||||||
|
JsonObject chapter = (JsonObject)jsonNode!;
|
||||||
|
JsonObject attributes = chapter["attributes"]!.AsObject();
|
||||||
|
|
||||||
|
string chapterId = chapter["id"]!.GetValue<string>();
|
||||||
|
string url = $"https://mangadex.org/chapter/{chapterId}";
|
||||||
|
|
||||||
|
string? title = attributes.ContainsKey("title") && attributes["title"] is not null
|
||||||
|
? attributes["title"]!.GetValue<string>()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
int? volume = attributes.ContainsKey("volume") && attributes["volume"] is not null
|
||||||
|
? int.Parse(attributes["volume"]!.GetValue<string>())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
string? chapterNumStr = attributes.ContainsKey("chapter") && attributes["chapter"] is not null
|
||||||
|
? attributes["chapter"]!.GetValue<string>()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
string chapterNumber = new(chapterNumStr);
|
||||||
|
|
||||||
|
|
||||||
|
if (attributes.ContainsKey("pages") && attributes["pages"] is not null &&
|
||||||
|
attributes["pages"]!.GetValue<int>() < 1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Chapter newChapter = new Chapter(manga, url, chapterNumber, volume, title);
|
||||||
|
if(!chapters.Contains(newChapter))
|
||||||
|
chapters.Add(newChapter);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Return Chapters ordered by Chapter-Number
|
||||||
|
return chapters.Order().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override string[] GetChapterImageUrls(Chapter chapter)
|
||||||
|
{//Request URLs for Chapter-Images
|
||||||
|
Match m = Regex.Match(chapter.Url, @"https?:\/\/mangadex.org\/chapter\/([0-9\-a-z]+)");
|
||||||
|
if (!m.Success)
|
||||||
|
return [];
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{m.Groups[1].Value}?forcePort443=false", RequestType.MangaDexImage);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
string baseUrl = result["baseUrl"]!.GetValue<string>();
|
||||||
|
string hash = result["chapter"]!["hash"]!.GetValue<string>();
|
||||||
|
JsonArray imageFileNames = result["chapter"]!["data"]!.AsArray();
|
||||||
|
//Loop through all imageNames and construct urls (imageUrl)
|
||||||
|
List<string> imageUrls = new();
|
||||||
|
foreach (JsonNode? image in imageFileNames)
|
||||||
|
imageUrls.Add($"{baseUrl}/data/{hash}/{image!.GetValue<string>()}");
|
||||||
|
return imageUrls.ToArray();
|
||||||
|
}
|
||||||
|
}
|
183
API/Schema/MangaConnectors/MangaHere.cs
Normal file
183
API/Schema/MangaConnectors/MangaHere.cs
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
public class MangaHere : MangaConnector
|
||||||
|
{
|
||||||
|
public MangaHere() : base("MangaHere", ["en"], ["www.mangahere.cc"], "http://www.mangahere.cc/favicon.ico")
|
||||||
|
{
|
||||||
|
this.downloadClient = new ChromiumDownloadClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
string sanitizedTitle = string.Join('+', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
|
||||||
|
string requestUrl = $"https://www.mangahere.cc/search?title={sanitizedTitle}";
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
if (document.DocumentNode.SelectNodes("//div[contains(concat(' ',normalize-space(@class),' '),' container ')]").Any(node => node.ChildNodes.Any(cNode => cNode.HasClass("search-keywords"))))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
List<string> urls = document.DocumentNode
|
||||||
|
.SelectNodes("//a[contains(@href, '/manga/') and not(contains(@href, '.html'))]")
|
||||||
|
.Select(thumb => $"https://www.mangahere.cc{thumb.GetAttributeValue("href", "")}").Distinct().ToList();
|
||||||
|
|
||||||
|
HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
|
||||||
|
if (manga is { } x)
|
||||||
|
ret.Add(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
|
||||||
|
{
|
||||||
|
return GetMangaFromUrl($"https://www.mangahere.cc/manga/{publicationId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
|
||||||
|
{
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(url, RequestType.MangaInfo);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Regex idRex = new (@"https:\/\/www\.mangahere\.[a-z]{0,63}\/manga\/([0-9A-z\-]+).*");
|
||||||
|
string id = idRex.Match(url).Groups[1].Value;
|
||||||
|
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, id, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
|
||||||
|
{
|
||||||
|
string originalLanguage = "", status = "";
|
||||||
|
Dictionary<string, string> altTitles = new(), links = new();
|
||||||
|
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
|
||||||
|
|
||||||
|
//We dont get posters, because same origin bs HtmlNode posterNode = document.DocumentNode.SelectSingleNode("//img[contains(concat(' ',normalize-space(@class),' '),' detail-info-cover-img ')]");
|
||||||
|
string coverUrl = "http://static.mangahere.cc/v20230914/mangahere/images/nopicture.jpg";
|
||||||
|
|
||||||
|
HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//span[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-title-font ')]");
|
||||||
|
string sortName = titleNode.InnerText;
|
||||||
|
|
||||||
|
List<string> authorNames = document.DocumentNode
|
||||||
|
.SelectNodes("//p[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-say ')]/a")
|
||||||
|
.Select(node => node.InnerText)
|
||||||
|
.ToList();
|
||||||
|
List<Author> authors = authorNames.Select(n => new Author(n)).ToList();
|
||||||
|
|
||||||
|
HashSet<string> tags = document.DocumentNode
|
||||||
|
.SelectNodes("//p[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-tag-list ')]/a")
|
||||||
|
.Select(node => node.InnerText)
|
||||||
|
.ToHashSet();
|
||||||
|
List<MangaTag> mangaTags = tags.Select(n => new MangaTag(n)).ToList();
|
||||||
|
|
||||||
|
status = document.DocumentNode.SelectSingleNode("//span[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-title-tip ')]").InnerText;
|
||||||
|
switch (status.ToLower())
|
||||||
|
{
|
||||||
|
case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break;
|
||||||
|
case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break;
|
||||||
|
case "discontinued": releaseStatus = MangaReleaseStatus.Cancelled; break;
|
||||||
|
case "complete": releaseStatus = MangaReleaseStatus.Completed; break;
|
||||||
|
case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
HtmlNode descriptionNode = document.DocumentNode
|
||||||
|
.SelectSingleNode("//p[contains(concat(' ',normalize-space(@class),' '),' fullcontent ')]");
|
||||||
|
string description = descriptionNode.InnerText;
|
||||||
|
|
||||||
|
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, 0,
|
||||||
|
originalLanguage, releaseStatus, -1,
|
||||||
|
this,
|
||||||
|
authors,
|
||||||
|
mangaTags,
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
return (manga, authors, mangaTags, [], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Manga manga, string language="en")
|
||||||
|
{
|
||||||
|
string requestUrl = $"https://www.mangahere.cc/manga/{manga.MangaId}";
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
|
||||||
|
return Array.Empty<Chapter>();
|
||||||
|
|
||||||
|
List<string> urls = requestResult.htmlDocument.DocumentNode.SelectNodes("//div[@id='list-1']/ul//li//a[contains(@href, '/manga/')]")
|
||||||
|
.Select(node => node.GetAttributeValue("href", "")).ToList();
|
||||||
|
Regex chapterRex = new(@".*\/manga\/[a-zA-Z0-9\-\._\~\!\$\&\'\(\)\*\+\,\;\=\:\@]+\/v([0-9(TBD)]+)\/c([0-9\.]+)\/.*");
|
||||||
|
|
||||||
|
List<Chapter> chapters = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
Match rexMatch = chapterRex.Match(url);
|
||||||
|
|
||||||
|
int? volumeNumber = rexMatch.Groups[1].Value == "TBD" ? null : int.Parse(rexMatch.Groups[1].Value);
|
||||||
|
string chapterNumber = new(rexMatch.Groups[2].Value);
|
||||||
|
string fullUrl = $"https://www.mangahere.cc{url}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
chapters.Add(new Chapter(manga, fullUrl, chapterNumber, volumeNumber, null));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//Return Chapters ordered by Chapter-Number
|
||||||
|
return chapters.Order().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override string[] GetChapterImageUrls(Chapter chapter)
|
||||||
|
{
|
||||||
|
List<string> imageUrls = new();
|
||||||
|
|
||||||
|
int downloaded = 1;
|
||||||
|
int images = 1;
|
||||||
|
string url = string.Join('/', chapter.Url.Split('/')[..^1]);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest($"{url}/{downloaded}.html", RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
imageUrls.AddRange(ParseImageUrlsFromHtml(requestResult.htmlDocument));
|
||||||
|
|
||||||
|
images = requestResult.htmlDocument.DocumentNode
|
||||||
|
.SelectNodes("//a[contains(@href, '/manga/')]")
|
||||||
|
.MaxBy(node => node.GetAttributeValue("data-page", 0))!.GetAttributeValue("data-page", 0);
|
||||||
|
} while (downloaded++ <= images);
|
||||||
|
|
||||||
|
return imageUrls.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
return document.DocumentNode
|
||||||
|
.SelectNodes("//img[contains(concat(' ',normalize-space(@class),' '),' reader-main-img ')]")
|
||||||
|
.Select(node =>
|
||||||
|
{
|
||||||
|
string url = node.GetAttributeValue("src", "");
|
||||||
|
return url.StartsWith("//") ? $"https:{url}" : url;
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
233
API/Schema/MangaConnectors/MangaKatana.cs
Normal file
233
API/Schema/MangaConnectors/MangaKatana.cs
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
public class MangaKatana : MangaConnector
|
||||||
|
{
|
||||||
|
public MangaKatana() : base("MangaKatana", ["en"], ["mangakatana.com"], "https://mangakatana.com/static/img/fav.png")
|
||||||
|
{
|
||||||
|
this.downloadClient = new HttpDownloadClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
string sanitizedTitle = string.Join("%20", Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
|
||||||
|
string requestUrl = $"https://mangakatana.com/?search={sanitizedTitle}&search_by=book_name";
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
// ReSharper disable once MergeIntoPattern
|
||||||
|
// If a single result is found, the user will be redirected to the results directly instead of a result page
|
||||||
|
if(requestResult.hasBeenRedirected
|
||||||
|
&& requestResult.redirectedToUrl is not null
|
||||||
|
&& requestResult.redirectedToUrl.Contains("mangakatana.com/manga"))
|
||||||
|
{
|
||||||
|
return new [] { ParseSinglePublicationFromHtml(requestResult.result, requestResult.redirectedToUrl.Split('/')[^1], requestResult.redirectedToUrl) };
|
||||||
|
}
|
||||||
|
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.result);
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
|
||||||
|
{
|
||||||
|
return GetMangaFromUrl($"https://mangakatana.com/manga/{publicationId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
|
||||||
|
{
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(url, RequestType.MangaInfo);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return null;
|
||||||
|
return ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1], url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(Stream html)
|
||||||
|
{
|
||||||
|
StreamReader reader = new(html);
|
||||||
|
string htmlString = reader.ReadToEnd();
|
||||||
|
HtmlDocument document = new();
|
||||||
|
document.LoadHtml(htmlString);
|
||||||
|
IEnumerable<HtmlNode> searchResults = document.DocumentNode.SelectNodes("//*[@id='book_list']/div");
|
||||||
|
if (searchResults is null || !searchResults.Any())
|
||||||
|
return [];
|
||||||
|
List<string> urls = new();
|
||||||
|
foreach (HtmlNode mangaResult in searchResults)
|
||||||
|
{
|
||||||
|
urls.Add(mangaResult.Descendants("a").First().GetAttributes()
|
||||||
|
.First(a => a.Name == "href").Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
|
||||||
|
if (manga is { } x)
|
||||||
|
ret.Add(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(Stream html, string publicationId, string websiteUrl)
|
||||||
|
{
|
||||||
|
StreamReader reader = new(html);
|
||||||
|
string htmlString = reader.ReadToEnd();
|
||||||
|
HtmlDocument document = new();
|
||||||
|
document.LoadHtml(htmlString);
|
||||||
|
Dictionary<string, string> altTitlesDict = new();
|
||||||
|
Dictionary<string, string>? links = null;
|
||||||
|
HashSet<string> tags = new();
|
||||||
|
string[] authorNames = [];
|
||||||
|
string originalLanguage = "";
|
||||||
|
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
|
||||||
|
|
||||||
|
HtmlNode infoNode = document.DocumentNode.SelectSingleNode("//*[@id='single_book']");
|
||||||
|
string sortName = infoNode.Descendants("h1").First(n => n.HasClass("heading")).InnerText;
|
||||||
|
HtmlNode infoTable = infoNode.SelectSingleNode("//*[@id='single_book']/div[2]/div/ul");
|
||||||
|
|
||||||
|
foreach (HtmlNode row in infoTable.Descendants("li"))
|
||||||
|
{
|
||||||
|
string key = row.SelectNodes("div").First().InnerText.ToLower();
|
||||||
|
string value = row.SelectNodes("div").Last().InnerText;
|
||||||
|
string keySanitized = string.Concat(Regex.Matches(key, "[a-z]"));
|
||||||
|
|
||||||
|
switch (keySanitized)
|
||||||
|
{
|
||||||
|
case "altnames":
|
||||||
|
string[] alts = value.Split(" ; ");
|
||||||
|
for (int i = 0; i < alts.Length; i++)
|
||||||
|
altTitlesDict.Add(i.ToString(), alts[i]);
|
||||||
|
break;
|
||||||
|
case "authorsartists":
|
||||||
|
authorNames = value.Split(',');
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
switch (value.ToLower())
|
||||||
|
{
|
||||||
|
case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
|
||||||
|
case "completed": releaseStatus = MangaReleaseStatus.Completed; break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "genres":
|
||||||
|
tags = row.SelectNodes("div").Last().Descendants("a").Select(a => a.InnerText).ToHashSet();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string coverUrl = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[1]/div").Descendants("img").First()
|
||||||
|
.GetAttributes().First(a => a.Name == "src").Value;
|
||||||
|
|
||||||
|
string description = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[3]/p").InnerText;
|
||||||
|
while (description.StartsWith('\n'))
|
||||||
|
description = description.Substring(1);
|
||||||
|
|
||||||
|
uint year = (uint)DateTime.UtcNow.Year;
|
||||||
|
string yearString = infoTable.Descendants("div").First(d => d.HasClass("updateAt"))
|
||||||
|
.InnerText.Split('-')[^1];
|
||||||
|
|
||||||
|
if(yearString.Contains("ago") == false)
|
||||||
|
{
|
||||||
|
year = uint.Parse(yearString);
|
||||||
|
}
|
||||||
|
List<Author> authors = authorNames.Select(n => new Author(n)).ToList();
|
||||||
|
List<MangaTag> mangaTags = tags.Select(n => new MangaTag(n)).ToList();
|
||||||
|
List<MangaAltTitle> altTitles = altTitlesDict.Select(x => new MangaAltTitle(x.Key, x.Value)).ToList();
|
||||||
|
|
||||||
|
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
|
||||||
|
originalLanguage, releaseStatus, -1,
|
||||||
|
this,
|
||||||
|
authors,
|
||||||
|
mangaTags,
|
||||||
|
[],
|
||||||
|
altTitles);
|
||||||
|
|
||||||
|
return (manga, authors, mangaTags, [], altTitles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Manga manga, string language="en")
|
||||||
|
{
|
||||||
|
string requestUrl = $"https://mangakatana.com/manga/{manga.MangaId}";
|
||||||
|
// Leaving this in for verification if the page exists
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return Array.Empty<Chapter>();
|
||||||
|
|
||||||
|
//Return Chapters ordered by Chapter-Number
|
||||||
|
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl);
|
||||||
|
return chapters.Order().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Chapter> ParseChaptersFromHtml(Manga manga, string mangaUrl)
|
||||||
|
{
|
||||||
|
// Using HtmlWeb will include the chapters since they are loaded with js
|
||||||
|
HtmlWeb web = new();
|
||||||
|
HtmlDocument document = web.Load(mangaUrl);
|
||||||
|
|
||||||
|
List<Chapter> ret = new();
|
||||||
|
|
||||||
|
HtmlNode chapterList = document.DocumentNode.SelectSingleNode("//div[contains(@class, 'chapters')]/table/tbody");
|
||||||
|
|
||||||
|
Regex volumeRex = new(@"[0-9a-z\-\.]+\/[0-9a-z\-]*v([0-9\.]+)");
|
||||||
|
Regex chapterNumRex = new(@"[0-9a-z\-\.]+\/[0-9a-z\-]*c([0-9\.]+)");
|
||||||
|
Regex chapterNameRex = new(@"Chapter [0-9\.]+:? (.*)");
|
||||||
|
|
||||||
|
foreach (HtmlNode chapterInfo in chapterList.Descendants("tr"))
|
||||||
|
{
|
||||||
|
string fullString = chapterInfo.Descendants("a").First().InnerText;
|
||||||
|
string url = chapterInfo.Descendants("a").First()
|
||||||
|
.GetAttributeValue("href", "");
|
||||||
|
|
||||||
|
int? volumeNumber = volumeRex.IsMatch(url) ? int.Parse(volumeRex.Match(url).Groups[1].Value) : null;
|
||||||
|
|
||||||
|
string chapterNumber = new(chapterNumRex.Match(url).Groups[1].Value);
|
||||||
|
string chapterName = chapterNameRex.Match(fullString).Groups[1].Value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ret.Add(new Chapter(manga, url, chapterNumber, volumeNumber, chapterName));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override string[] GetChapterImageUrls(Chapter chapter)
|
||||||
|
{
|
||||||
|
string requestUrl = chapter.Url;
|
||||||
|
// Leaving this in to check if the page exists
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
|
||||||
|
return imageUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
// Images are loaded dynamically, but the urls are present in a piece of js code on the page
|
||||||
|
string js = document.DocumentNode.SelectSingleNode("//script[contains(., 'data-src')]").InnerText
|
||||||
|
.Replace("\r", "")
|
||||||
|
.Replace("\n", "")
|
||||||
|
.Replace("\t", "");
|
||||||
|
|
||||||
|
// ReSharper disable once StringLiteralTypo
|
||||||
|
string regexPat = @"(var thzq=\[')(.*)(,];function)";
|
||||||
|
var group = Regex.Matches(js, regexPat).First().Groups[2].Value.Replace("'", "");
|
||||||
|
var urls = group.Split(',');
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
}
|
219
API/Schema/MangaConnectors/Manganato.cs
Normal file
219
API/Schema/MangaConnectors/Manganato.cs
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
public class Manganato : MangaConnector
|
||||||
|
{
|
||||||
|
public Manganato() : base("Manganato", ["en"],
|
||||||
|
["natomanga.com", "manganato.gg", "mangakakalot.gg", "nelomanga.com"],
|
||||||
|
"https://www.manganato.gg/images/favicon-manganato.webp")
|
||||||
|
{
|
||||||
|
this.downloadClient = new HttpDownloadClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(
|
||||||
|
string publicationTitle = "")
|
||||||
|
{
|
||||||
|
string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0))
|
||||||
|
.ToLower();
|
||||||
|
string requestUrl = $"https://manganato.gg/search/story/{sanitizedTitle}";
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
return [];
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications =
|
||||||
|
ParsePublicationsFromHtml(requestResult.htmlDocument);
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(
|
||||||
|
HtmlDocument document)
|
||||||
|
{
|
||||||
|
List<HtmlNode> searchResults =
|
||||||
|
document.DocumentNode.Descendants("div").Where(n => n.HasClass("story_item")).ToList();
|
||||||
|
List<string> urls = new();
|
||||||
|
foreach (HtmlNode mangaResult in searchResults)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
urls.Add(mangaResult.Descendants("h3").First(n => n.HasClass("story_name"))
|
||||||
|
.Descendants("a").First().GetAttributeValue("href", ""));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
//failed to get a url, send it to the void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
|
||||||
|
if (manga is { } m)
|
||||||
|
ret.Add(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(
|
||||||
|
string publicationId)
|
||||||
|
{
|
||||||
|
return GetMangaFromUrl($"https://chapmanganato.com/{publicationId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)?
|
||||||
|
GetMangaFromUrl(string url)
|
||||||
|
{
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(url, RequestType.MangaInfo);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
return null;
|
||||||
|
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(
|
||||||
|
HtmlDocument document, string publicationId, string websiteUrl)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> altTitles = new();
|
||||||
|
List<MangaTag> tags = new();
|
||||||
|
List<Author> authors = new();
|
||||||
|
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
|
||||||
|
|
||||||
|
HtmlNode infoNode = document.DocumentNode.Descendants("ul").First(d => d.HasClass("manga-info-text"));
|
||||||
|
|
||||||
|
string sortName = infoNode.Descendants("h1").First().InnerText;
|
||||||
|
|
||||||
|
foreach (HtmlNode li in infoNode.Descendants("li"))
|
||||||
|
{
|
||||||
|
string text = li.InnerText.Trim().ToLower();
|
||||||
|
|
||||||
|
if (text.StartsWith("author(s) :"))
|
||||||
|
{
|
||||||
|
authors = li.Descendants("a").Select(a => a.InnerText.Trim()).Select(a => new Author(a)).ToList();
|
||||||
|
}
|
||||||
|
else if (text.StartsWith("status :"))
|
||||||
|
{
|
||||||
|
string status = text.Replace("status :", "").Trim().ToLower();
|
||||||
|
if (string.IsNullOrWhiteSpace(status))
|
||||||
|
releaseStatus = MangaReleaseStatus.Continuing;
|
||||||
|
else if (status == "ongoing")
|
||||||
|
releaseStatus = MangaReleaseStatus.Continuing;
|
||||||
|
else
|
||||||
|
releaseStatus = Enum.Parse<MangaReleaseStatus>(status, true);
|
||||||
|
}
|
||||||
|
else if (li.HasClass("genres"))
|
||||||
|
{
|
||||||
|
tags = li.Descendants("a").Select(a => new MangaTag(a.InnerText.Trim())).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string posterUrl = document.DocumentNode.Descendants("div").First(s => s.HasClass("manga-info-pic"))
|
||||||
|
.Descendants("img").First()
|
||||||
|
.GetAttributes().First(a => a.Name == "src").Value;
|
||||||
|
|
||||||
|
string description = document.DocumentNode.SelectSingleNode("//div[@id='contentBox']")
|
||||||
|
.InnerText.Replace("Description :", "");
|
||||||
|
while (description.StartsWith('\n'))
|
||||||
|
description = description.Substring(1);
|
||||||
|
|
||||||
|
string pattern = "MMM-dd-yyyy HH:mm";
|
||||||
|
|
||||||
|
HtmlNode? oldestChapter = document.DocumentNode
|
||||||
|
.SelectNodes("//div[contains(concat(' ',normalize-space(@class),' '),' row ')]/span[@title]").MaxBy(
|
||||||
|
node => DateTime.ParseExact(node.GetAttributeValue("title", "Dec-31-2400 23:59"), pattern,
|
||||||
|
CultureInfo.InvariantCulture).Millisecond);
|
||||||
|
|
||||||
|
|
||||||
|
uint year = Convert.ToUInt32(DateTime.ParseExact(
|
||||||
|
oldestChapter?.GetAttributeValue("title", "Dec 31 2400, 23:59") ?? "Dec 31 2400, 23:59", pattern,
|
||||||
|
CultureInfo.InvariantCulture).Year);
|
||||||
|
|
||||||
|
Manga manga = new(publicationId, sortName, description, websiteUrl, posterUrl, null, year, null, releaseStatus,
|
||||||
|
-1, this, authors, tags, [], []);
|
||||||
|
return (manga, authors, tags, [], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Manga manga, string language = "en")
|
||||||
|
{
|
||||||
|
string requestUrl = manga.WebsiteUrl;
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return Array.Empty<Chapter>();
|
||||||
|
|
||||||
|
//Return Chapters ordered by Chapter-Number
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
return Array.Empty<Chapter>();
|
||||||
|
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument);
|
||||||
|
return chapters.Order().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override string[] GetChapterImageUrls(Chapter chapter)
|
||||||
|
{
|
||||||
|
string requestUrl = chapter.Url;
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 ||
|
||||||
|
requestResult.htmlDocument is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
|
||||||
|
|
||||||
|
return imageUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Chapter> ParseChaptersFromHtml(Manga manga, HtmlDocument document)
|
||||||
|
{
|
||||||
|
List<Chapter> ret = new();
|
||||||
|
|
||||||
|
HtmlNode chapterList = document.DocumentNode.Descendants("div").First(l => l.HasClass("chapter-list"));
|
||||||
|
|
||||||
|
Regex volRex = new(@"Vol\.([0-9]+).*");
|
||||||
|
Regex chapterRex = new(@"https:\/\/chapmanganato.[A-z]+\/manga-[A-z0-9]+\/chapter-([0-9\.]+)");
|
||||||
|
Regex nameRex = new(@"Chapter ([0-9]+(\.[0-9]+)*){1}:? (.*)");
|
||||||
|
|
||||||
|
foreach (HtmlNode chapterInfo in chapterList.Descendants("div").Where(x => x.HasClass("row")))
|
||||||
|
{
|
||||||
|
string url = chapterInfo.Descendants("a").First().GetAttributeValue("href", "");
|
||||||
|
var name = chapterInfo.Descendants("a").First().InnerText.Trim();
|
||||||
|
string chapterName = nameRex.Match(name).Groups[3].Value;
|
||||||
|
string chapterNumber = Regex.Match(name, @"Chapter ([0-9]+(\.[0-9]+)*)").Groups[1].Value;
|
||||||
|
string? volumeNumber = Regex.Match(chapterName, @"Vol\.([0-9]+)").Groups[1].Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(volumeNumber))
|
||||||
|
volumeNumber = "0";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ret.Add(new Chapter(manga, url, chapterNumber, int.Parse(volumeNumber), chapterName));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.Reverse();
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
List<string> ret = new();
|
||||||
|
|
||||||
|
HtmlNode imageContainer =
|
||||||
|
document.DocumentNode.Descendants("div").First(i => i.HasClass("container-chapter-reader"));
|
||||||
|
foreach (HtmlNode imageNode in imageContainer.Descendants("img"))
|
||||||
|
ret.Add(imageNode.GetAttributeValue("src", ""));
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
}
|
223
API/Schema/MangaConnectors/Mangaworld.cs
Normal file
223
API/Schema/MangaConnectors/Mangaworld.cs
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
public class Mangaworld : MangaConnector
|
||||||
|
{
|
||||||
|
public Mangaworld() : base("Mangaworld", ["it"], ["www.mangaworld.ac", "www.mangaworld.nz"], "https://www.mangaworld.nz/public/assets/seo/android-icon-192x192.png")
|
||||||
|
{
|
||||||
|
this.downloadClient = new ChromiumDownloadClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
|
||||||
|
string requestUrl = $"https://www.mangaworld.ac/archive?keyword={sanitizedTitle}";
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
return [];
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
if (!document.DocumentNode.SelectSingleNode("//div[@class='comics-grid']").ChildNodes
|
||||||
|
.Any(node => node.HasClass("entry")))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
List<string> urls = document.DocumentNode
|
||||||
|
.SelectNodes(
|
||||||
|
"//div[@class='comics-grid']//div[@class='entry']//a[contains(concat(' ',normalize-space(@class),' '),'thumb')]")
|
||||||
|
.Select(thumb => thumb.GetAttributeValue("href", "")).ToList();
|
||||||
|
|
||||||
|
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
|
||||||
|
if (manga is { } x)
|
||||||
|
ret.Add(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
|
||||||
|
{
|
||||||
|
return GetMangaFromUrl($"https://www.mangaworld.ac/manga/{publicationId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
|
||||||
|
{
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(url, RequestType.MangaInfo);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Regex idRex = new (@"https:\/\/www\.mangaworld\.[a-z]{0,63}\/manga\/([0-9]+\/[0-9A-z\-]+).*");
|
||||||
|
string id = idRex.Match(url).Groups[1].Value;
|
||||||
|
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, id, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> altTitlesDict = new();
|
||||||
|
string originalLanguage = "";
|
||||||
|
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
|
||||||
|
|
||||||
|
HtmlNode infoNode = document.DocumentNode.Descendants("div").First(d => d.HasClass("info"));
|
||||||
|
|
||||||
|
string sortName = infoNode.Descendants("h1").First().InnerText;
|
||||||
|
|
||||||
|
HtmlNode metadata = infoNode.Descendants().First(d => d.HasClass("meta-data"));
|
||||||
|
|
||||||
|
HtmlNode altTitlesNode = metadata.SelectSingleNode("//span[text()='Titoli alternativi: ' or text()='Titolo alternativo: ']/..").ChildNodes[1];
|
||||||
|
string[] alts = altTitlesNode.InnerText.Split(", ");
|
||||||
|
for(int i = 0; i < alts.Length; i++)
|
||||||
|
altTitlesDict.Add(i.ToString(), alts[i]);
|
||||||
|
List<MangaAltTitle> altTitles = altTitlesDict.Select(a => new MangaAltTitle(a.Key, a.Value)).ToList();
|
||||||
|
|
||||||
|
HtmlNode genresNode =
|
||||||
|
metadata.SelectSingleNode("//span[text()='Generi: ' or text()='Genero: ']/..");
|
||||||
|
HashSet<string> tags = genresNode.SelectNodes("a").Select(node => node.InnerText).ToHashSet();
|
||||||
|
List<MangaTag> mangaTags = tags.Select(t => new MangaTag(t)).ToList();
|
||||||
|
|
||||||
|
HtmlNode authorsNode =
|
||||||
|
metadata.SelectSingleNode("//span[text()='Autore: ' or text()='Autori: ']/..");
|
||||||
|
string[] authorNames = authorsNode.SelectNodes("a").Select(node => node.InnerText).ToArray();
|
||||||
|
List<Author> authors = authorNames.Select(n => new Author(n)).ToList();
|
||||||
|
|
||||||
|
string status = metadata.SelectSingleNode("//span[text()='Stato: ']/..").SelectNodes("a").First().InnerText;
|
||||||
|
// ReSharper disable 5 times StringLiteralTypo
|
||||||
|
switch (status.ToLower())
|
||||||
|
{
|
||||||
|
case "cancellato": releaseStatus = MangaReleaseStatus.Cancelled; break;
|
||||||
|
case "in pausa": releaseStatus = MangaReleaseStatus.OnHiatus; break;
|
||||||
|
case "droppato": releaseStatus = MangaReleaseStatus.Cancelled; break;
|
||||||
|
case "finito": releaseStatus = MangaReleaseStatus.Completed; break;
|
||||||
|
case "in corso": releaseStatus = MangaReleaseStatus.Continuing; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
string coverUrl = document.DocumentNode.SelectSingleNode("//img[@class='rounded']").GetAttributeValue("src", "");
|
||||||
|
|
||||||
|
string description = document.DocumentNode.SelectSingleNode("//div[@id='noidungm']").InnerText;
|
||||||
|
|
||||||
|
string yearString = metadata.SelectSingleNode("//span[text()='Anno di uscita: ']/..").SelectNodes("a").First().InnerText;
|
||||||
|
uint year = uint.Parse(yearString);
|
||||||
|
|
||||||
|
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
|
||||||
|
originalLanguage, releaseStatus, -1,
|
||||||
|
this,
|
||||||
|
authors,
|
||||||
|
mangaTags,
|
||||||
|
[],
|
||||||
|
altTitles);
|
||||||
|
|
||||||
|
return (manga, authors, mangaTags, [], altTitles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Manga manga, string language="en")
|
||||||
|
{
|
||||||
|
string requestUrl = $"https://www.mangaworld.ac/manga/{manga.MangaId}";
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument);
|
||||||
|
return chapters.Order().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Chapter> ParseChaptersFromHtml(Manga manga, HtmlDocument document)
|
||||||
|
{
|
||||||
|
List<Chapter> ret = new();
|
||||||
|
|
||||||
|
HtmlNode chaptersWrapper =
|
||||||
|
document.DocumentNode.SelectSingleNode(
|
||||||
|
"//div[contains(concat(' ',normalize-space(@class),' '),'chapters-wrapper')]");
|
||||||
|
|
||||||
|
Regex volumeRex = new(@"[Vv]olume ([0-9]+).*");
|
||||||
|
Regex chapterRex = new(@"[Cc]apitolo ([0-9]+(?:\.[0-9]+)?).*");
|
||||||
|
Regex idRex = new(@".*\/read\/([a-z0-9]+)(?:[?\/].*)?");
|
||||||
|
if (chaptersWrapper.Descendants("div").Any(descendant => descendant.HasClass("volume-element")))
|
||||||
|
{
|
||||||
|
foreach (HtmlNode volNode in document.DocumentNode.SelectNodes("//div[contains(concat(' ',normalize-space(@class),' '),'volume-element')]"))
|
||||||
|
{
|
||||||
|
string volumeStr = volumeRex.Match(volNode.SelectNodes("div").First(node => node.HasClass("volume")).SelectSingleNode("p").InnerText).Groups[1].Value;
|
||||||
|
int volume = int.Parse(volumeStr);
|
||||||
|
foreach (HtmlNode chNode in volNode.SelectNodes("div").First(node => node.HasClass("volume-chapters")).SelectNodes("div"))
|
||||||
|
{
|
||||||
|
|
||||||
|
string numberStr = chapterRex.Match(chNode.SelectSingleNode("a").SelectSingleNode("span").InnerText).Groups[1].Value;
|
||||||
|
|
||||||
|
string chapterNumber = new(numberStr);
|
||||||
|
string url = chNode.SelectSingleNode("a").GetAttributeValue("href", "");
|
||||||
|
string id = idRex.Match(chNode.SelectSingleNode("a").GetAttributeValue("href", "")).Groups[1].Value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ret.Add(new Chapter(manga, url, chapterNumber, volume, null));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (HtmlNode chNode in chaptersWrapper.SelectNodes("div").Where(node => node.HasClass("chapter")))
|
||||||
|
{
|
||||||
|
string numberStr = chapterRex.Match(chNode.SelectSingleNode("a").SelectSingleNode("span").InnerText).Groups[1].Value;
|
||||||
|
|
||||||
|
string chapterNumber = new(numberStr);
|
||||||
|
string url = chNode.SelectSingleNode("a").GetAttributeValue("href", "");
|
||||||
|
string id = idRex.Match(chNode.SelectSingleNode("a").GetAttributeValue("href", "")).Groups[1].Value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ret.Add(new Chapter(manga, url, chapterNumber, null, null));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.Reverse();
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override string[] GetChapterImageUrls(Chapter chapter)
|
||||||
|
{
|
||||||
|
string requestUrl = $"{chapter.Url}?style=list";
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
|
||||||
|
return imageUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
List<string> ret = new();
|
||||||
|
|
||||||
|
HtmlNode imageContainer =
|
||||||
|
document.DocumentNode.SelectSingleNode("//div[@id='page']");
|
||||||
|
foreach(HtmlNode imageNode in imageContainer.Descendants("img"))
|
||||||
|
ret.Add(imageNode.GetAttributeValue("src", ""));
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
}
|
179
API/Schema/MangaConnectors/ManhuaPlus.cs
Normal file
179
API/Schema/MangaConnectors/ManhuaPlus.cs
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
public class ManhuaPlus : MangaConnector
|
||||||
|
{
|
||||||
|
public ManhuaPlus() : base("ManhuaPlus", ["en"], ["manhuaplus.org"], "https://manhuaplus.org/uploads/images/favicon.png")
|
||||||
|
{
|
||||||
|
this.downloadClient = new ChromiumDownloadClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
|
||||||
|
string requestUrl = $"https://manhuaplus.org/search?keyword={sanitizedTitle}";
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
if (document.DocumentNode.SelectSingleNode("//h1/../..").ChildNodes//I already want to not.
|
||||||
|
.Any(node => node.InnerText.Contains("No manga found")))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
List<string> urls = document.DocumentNode
|
||||||
|
.SelectNodes("//h1/../..//a[contains(@href, 'https://manhuaplus.org/manga/') and contains(concat(' ',normalize-space(@class),' '),' clamp ') and not(contains(@href, '/chapter'))]")
|
||||||
|
.Select(mangaNode => mangaNode.GetAttributeValue("href", "")).ToList();
|
||||||
|
|
||||||
|
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
|
||||||
|
if (manga is { } x)
|
||||||
|
ret.Add(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
|
||||||
|
{
|
||||||
|
return GetMangaFromUrl($"https://manhuaplus.org/manga/{publicationId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
|
||||||
|
{
|
||||||
|
Regex publicationIdRex = new(@"https:\/\/manhuaplus.org\/manga\/(.*)(\/.*)*");
|
||||||
|
string publicationId = publicationIdRex.Match(url).Groups[1].Value;
|
||||||
|
|
||||||
|
RequestResult requestResult = this.downloadClient.MakeRequest(url, RequestType.MangaInfo);
|
||||||
|
if((int)requestResult.statusCode < 300 && (int)requestResult.statusCode >= 200 && requestResult.htmlDocument is not null && requestResult.redirectedToUrl != "https://manhuaplus.org/home") //When manga doesnt exists it redirects to home
|
||||||
|
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, publicationId, url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
|
||||||
|
{
|
||||||
|
string originalLanguage = "", status = "";
|
||||||
|
Dictionary<string, string> altTitles = new(), links = new();
|
||||||
|
HashSet<string> tags = new();
|
||||||
|
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
|
||||||
|
|
||||||
|
HtmlNode posterNode = document.DocumentNode.SelectSingleNode("/html/body/main/div/div/div[2]/div[1]/figure/a/img");//BRUH
|
||||||
|
Regex posterRex = new(@".*(\/uploads/covers/[a-zA-Z0-9\-\._\~\!\$\&\'\(\)\*\+\,\;\=\:\@]+).*");
|
||||||
|
string coverUrl = $"https://manhuaplus.org/{posterRex.Match(posterNode.GetAttributeValue("src", "")).Groups[1].Value}";
|
||||||
|
|
||||||
|
HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//h1");
|
||||||
|
string sortName = titleNode.InnerText.Replace("\n", "");
|
||||||
|
|
||||||
|
List<string> authorNames = new();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HtmlNode[] authorsNodes = document.DocumentNode
|
||||||
|
.SelectNodes("//a[contains(@href, 'https://manhuaplus.org/authors/')]")
|
||||||
|
.ToArray();
|
||||||
|
foreach (HtmlNode authorNode in authorsNodes)
|
||||||
|
authorNames.Add(authorNode.InnerText);
|
||||||
|
}
|
||||||
|
catch (ArgumentNullException e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
List<Author> authors = authorNames.Select(a => new Author(a)).ToList();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HtmlNode[] genreNodes = document.DocumentNode
|
||||||
|
.SelectNodes("//a[contains(@href, 'https://manhuaplus.org/genres/')]").ToArray();
|
||||||
|
foreach (HtmlNode genreNode in genreNodes)
|
||||||
|
tags.Add(genreNode.InnerText.Replace("\n", ""));
|
||||||
|
}
|
||||||
|
catch (ArgumentNullException e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
List<MangaTag> mangaTags = tags.Select(t => new MangaTag(t)).ToList();
|
||||||
|
|
||||||
|
Regex yearRex = new(@"(?:[0-9]{1,2}\/){2}([0-9]{2,4}) [0-9]{1,2}:[0-9]{1,2}");
|
||||||
|
HtmlNode yearNode = document.DocumentNode.SelectSingleNode("//aside//i[contains(concat(' ',normalize-space(@class),' '),' fa-clock ')]/../span");
|
||||||
|
Match match = yearRex.Match(yearNode.InnerText);
|
||||||
|
uint year = match.Success && match.Groups[1].Success ? uint.Parse(match.Groups[1].Value) : 0;
|
||||||
|
|
||||||
|
status = document.DocumentNode.SelectSingleNode("//aside//i[contains(concat(' ',normalize-space(@class),' '),' fa-rss ')]/../span").InnerText.Replace("\n", "");
|
||||||
|
switch (status.ToLower())
|
||||||
|
{
|
||||||
|
case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break;
|
||||||
|
case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break;
|
||||||
|
case "discontinued": releaseStatus = MangaReleaseStatus.Cancelled; break;
|
||||||
|
case "complete": releaseStatus = MangaReleaseStatus.Completed; break;
|
||||||
|
case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
HtmlNode descriptionNode = document.DocumentNode
|
||||||
|
.SelectSingleNode("//div[@id='syn-target']");
|
||||||
|
string description = descriptionNode.InnerText;
|
||||||
|
|
||||||
|
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
|
||||||
|
originalLanguage, releaseStatus, -1,
|
||||||
|
this,
|
||||||
|
authors,
|
||||||
|
mangaTags,
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
return (manga, authors, mangaTags, [], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Manga manga, string language="en")
|
||||||
|
{
|
||||||
|
RequestResult result = downloadClient.MakeRequest($"https://manhuaplus.org/manga/{manga.MangaId}", RequestType.Default);
|
||||||
|
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return Array.Empty<Chapter>();
|
||||||
|
}
|
||||||
|
|
||||||
|
HtmlNodeCollection chapterNodes = result.htmlDocument.DocumentNode.SelectNodes("//li[contains(concat(' ',normalize-space(@class),' '),' chapter ')]//a");
|
||||||
|
string[] urls = chapterNodes.Select(node => node.GetAttributeValue("href", "")).ToArray();
|
||||||
|
Regex urlRex = new (@".*\/chapter-([0-9\-]+).*");
|
||||||
|
|
||||||
|
List<Chapter> chapters = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
Match rexMatch = urlRex.Match(url);
|
||||||
|
|
||||||
|
string chapterNumber = new(rexMatch.Groups[1].Value);
|
||||||
|
string fullUrl = url;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
chapters.Add(new Chapter(manga, fullUrl, chapterNumber, null, null));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//Return Chapters ordered by Chapter-Number
|
||||||
|
return chapters.Order().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override string[] GetChapterImageUrls(Chapter chapter)
|
||||||
|
{
|
||||||
|
RequestResult requestResult = this.downloadClient.MakeRequest(chapter.Url, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
HtmlDocument document = requestResult.htmlDocument;
|
||||||
|
|
||||||
|
HtmlNode[] images = document.DocumentNode.SelectNodes("//a[contains(concat(' ',normalize-space(@class),' '),' readImg ')]/img").ToArray();
|
||||||
|
List<string> urls = images.Select(node => node.GetAttributeValue("src", "")).ToList();
|
||||||
|
return urls.ToArray();
|
||||||
|
}
|
||||||
|
}
|
259
API/Schema/MangaConnectors/Webtoons.cs
Normal file
259
API/Schema/MangaConnectors/Webtoons.cs
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
public class Webtoons : MangaConnector
|
||||||
|
{
|
||||||
|
|
||||||
|
public Webtoons() : base("Webtoons", ["en"], ["www.webtoons.com"], "https://webtoons-static.pstatic.net/image/favicon/favicon.ico")
|
||||||
|
{
|
||||||
|
this.downloadClient = new HttpDownloadClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
|
||||||
|
string requestUrl = $"https://www.webtoons.com/en/search?keyword={sanitizedTitle}&searchType=WEBTOON";
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
(Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)[] publications =
|
||||||
|
ParsePublicationsFromHtml(requestResult.htmlDocument);
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
public override (Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)? GetMangaFromId(string publicationId)
|
||||||
|
{
|
||||||
|
PublicationManager pb = new PublicationManager(publicationId);
|
||||||
|
return GetMangaFromUrl($"https://www.webtoons.com/en/{pb.Category}/{pb.Title}/list?title_no={pb.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
public override (Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)? GetMangaFromUrl(string url)
|
||||||
|
{
|
||||||
|
RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Regex regex = new Regex(@".*webtoons\.com/en/(?<category>[^/]+)/(?<title>[^/]+)/list\?title_no=(?<id>\d+).*");
|
||||||
|
Match match = regex.Match(url);
|
||||||
|
|
||||||
|
if(match.Success) {
|
||||||
|
PublicationManager pm = new PublicationManager(match.Groups["title"].Value, match.Groups["category"].Value, match.Groups["id"].Value);
|
||||||
|
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, pm.getPublicationId(), url);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
private (Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)[] ParsePublicationsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
HtmlNode mangaList = document.DocumentNode.SelectSingleNode("//ul[contains(@class, 'card_lst')]");
|
||||||
|
if (!mangaList.ChildNodes.Any(node => node.Name == "li")) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<string> urls = document.DocumentNode
|
||||||
|
.SelectNodes("//ul[contains(@class, 'card_lst')]/li/a")
|
||||||
|
.Select(node => node.GetAttributeValue("href", "https://www.webtoons.com"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
List<(Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)> ret = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
(Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)? manga = GetMangaFromUrl(url);
|
||||||
|
if(manga is { } m)
|
||||||
|
ret.Add(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string capitalizeString(string str = "") {
|
||||||
|
if(str.Length == 0) return "";
|
||||||
|
if(str.Length == 1) return str.ToUpper();
|
||||||
|
return char.ToUpper(str[0]) + str.Substring(1).ToLower();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
private (Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
|
||||||
|
{
|
||||||
|
HtmlNode infoNode1 = document.DocumentNode.SelectSingleNode("//*[@id='content']/div[2]/div[1]/div[1]");
|
||||||
|
HtmlNode infoNode2 = document.DocumentNode.SelectSingleNode("//*[@id='content']/div[2]/div[2]/div[2]");
|
||||||
|
|
||||||
|
string sortName = infoNode1.SelectSingleNode(".//h1[contains(@class, 'subj')]").InnerText;
|
||||||
|
string description = infoNode2.SelectSingleNode(".//p[contains(@class, 'summary')]")
|
||||||
|
.InnerText.Trim();
|
||||||
|
|
||||||
|
HtmlNode posterNode = document.DocumentNode.SelectSingleNode("//div[contains(@class, 'detail_body') and contains(@class, 'banner')]");
|
||||||
|
|
||||||
|
Regex regex = new Regex(@"url\((?<url>.*?)\)");
|
||||||
|
Match match = regex.Match(posterNode.GetAttributeValue("style", ""));
|
||||||
|
|
||||||
|
string coverUrl = match.Groups["url"].Value;
|
||||||
|
|
||||||
|
string genre = infoNode1.SelectSingleNode(".//h2[contains(@class, 'genre')]")
|
||||||
|
.InnerText.Trim();
|
||||||
|
List<MangaTag> mangaTags = [new MangaTag(genre)];
|
||||||
|
|
||||||
|
List<HtmlNode> authorsNodes = infoNode1.SelectSingleNode(".//div[contains(@class, 'author_area')]").Descendants("a").ToList();
|
||||||
|
List<Author> authors = authorsNodes.Select(node => new Author(node.InnerText.Trim())).ToList();
|
||||||
|
|
||||||
|
string originalLanguage = "";
|
||||||
|
|
||||||
|
uint year = 0;
|
||||||
|
|
||||||
|
string status1 = infoNode2.SelectSingleNode(".//p").InnerText;
|
||||||
|
string status2 = infoNode2.SelectSingleNode(".//p/span").InnerText;
|
||||||
|
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
|
||||||
|
if(status2.Length == 0 || status1.ToLower() == "completed") {
|
||||||
|
releaseStatus = MangaReleaseStatus.Completed;
|
||||||
|
} else if(status2.ToLower() == "up") {
|
||||||
|
releaseStatus = MangaReleaseStatus.Continuing;
|
||||||
|
}
|
||||||
|
|
||||||
|
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
|
||||||
|
originalLanguage, releaseStatus, -1,
|
||||||
|
this,
|
||||||
|
authors,
|
||||||
|
mangaTags,
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
return (manga, authors, mangaTags, [], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
public override Chapter[] GetChapters(Manga manga, string language = "en")
|
||||||
|
{
|
||||||
|
PublicationManager pm = new(manga.MangaId);
|
||||||
|
string requestUrl = $"https://www.webtoons.com/en/{pm.Category}/{pm.Title}/list?title_no={pm.Id}";
|
||||||
|
// Leaving this in for verification if the page exists
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return Array.Empty<Chapter>();
|
||||||
|
|
||||||
|
// Get number of pages
|
||||||
|
int pages = requestResult.htmlDocument.DocumentNode
|
||||||
|
.SelectNodes("//div[contains(@class, 'paginate')]/a")
|
||||||
|
.ToList()
|
||||||
|
.Count;
|
||||||
|
List<Chapter> chapters = new List<Chapter>();
|
||||||
|
|
||||||
|
for(int page = 1; page <= pages; page++) {
|
||||||
|
string pageRequestUrl = $"{requestUrl}&page={page}";
|
||||||
|
chapters.AddRange(ParseChaptersFromHtml(manga, pageRequestUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapters.Order().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
private List<Chapter> ParseChaptersFromHtml(Manga manga, string mangaUrl)
|
||||||
|
{
|
||||||
|
RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default);
|
||||||
|
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return new List<Chapter>();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Chapter> ret = new();
|
||||||
|
|
||||||
|
foreach (HtmlNode chapterInfo in result.htmlDocument.DocumentNode.SelectNodes("//ul/li[contains(@class, '_episodeItem')]"))
|
||||||
|
{
|
||||||
|
HtmlNode infoNode = chapterInfo.SelectSingleNode(".//a");
|
||||||
|
string url = infoNode.GetAttributeValue("href", "");
|
||||||
|
|
||||||
|
string id = chapterInfo.GetAttributeValue("id", "");
|
||||||
|
if(id == "") continue;
|
||||||
|
string chapterNumber = chapterInfo.GetAttributeValue("data-episode-no", "");
|
||||||
|
if(chapterNumber == "") continue;
|
||||||
|
string chapterName = infoNode.SelectSingleNode(".//span[contains(@class, 'subj')]/span").InnerText.Trim();
|
||||||
|
ret.Add(new Chapter(manga, url, chapterNumber, null, chapterName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override string[] GetChapterImageUrls(Chapter chapter)
|
||||||
|
{
|
||||||
|
string requestUrl = chapter.Url;
|
||||||
|
// Leaving this in to check if the page exists
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] imageUrls = ParseImageUrlsFromHtml(requestUrl);
|
||||||
|
return imageUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] ParseImageUrlsFromHtml(string mangaUrl)
|
||||||
|
{
|
||||||
|
RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(mangaUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestResult.htmlDocument.DocumentNode
|
||||||
|
.SelectNodes("//*[@id='_imageList']/img")
|
||||||
|
.Select(node =>
|
||||||
|
node.GetAttributeValue("data-url", ""))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class PublicationManager {
|
||||||
|
public PublicationManager(string title = "", string category = "", string id = "") {
|
||||||
|
this.Title = title;
|
||||||
|
this.Category = category;
|
||||||
|
this.Id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicationManager(string publicationId) {
|
||||||
|
string[] parts = publicationId.Split("|");
|
||||||
|
if(parts.Length == 3) {
|
||||||
|
this.Title = parts[0];
|
||||||
|
this.Category = parts[1];
|
||||||
|
this.Id = parts[2];
|
||||||
|
} else {
|
||||||
|
this.Title = "";
|
||||||
|
this.Category = "";
|
||||||
|
this.Id = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string getPublicationId() {
|
||||||
|
return $"{this.Title}|{this.Category}|{this.Id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Title { get; set; }
|
||||||
|
public string Category { get; set; }
|
||||||
|
public string Id { get; set; }
|
||||||
|
}
|
175
API/Schema/MangaConnectors/WeebCentral.cs
Normal file
175
API/Schema/MangaConnectors/WeebCentral.cs
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.Schema.MangaConnectors;
|
||||||
|
|
||||||
|
public class Weebcentral : MangaConnector
|
||||||
|
{
|
||||||
|
private readonly string[] _filterWords =
|
||||||
|
{ "a", "the", "of", "as", "to", "no", "for", "on", "with", "be", "and", "in", "wa", "at", "be", "ni" };
|
||||||
|
|
||||||
|
public Weebcentral() : base("Weebcentral", ["en"], ["weebcentral.com"], "https://weebcentral.com/favicon.ico")
|
||||||
|
{
|
||||||
|
downloadClient = new ChromiumDownloadClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
const int limit = 32; //How many values we want returned at once
|
||||||
|
var offset = 0; //"Page"
|
||||||
|
var requestUrl =
|
||||||
|
$"https://{BaseUris[0]}/search/data?limit={limit}&offset={offset}&text={publicationTitle}&sort=Best+Match&order=Ascending&official=Any&display_mode=Minimal%20Display";
|
||||||
|
var requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 ||
|
||||||
|
requestResult.htmlDocument == null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
|
||||||
|
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
|
||||||
|
{
|
||||||
|
if (document.DocumentNode.SelectNodes("//article") == null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var urls = document.DocumentNode.SelectNodes("/html/body/article/a[@class='link link-hover']")
|
||||||
|
.Select(elem => elem.GetAttributeValue("href", "")).ToList();
|
||||||
|
|
||||||
|
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
|
||||||
|
foreach (var url in urls)
|
||||||
|
{
|
||||||
|
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
|
||||||
|
if (manga is { })
|
||||||
|
ret.Add(((Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?))manga);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
|
||||||
|
{
|
||||||
|
Regex publicationIdRex = new(@"https:\/\/weebcentral\.com\/series\/(\w*)\/(.*)");
|
||||||
|
var publicationId = publicationIdRex.Match(url).Groups[1].Value;
|
||||||
|
|
||||||
|
var requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo);
|
||||||
|
if ((int)requestResult.statusCode < 300 && (int)requestResult.statusCode >= 200 &&
|
||||||
|
requestResult.htmlDocument is not null)
|
||||||
|
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, publicationId, url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
|
||||||
|
{
|
||||||
|
HtmlNode posterNode =
|
||||||
|
document.DocumentNode.SelectSingleNode("//section[@class='flex items-center justify-center']/picture/img");
|
||||||
|
string posterUrl = posterNode?.GetAttributeValue("src", "") ?? "";
|
||||||
|
|
||||||
|
HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//section/h1");
|
||||||
|
string sortName = titleNode?.InnerText ?? "Undefined";
|
||||||
|
|
||||||
|
HtmlNode[] authorsNodes =
|
||||||
|
document.DocumentNode.SelectNodes("//ul/li[strong/text() = 'Author(s): ']/span")?.ToArray() ?? [];
|
||||||
|
List<Author> authors = authorsNodes.Select(n => new Author(n.InnerText)).ToList();
|
||||||
|
|
||||||
|
HtmlNode[] genreNodes =
|
||||||
|
document.DocumentNode.SelectNodes("//ul/li[strong/text() = 'Tags(s): ']/span")?.ToArray() ?? [];
|
||||||
|
List<MangaTag> tags = genreNodes.Select(n => new MangaTag(n.InnerText)).ToList();
|
||||||
|
|
||||||
|
HtmlNode statusNode = document.DocumentNode.SelectSingleNode("//ul/li[strong/text() = 'Status: ']/a");
|
||||||
|
string statusText = statusNode?.InnerText ?? "";
|
||||||
|
MangaReleaseStatus releaseStatus = statusText.ToLower() switch
|
||||||
|
{
|
||||||
|
"cancelled" => MangaReleaseStatus.Cancelled,
|
||||||
|
"hiatus" => MangaReleaseStatus.OnHiatus,
|
||||||
|
"complete" => MangaReleaseStatus.Completed,
|
||||||
|
"ongoing" => MangaReleaseStatus.Continuing,
|
||||||
|
_ => MangaReleaseStatus.Unreleased
|
||||||
|
};
|
||||||
|
|
||||||
|
HtmlNode yearNode = document.DocumentNode.SelectSingleNode("//ul/li[strong/text() = 'Released: ']/span");
|
||||||
|
uint year = Convert.ToUInt32(yearNode?.InnerText ?? "0");
|
||||||
|
|
||||||
|
HtmlNode descriptionNode = document.DocumentNode.SelectSingleNode("//ul/li[strong/text() = 'Description']/p");
|
||||||
|
string description = descriptionNode?.InnerText ?? "Undefined";
|
||||||
|
|
||||||
|
HtmlNode[] altTitleNodes = document.DocumentNode
|
||||||
|
.SelectNodes("//ul/li[strong/text() = 'Associated Name(s)']/ul/li")?.ToArray() ?? [];
|
||||||
|
List<MangaAltTitle> altTitles = altTitleNodes.Select(n => new MangaAltTitle("", n.InnerText)).ToList();
|
||||||
|
|
||||||
|
Manga m = new(publicationId, sortName, description, websiteUrl, posterUrl, null, year, null, releaseStatus, -1,
|
||||||
|
this, authors, tags, [], altTitles);
|
||||||
|
return (m, authors, tags, [], altTitles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
|
||||||
|
{
|
||||||
|
return GetMangaFromUrl($"https://{BaseUris[0]}/series/{publicationId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Manga manga, string language = "en")
|
||||||
|
{
|
||||||
|
var requestUrl = $"https://{BaseUris[0]}/series/{manga.MangaConnectorId}/full-chapter-list";
|
||||||
|
var requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, RequestType.Default);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
//Return Chapters ordered by Chapter-Number
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
return [];
|
||||||
|
var chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument);
|
||||||
|
return chapters.Order().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override string[] GetChapterImageUrls(Chapter chapter)
|
||||||
|
{
|
||||||
|
var requestResult = downloadClient.MakeRequest(chapter.Url, RequestType.Default);
|
||||||
|
if (requestResult.htmlDocument is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var document = requestResult.htmlDocument;
|
||||||
|
|
||||||
|
var imageNodes =
|
||||||
|
document.DocumentNode.SelectNodes($"//section[@hx-get='{chapter.Url}/images']/img")?.ToArray() ?? [];
|
||||||
|
var urls = imageNodes.Select(imgNode => imgNode.GetAttributeValue("src", "")).ToArray();
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Chapter> ParseChaptersFromHtml(Manga manga, HtmlDocument document)
|
||||||
|
{
|
||||||
|
var chaptersWrapper = document.DocumentNode.SelectSingleNode("/html/body");
|
||||||
|
|
||||||
|
Regex chapterRex = new(@"(\d+(?:\.\d+)*)");
|
||||||
|
Regex idRex = new(@"https:\/\/weebcentral\.com\/chapters\/(\w*)");
|
||||||
|
|
||||||
|
var ret = chaptersWrapper.Descendants("a").Select(elem =>
|
||||||
|
{
|
||||||
|
var url = elem.GetAttributeValue("href", "") ?? "Undefined";
|
||||||
|
|
||||||
|
if (!url.StartsWith("https://") && !url.StartsWith("http://"))
|
||||||
|
return new Chapter(manga, "", "");
|
||||||
|
|
||||||
|
var idMatch = idRex.Match(url);
|
||||||
|
var id = idMatch.Success ? idMatch.Groups[1].Value : null;
|
||||||
|
|
||||||
|
var chapterNode = elem.SelectSingleNode("span[@class='grow flex items-center gap-2']/span")?.InnerText ??
|
||||||
|
"Undefined";
|
||||||
|
|
||||||
|
var chapterNumberMatch = chapterRex.Match(chapterNode);
|
||||||
|
var chapterNumber = chapterNumberMatch.Success ? chapterNumberMatch.Groups[1].Value : "-1";
|
||||||
|
|
||||||
|
return new Chapter(manga, url, chapterNumber);
|
||||||
|
}).Where(elem => elem.ChapterNumber != String.Empty && elem.Url != string.Empty).ToList();
|
||||||
|
|
||||||
|
ret.Reverse();
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
}
|
10
API/Schema/MangaReleaseStatus.cs
Normal file
10
API/Schema/MangaReleaseStatus.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace API.Schema;
|
||||||
|
|
||||||
|
public enum MangaReleaseStatus : byte
|
||||||
|
{
|
||||||
|
Continuing = 0,
|
||||||
|
Completed = 1,
|
||||||
|
OnHiatus = 2,
|
||||||
|
Cancelled = 3,
|
||||||
|
Unreleased = 4
|
||||||
|
}
|
12
API/Schema/MangaTag.cs
Normal file
12
API/Schema/MangaTag.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Schema;
|
||||||
|
|
||||||
|
[PrimaryKey("Tag")]
|
||||||
|
public class MangaTag(string tag)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string Tag { get; init; } = tag;
|
||||||
|
}
|
28
API/Schema/Notification.cs
Normal file
28
API/Schema/Notification.cs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Schema;
|
||||||
|
|
||||||
|
[PrimaryKey("NotificationId")]
|
||||||
|
public class Notification(string title, string message = "", NotificationUrgency urgency = NotificationUrgency.Normal, DateTime? date = null)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string NotificationId { get; init; } = TokenGen.CreateToken("Notification");
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public NotificationUrgency Urgency { get; init; } = urgency;
|
||||||
|
|
||||||
|
[StringLength(128)]
|
||||||
|
[Required]
|
||||||
|
public string Title { get; init; } = title;
|
||||||
|
|
||||||
|
[StringLength(512)]
|
||||||
|
[Required]
|
||||||
|
public string Message { get; init; } = message;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime Date { get; init; } = date ?? DateTime.UtcNow;
|
||||||
|
|
||||||
|
public Notification() : this("") { }
|
||||||
|
}
|
74
API/Schema/NotificationConnectors/NotificationConnector.cs
Normal file
74
API/Schema/NotificationConnectors/NotificationConnector.cs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace API.Schema.NotificationConnectors;
|
||||||
|
|
||||||
|
[PrimaryKey("Name")]
|
||||||
|
public class NotificationConnector(string name, string url, Dictionary<string, string> headers, string httpMethod, string body)
|
||||||
|
{
|
||||||
|
[StringLength(64)]
|
||||||
|
[Required]
|
||||||
|
public string Name { get; init; } = name;
|
||||||
|
|
||||||
|
[StringLength(2048)]
|
||||||
|
[Required]
|
||||||
|
[Url]
|
||||||
|
public string Url { get; internal set; } = url;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public Dictionary<string, string> Headers { get; internal set; } = headers;
|
||||||
|
|
||||||
|
[StringLength(8)]
|
||||||
|
[Required]
|
||||||
|
public string HttpMethod { get; internal set; } = httpMethod;
|
||||||
|
|
||||||
|
[StringLength(4096)]
|
||||||
|
[Required]
|
||||||
|
public string Body { get; internal set; } = body;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
[NotMapped]
|
||||||
|
private readonly HttpClient Client = new()
|
||||||
|
{
|
||||||
|
DefaultRequestHeaders = { { "User-Agent", TrangaSettings.userAgent } }
|
||||||
|
};
|
||||||
|
|
||||||
|
public void SendNotification(string title, string notificationText)
|
||||||
|
{
|
||||||
|
CustomWebhookFormatProvider formatProvider = new CustomWebhookFormatProvider(title, notificationText);
|
||||||
|
string formattedUrl = string.Format(formatProvider, Url);
|
||||||
|
string formattedBody = string.Format(formatProvider, Body, title, notificationText);
|
||||||
|
Dictionary<string, string> formattedHeaders = Headers.ToDictionary(h => h.Key,
|
||||||
|
h => string.Format(formatProvider, h.Value, title, notificationText));
|
||||||
|
|
||||||
|
HttpRequestMessage request = new(System.Net.Http.HttpMethod.Parse(HttpMethod), formattedUrl);
|
||||||
|
foreach (var (key, value) in formattedHeaders)
|
||||||
|
request.Headers.Add(key, value);
|
||||||
|
request.Content = new StringContent(formattedBody);
|
||||||
|
|
||||||
|
HttpResponseMessage response = Client.Send(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CustomWebhookFormatProvider(string title, string text) : IFormatProvider
|
||||||
|
{
|
||||||
|
public object? GetFormat(Type? formatType)
|
||||||
|
{
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Format(string fmt, object arg, IFormatProvider provider)
|
||||||
|
{
|
||||||
|
if(arg.GetType() != typeof(string))
|
||||||
|
return arg.ToString() ?? string.Empty;
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(fmt);
|
||||||
|
sb.Replace("%title", title);
|
||||||
|
sb.Replace("%text", text);
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
8
API/Schema/NotificationUrgency.cs
Normal file
8
API/Schema/NotificationUrgency.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace API.Schema;
|
||||||
|
|
||||||
|
public enum NotificationUrgency : byte
|
||||||
|
{
|
||||||
|
Low = 1,
|
||||||
|
Normal = 3,
|
||||||
|
High = 5
|
||||||
|
}
|
120
API/Schema/PgsqlContext.cs
Normal file
120
API/Schema/PgsqlContext.cs
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
using API.Schema.Jobs;
|
||||||
|
using API.Schema.LibraryConnectors;
|
||||||
|
using API.Schema.MangaConnectors;
|
||||||
|
using API.Schema.NotificationConnectors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Schema;
|
||||||
|
|
||||||
|
public class PgsqlContext(DbContextOptions<PgsqlContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<Job> Jobs { get; set; }
|
||||||
|
public DbSet<MangaConnector> MangaConnectors { get; set; }
|
||||||
|
public DbSet<Manga> Mangas { get; set; }
|
||||||
|
public DbSet<LocalLibrary> LocalLibraries { get; set; }
|
||||||
|
public DbSet<Chapter> Chapters { get; set; }
|
||||||
|
public DbSet<Author> Authors { get; set; }
|
||||||
|
public DbSet<Link> Links { get; set; }
|
||||||
|
public DbSet<MangaTag> Tags { get; set; }
|
||||||
|
public DbSet<MangaAltTitle> AltTitles { get; set; }
|
||||||
|
public DbSet<LibraryConnector> LibraryConnectors { get; set; }
|
||||||
|
public DbSet<NotificationConnector> NotificationConnectors { get; set; }
|
||||||
|
public DbSet<Notification> Notifications { get; set; }
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<MangaConnector>()
|
||||||
|
.HasDiscriminator(c => c.Name)
|
||||||
|
.HasValue<Global>("Global")
|
||||||
|
.HasValue<AsuraToon>("AsuraToon")
|
||||||
|
.HasValue<Bato>("Bato")
|
||||||
|
.HasValue<MangaHere>("MangaHere")
|
||||||
|
.HasValue<MangaKatana>("MangaKatana")
|
||||||
|
.HasValue<Mangaworld>("Mangaworld")
|
||||||
|
.HasValue<ManhuaPlus>("ManhuaPlus")
|
||||||
|
.HasValue<Weebcentral>("Weebcentral")
|
||||||
|
.HasValue<Manganato>("Manganato")
|
||||||
|
.HasValue<MangaDex>("MangaDex");
|
||||||
|
modelBuilder.Entity<LibraryConnector>()
|
||||||
|
.HasDiscriminator<LibraryType>(l => l.LibraryType)
|
||||||
|
.HasValue<Komga>(LibraryType.Komga)
|
||||||
|
.HasValue<Kavita>(LibraryType.Kavita);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Job>()
|
||||||
|
.HasDiscriminator<JobType>(j => j.JobType)
|
||||||
|
.HasValue<MoveFileOrFolderJob>(JobType.MoveFileOrFolderJob)
|
||||||
|
.HasValue<DownloadAvailableChaptersJob>(JobType.DownloadAvailableChaptersJob)
|
||||||
|
.HasValue<DownloadSingleChapterJob>(JobType.DownloadSingleChapterJob)
|
||||||
|
.HasValue<DownloadMangaCoverJob>(JobType.DownloadMangaCoverJob)
|
||||||
|
.HasValue<UpdateMetadataJob>(JobType.UpdateMetaDataJob)
|
||||||
|
.HasValue<RetrieveChaptersJob>(JobType.RetrieveChaptersJob)
|
||||||
|
.HasValue<UpdateFilesDownloadedJob>(JobType.UpdateFilesDownloadedJob);
|
||||||
|
modelBuilder.Entity<Job>()
|
||||||
|
.HasOne<Job>(j => j.ParentJob)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(j => j.ParentJobId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<Job>()
|
||||||
|
.HasMany<Job>(j => j.DependsOnJobs)
|
||||||
|
.WithMany();
|
||||||
|
modelBuilder.Entity<DownloadAvailableChaptersJob>()
|
||||||
|
.Navigation(dncj => dncj.Manga)
|
||||||
|
.AutoInclude();
|
||||||
|
modelBuilder.Entity<DownloadSingleChapterJob>()
|
||||||
|
.Navigation(dscj => dscj.Chapter)
|
||||||
|
.AutoInclude();
|
||||||
|
modelBuilder.Entity<UpdateMetadataJob>()
|
||||||
|
.Navigation(umj => umj.Manga)
|
||||||
|
.AutoInclude();
|
||||||
|
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.HasOne<MangaConnector>(m => m.MangaConnector)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(m => m.MangaConnectorId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.Navigation(m => m.MangaConnector)
|
||||||
|
.AutoInclude();
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.HasOne<LocalLibrary>(m => m.Library)
|
||||||
|
.WithMany()
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.Navigation(m => m.Library)
|
||||||
|
.AutoInclude();
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.HasMany<Author>(m => m.Authors)
|
||||||
|
.WithMany();
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.Navigation(m => m.Authors)
|
||||||
|
.AutoInclude();
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.HasMany<MangaTag>(m => m.MangaTags)
|
||||||
|
.WithMany();
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.Navigation(m => m.MangaTags)
|
||||||
|
.AutoInclude();
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.HasMany<Link>(m => m.Links)
|
||||||
|
.WithOne()
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.Navigation(m => m.Links)
|
||||||
|
.AutoInclude();
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.HasMany<MangaAltTitle>(m => m.AltTitles)
|
||||||
|
.WithOne()
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<Manga>()
|
||||||
|
.Navigation(m => m.AltTitles)
|
||||||
|
.AutoInclude();
|
||||||
|
modelBuilder.Entity<Chapter>()
|
||||||
|
.HasOne<Manga>(c => c.ParentManga)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.ParentMangaId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<Chapter>()
|
||||||
|
.Navigation(c => c.ParentManga)
|
||||||
|
.AutoInclude();
|
||||||
|
}
|
||||||
|
}
|
37
API/TokenGen.cs
Normal file
37
API/TokenGen.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace API;
|
||||||
|
|
||||||
|
public static class TokenGen
|
||||||
|
{
|
||||||
|
private const int MinimumLength = 32;
|
||||||
|
private const int MaximumLength = 64;
|
||||||
|
private const string Chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
|
||||||
|
public static string CreateToken(Type t, params string[] identifiers) => CreateToken(t.Name, identifiers);
|
||||||
|
|
||||||
|
public static string CreateToken(string prefix, params string[] identifiers)
|
||||||
|
{
|
||||||
|
if (prefix.Length + 1 >= MaximumLength - MinimumLength)
|
||||||
|
throw new ArgumentException("Prefix to long to create Token of meaningful length.");
|
||||||
|
|
||||||
|
int tokenLength = MaximumLength - prefix.Length - 1;
|
||||||
|
|
||||||
|
if (identifiers.Length == 0)
|
||||||
|
{
|
||||||
|
// No identifier, just create a random token
|
||||||
|
byte[] rng = new byte[tokenLength];
|
||||||
|
RandomNumberGenerator.Create().GetBytes(rng);
|
||||||
|
string key = new(rng.Select(b => Chars[b % Chars.Length]).ToArray());
|
||||||
|
key = string.Join('-', prefix, key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifier provided, create a token based on the identifier hashed
|
||||||
|
byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes(string.Join("", identifiers)));
|
||||||
|
string token = Convert.ToHexStringLower(hash);
|
||||||
|
|
||||||
|
return string.Join('-', prefix, token);
|
||||||
|
}
|
||||||
|
}
|
218
API/Tranga.cs
Normal file
218
API/Tranga.cs
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
using API.Schema;
|
||||||
|
using API.Schema.Jobs;
|
||||||
|
using API.Schema.MangaConnectors;
|
||||||
|
using API.Schema.NotificationConnectors;
|
||||||
|
using log4net;
|
||||||
|
using log4net.Config;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API;
|
||||||
|
|
||||||
|
public static class Tranga
|
||||||
|
{
|
||||||
|
public static Thread NotificationSenderThread { get; } = new (NotificationSender);
|
||||||
|
public static Thread JobStarterThread { get; } = new (JobStarter);
|
||||||
|
private static readonly Dictionary<Thread, Job> RunningJobs = new();
|
||||||
|
private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga));
|
||||||
|
|
||||||
|
internal static void StartLogger()
|
||||||
|
{
|
||||||
|
BasicConfigurator.Configure();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void NotificationSender(object? pgsqlContext)
|
||||||
|
{
|
||||||
|
if(pgsqlContext is null) return;
|
||||||
|
PgsqlContext context = (PgsqlContext)pgsqlContext;
|
||||||
|
|
||||||
|
IQueryable<Notification> staleNotifications = context.Notifications.Where(n => n.Urgency < NotificationUrgency.Normal);
|
||||||
|
context.Notifications.RemoveRange(staleNotifications);
|
||||||
|
context.SaveChanges();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
SendNotifications(context, NotificationUrgency.High);
|
||||||
|
SendNotifications(context, NotificationUrgency.Normal);
|
||||||
|
SendNotifications(context, NotificationUrgency.Low);
|
||||||
|
|
||||||
|
context.SaveChanges();
|
||||||
|
Thread.Sleep(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SendNotifications(PgsqlContext context, NotificationUrgency urgency)
|
||||||
|
{
|
||||||
|
List<Notification> notifications = context.Notifications.Where(n => n.Urgency == urgency).ToList();
|
||||||
|
if (notifications.Any())
|
||||||
|
{
|
||||||
|
DateTime max = notifications.MaxBy(n => n.Date)!.Date;
|
||||||
|
if (DateTime.UtcNow.Subtract(max) > TrangaSettings.NotificationUrgencyDelay(urgency))
|
||||||
|
{
|
||||||
|
foreach (NotificationConnector notificationConnector in context.NotificationConnectors)
|
||||||
|
{
|
||||||
|
foreach (Notification notification in notifications)
|
||||||
|
notificationConnector.SendNotification(notification.Title, notification.Message);
|
||||||
|
}
|
||||||
|
context.Notifications.RemoveRange(notifications);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void JobStarter(object? serviceProviderObj)
|
||||||
|
{
|
||||||
|
if(serviceProviderObj is null) return;
|
||||||
|
IServiceProvider serviceProvider = (IServiceProvider)serviceProviderObj;
|
||||||
|
using IServiceScope scope = serviceProvider.CreateScope();
|
||||||
|
PgsqlContext? context = scope.ServiceProvider.GetService<PgsqlContext>();
|
||||||
|
if (context is null) return;
|
||||||
|
|
||||||
|
string TRANGA =
|
||||||
|
"\n\n _______ \n|_ _|.----..---.-..-----..-----..---.-.\n | | | _|| _ || || _ || _ |\n |___| |__| |___._||__|__||___ ||___._|\n |_____| \n\n";
|
||||||
|
Log.Info(TRANGA);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
List<Job> completedJobs = context.Jobs.Where(j => j.state >= JobState.Completed).ToList();
|
||||||
|
foreach (Job job in completedJobs)
|
||||||
|
if (job.RecurrenceMs <= 0)
|
||||||
|
context.Jobs.Remove(job);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (job.state >= JobState.Failed)
|
||||||
|
job.Enabled = false;
|
||||||
|
else
|
||||||
|
job.state = JobState.Waiting;
|
||||||
|
job.LastExecution = DateTime.UtcNow;
|
||||||
|
context.Jobs.Update(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Job> runJobs = context.Jobs.Where(j => j.state <= JobState.Running && j.Enabled == true).ToList()
|
||||||
|
.Where(j => j.NextExecution < DateTime.UtcNow).ToList();
|
||||||
|
foreach (Job job in OrderJobs(runJobs, context))
|
||||||
|
{
|
||||||
|
// If the job is already running, skip it
|
||||||
|
if (RunningJobs.Values.Any(j => j.JobId == job.JobId)) continue;
|
||||||
|
|
||||||
|
if (job is DownloadAvailableChaptersJob dncj)
|
||||||
|
{
|
||||||
|
if (RunningJobs.Values.Any(j =>
|
||||||
|
j is DownloadAvailableChaptersJob rdncj &&
|
||||||
|
rdncj.Manga?.MangaConnector == dncj.Manga?.MangaConnector))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (job is DownloadSingleChapterJob dscj)
|
||||||
|
{
|
||||||
|
if (RunningJobs.Values.Any(j =>
|
||||||
|
j is DownloadSingleChapterJob rdscj && rdscj.Chapter?.ParentManga?.MangaConnector ==
|
||||||
|
dscj.Chapter?.ParentManga?.MangaConnector))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread t = new(() =>
|
||||||
|
{
|
||||||
|
IEnumerable<Job> newJobs = job.Run(serviceProvider);
|
||||||
|
});
|
||||||
|
RunningJobs.Add(t, job);
|
||||||
|
t.Start();
|
||||||
|
context.Jobs.Update(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
(Thread, Job)[] removeFromThreadsList = RunningJobs.Where(t => !t.Key.IsAlive)
|
||||||
|
.Select(t => (t.Key, t.Value)).ToArray();
|
||||||
|
foreach ((Thread thread, Job job) thread in removeFromThreadsList)
|
||||||
|
{
|
||||||
|
RunningJobs.Remove(thread.thread);
|
||||||
|
if(context.Jobs.Find(thread.job.JobId) is not null)
|
||||||
|
context.Jobs.Update(thread.job);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.SaveChanges();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException e)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
Thread.Sleep(TrangaSettings.startNewJobTimeoutMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Job> OrderJobs(List<Job> jobs, PgsqlContext context)
|
||||||
|
{
|
||||||
|
Dictionary<JobType, List<Job>> jobsByType = new();
|
||||||
|
foreach (Job job in jobs)
|
||||||
|
if(!jobsByType.TryAdd(job.JobType, [job]))
|
||||||
|
jobsByType[job.JobType].Add(job);
|
||||||
|
|
||||||
|
IEnumerable<Job> ret = new List<Job>();
|
||||||
|
if(jobsByType.ContainsKey(JobType.MoveMangaLibraryJob))
|
||||||
|
ret = ret.Concat(jobsByType[JobType.MoveMangaLibraryJob]);
|
||||||
|
if(jobsByType.ContainsKey(JobType.MoveFileOrFolderJob))
|
||||||
|
ret = ret.Concat(jobsByType[JobType.MoveFileOrFolderJob]);
|
||||||
|
if(jobsByType.ContainsKey(JobType.DownloadMangaCoverJob))
|
||||||
|
ret = ret.Concat(jobsByType[JobType.DownloadMangaCoverJob]);
|
||||||
|
if(jobsByType.ContainsKey(JobType.UpdateFilesDownloadedJob))
|
||||||
|
ret = ret.Concat(jobsByType[JobType.UpdateFilesDownloadedJob]);
|
||||||
|
|
||||||
|
Dictionary<MangaConnector, List<Job>> metadataJobsByConnector = new();
|
||||||
|
if (jobsByType.ContainsKey(JobType.DownloadAvailableChaptersJob))
|
||||||
|
{
|
||||||
|
foreach (DownloadAvailableChaptersJob job in jobsByType[JobType.DownloadAvailableChaptersJob])
|
||||||
|
{
|
||||||
|
Manga manga = job.Manga ?? context.Mangas.Find(job.MangaId)!;
|
||||||
|
MangaConnector connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId)!;
|
||||||
|
if(!metadataJobsByConnector.TryAdd(connector, [job]))
|
||||||
|
metadataJobsByConnector[connector].Add(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jobsByType.ContainsKey(JobType.UpdateMetaDataJob))
|
||||||
|
{
|
||||||
|
foreach (UpdateMetadataJob job in jobsByType[JobType.UpdateMetaDataJob])
|
||||||
|
{
|
||||||
|
Manga manga = job.Manga ?? context.Mangas.Find(job.MangaId)!;
|
||||||
|
MangaConnector connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId)!;
|
||||||
|
if(!metadataJobsByConnector.TryAdd(connector, [job]))
|
||||||
|
metadataJobsByConnector[connector].Add(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jobsByType.ContainsKey(JobType.RetrieveChaptersJob))
|
||||||
|
{
|
||||||
|
foreach (RetrieveChaptersJob job in jobsByType[JobType.RetrieveChaptersJob])
|
||||||
|
{
|
||||||
|
Manga manga = job.Manga ?? context.Mangas.Find(job.MangaId)!;
|
||||||
|
MangaConnector connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId)!;
|
||||||
|
if(!metadataJobsByConnector.TryAdd(connector, [job]))
|
||||||
|
metadataJobsByConnector[connector].Add(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (List<Job> metadataJobs in metadataJobsByConnector.Values)
|
||||||
|
ret = ret.Append(metadataJobs.MinBy(j => j.NextExecution))!;
|
||||||
|
|
||||||
|
if (jobsByType.ContainsKey(JobType.DownloadSingleChapterJob))
|
||||||
|
{
|
||||||
|
|
||||||
|
Dictionary<MangaConnector, List<DownloadSingleChapterJob>> downloadJobsByConnector = new();
|
||||||
|
foreach (DownloadSingleChapterJob job in jobsByType[JobType.DownloadSingleChapterJob])
|
||||||
|
{
|
||||||
|
Chapter chapter = job.Chapter ?? context.Chapters.Find(job.ChapterId)!;
|
||||||
|
Manga manga = chapter.ParentManga ?? context.Mangas.Find(chapter.ParentMangaId)!;
|
||||||
|
MangaConnector connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId)!;
|
||||||
|
|
||||||
|
if(!downloadJobsByConnector.TryAdd(connector, [job]))
|
||||||
|
downloadJobsByConnector[connector].Add(job);
|
||||||
|
}
|
||||||
|
//From all jobs select those that are supposed to be executed soonest, then select the minimum chapternumber
|
||||||
|
foreach (List<DownloadSingleChapterJob> downloadJobs in downloadJobsByConnector.Values)
|
||||||
|
ret = ret.Append(
|
||||||
|
downloadJobs.Where(j => j.NextExecution == downloadJobs
|
||||||
|
.MinBy(mj => mj.NextExecution)!.NextExecution)
|
||||||
|
.MinBy(j => j.Chapter ?? context.Chapters.Find(j.ChapterId)!))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
}
|
191
API/TrangaSettings.cs
Normal file
191
API/TrangaSettings.cs
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using API.MangaDownloadClients;
|
||||||
|
using API.Schema;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using static System.IO.UnixFileMode;
|
||||||
|
|
||||||
|
namespace API;
|
||||||
|
|
||||||
|
public static class TrangaSettings
|
||||||
|
{
|
||||||
|
public static string downloadLocation { get; private set; } = (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(Directory.GetCurrentDirectory(), "Downloads"));
|
||||||
|
public static string workingDirectory { get; private set; } = Path.Join(RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/usr/share" : Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "tranga-api");
|
||||||
|
[JsonIgnore]
|
||||||
|
internal static readonly string DefaultUserAgent = $"Tranga ({Enum.GetName(Environment.OSVersion.Platform)}; {(Environment.Is64BitOperatingSystem ? "x64" : "")}) / 1.0";
|
||||||
|
public static string userAgent { get; private set; } = DefaultUserAgent;
|
||||||
|
public static int compression{ get; private set; } = 40;
|
||||||
|
public static bool bwImages { get; private set; } = false;
|
||||||
|
[JsonIgnore]
|
||||||
|
public static string settingsFilePath => Path.Join(workingDirectory, "settings.json");
|
||||||
|
[JsonIgnore]
|
||||||
|
public static string coverImageCache => Path.Join(workingDirectory, "imageCache");
|
||||||
|
public static bool aprilFoolsMode { get; private set; } = true;
|
||||||
|
public static int startNewJobTimeoutMs { get; private set; } = 1000;
|
||||||
|
[JsonIgnore]
|
||||||
|
internal static readonly Dictionary<RequestType, int> DefaultRequestLimits = new ()
|
||||||
|
{
|
||||||
|
{RequestType.MangaInfo, 250},
|
||||||
|
{RequestType.MangaDexFeed, 250},
|
||||||
|
{RequestType.MangaDexImage, 40},
|
||||||
|
{RequestType.MangaImage, 60},
|
||||||
|
{RequestType.MangaCover, 250},
|
||||||
|
{RequestType.Default, 60}
|
||||||
|
};
|
||||||
|
public static Dictionary<RequestType, int> requestLimits { get; private set; } = DefaultRequestLimits;
|
||||||
|
|
||||||
|
public static TimeSpan NotificationUrgencyDelay(NotificationUrgency urgency) => urgency switch
|
||||||
|
{
|
||||||
|
NotificationUrgency.High => TimeSpan.Zero,
|
||||||
|
NotificationUrgency.Normal => TimeSpan.FromMinutes(5),
|
||||||
|
NotificationUrgency.Low => TimeSpan.FromMinutes(10),
|
||||||
|
_ => TimeSpan.FromHours(1)
|
||||||
|
}; //TODO make this a setting?
|
||||||
|
|
||||||
|
public static void Load()
|
||||||
|
{
|
||||||
|
if(File.Exists(settingsFilePath))
|
||||||
|
Deserialize(File.ReadAllText(settingsFilePath));
|
||||||
|
else return;
|
||||||
|
|
||||||
|
Directory.CreateDirectory(downloadLocation);
|
||||||
|
ExportSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void UpdateAprilFoolsMode(bool enabled)
|
||||||
|
{
|
||||||
|
aprilFoolsMode = enabled;
|
||||||
|
ExportSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void UpdateCompressImages(int value)
|
||||||
|
{
|
||||||
|
compression = int.Clamp(value, 1, 100);
|
||||||
|
ExportSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void UpdateBwImages(bool enabled)
|
||||||
|
{
|
||||||
|
bwImages = enabled;
|
||||||
|
ExportSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void UpdateDownloadLocation(string newPath, bool moveFiles = true)
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
Directory.CreateDirectory(newPath, GroupRead | GroupWrite | None | OtherRead | OtherWrite | UserRead | UserWrite);
|
||||||
|
else
|
||||||
|
Directory.CreateDirectory(newPath);
|
||||||
|
|
||||||
|
if (moveFiles)
|
||||||
|
MoveContentsOfDirectoryTo(TrangaSettings.downloadLocation, newPath);
|
||||||
|
|
||||||
|
TrangaSettings.downloadLocation = newPath;
|
||||||
|
ExportSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MoveContentsOfDirectoryTo(string oldDir, string newDir)
|
||||||
|
{
|
||||||
|
string[] directoryPaths = Directory.GetDirectories(oldDir);
|
||||||
|
string[] filePaths = Directory.GetFiles(oldDir);
|
||||||
|
foreach (string file in filePaths)
|
||||||
|
{
|
||||||
|
string newPath = Path.Join(newDir, Path.GetFileName(file));
|
||||||
|
File.Move(file, newPath, true);
|
||||||
|
}
|
||||||
|
foreach(string directory in directoryPaths)
|
||||||
|
{
|
||||||
|
string? dirName = Path.GetDirectoryName(directory);
|
||||||
|
if(dirName is null)
|
||||||
|
continue;
|
||||||
|
string newPath = Path.Join(newDir, dirName);
|
||||||
|
if(Directory.Exists(newPath))
|
||||||
|
MoveContentsOfDirectoryTo(directory, newPath);
|
||||||
|
else
|
||||||
|
Directory.Move(directory, newPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void UpdateUserAgent(string? customUserAgent)
|
||||||
|
{
|
||||||
|
userAgent = customUserAgent ?? DefaultUserAgent;
|
||||||
|
ExportSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void UpdateRequestLimit(RequestType requestType, int newLimit)
|
||||||
|
{
|
||||||
|
requestLimits[requestType] = newLimit;
|
||||||
|
ExportSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ResetRequestLimits()
|
||||||
|
{
|
||||||
|
requestLimits = DefaultRequestLimits;
|
||||||
|
ExportSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ExportSettings()
|
||||||
|
{
|
||||||
|
if (File.Exists(settingsFilePath))
|
||||||
|
{
|
||||||
|
while(IsFileInUse(settingsFilePath))
|
||||||
|
Thread.Sleep(100);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
Directory.CreateDirectory(new FileInfo(settingsFilePath).DirectoryName!);
|
||||||
|
File.WriteAllText(settingsFilePath, Serialize());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsFileInUse(string filePath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using FileStream stream = new (filePath, FileMode.Open, FileAccess.Read, FileShare.None);
|
||||||
|
stream.Close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JObject AsJObject()
|
||||||
|
{
|
||||||
|
JObject jobj = new JObject();
|
||||||
|
jobj.Add("downloadLocation", JToken.FromObject(downloadLocation));
|
||||||
|
jobj.Add("workingDirectory", JToken.FromObject(workingDirectory));
|
||||||
|
jobj.Add("userAgent", JToken.FromObject(userAgent));
|
||||||
|
jobj.Add("aprilFoolsMode", JToken.FromObject(aprilFoolsMode));
|
||||||
|
jobj.Add("requestLimits", JToken.FromObject(requestLimits));
|
||||||
|
jobj.Add("compression", JToken.FromObject(compression));
|
||||||
|
jobj.Add("bwImages", JToken.FromObject(bwImages));
|
||||||
|
jobj.Add("startNewJobTimeoutMs", JToken.FromObject(startNewJobTimeoutMs));
|
||||||
|
return jobj;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Serialize() => AsJObject().ToString();
|
||||||
|
|
||||||
|
public static void Deserialize(string serialized)
|
||||||
|
{
|
||||||
|
JObject jobj = JObject.Parse(serialized);
|
||||||
|
if (jobj.TryGetValue("downloadLocation", out JToken? dl))
|
||||||
|
downloadLocation = dl.Value<string>()!;
|
||||||
|
if (jobj.TryGetValue("workingDirectory", out JToken? wd))
|
||||||
|
workingDirectory = wd.Value<string>()!;
|
||||||
|
if (jobj.TryGetValue("userAgent", out JToken? ua))
|
||||||
|
userAgent = ua.Value<string>()!;
|
||||||
|
if (jobj.TryGetValue("aprilFoolsMode", out JToken? afm))
|
||||||
|
aprilFoolsMode = afm.Value<bool>()!;
|
||||||
|
if (jobj.TryGetValue("requestLimits", out JToken? rl))
|
||||||
|
requestLimits = rl.ToObject<Dictionary<RequestType, int>>()!;
|
||||||
|
if (jobj.TryGetValue("compression", out JToken? ci))
|
||||||
|
compression = ci.Value<int>()!;
|
||||||
|
if (jobj.TryGetValue("bwImages", out JToken? bwi))
|
||||||
|
bwImages = bwi.Value<bool>()!;
|
||||||
|
if (jobj.TryGetValue("startNewJobTimeoutMs", out JToken? snjt))
|
||||||
|
startNewJobTimeoutMs = snjt.Value<int>()!;
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Error",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Error",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
50
Dockerfile
50
Dockerfile
@ -1,14 +1,42 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
ARG DOTNET=9.0
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
|
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:$DOTNET AS base
|
||||||
WORKDIR /src
|
|
||||||
COPY . /src/
|
|
||||||
RUN dotnet restore Tranga-API/Tranga-API.csproj
|
|
||||||
RUN dotnet publish -c Release -o /publish
|
|
||||||
|
|
||||||
#FROM mcr.microsoft.com/dotnet/aspnet:7.0 as runtime
|
|
||||||
FROM glax/tranga-base:latest as runtime
|
|
||||||
WORKDIR /publish
|
WORKDIR /publish
|
||||||
COPY --from=build-env /publish .
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||||
EXPOSE 80
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
ENTRYPOINT ["dotnet", "/publish/Tranga-API.dll"]
|
ENV XDG_CONFIG_HOME=/tmp/.chromium
|
||||||
|
ENV XDG_CACHE_HOME=/tmp/.chromium
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y libx11-6 libx11-xcb1 libatk1.0-0 libgtk-3-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 libxshmfence1 libnss3 chromium \
|
||||||
|
&& apt-get autopurge -y \
|
||||||
|
&& apt-get autoclean -y
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:$DOTNET AS build-env
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY Tranga.sln /src
|
||||||
|
COPY API/API.csproj /src/API/API.csproj
|
||||||
|
RUN dotnet restore /src/Tranga.sln
|
||||||
|
|
||||||
|
COPY . /src/
|
||||||
|
RUN dotnet publish -c Release --property:OutputPath=/publish -maxcpucount:1
|
||||||
|
|
||||||
|
FROM --platform=$TARGETPLATFORM base AS runtime
|
||||||
|
EXPOSE 6531
|
||||||
|
ARG UNAME=tranga
|
||||||
|
ARG UID=1000
|
||||||
|
ARG GID=1000
|
||||||
|
RUN groupadd -g $GID -o $UNAME \
|
||||||
|
&& useradd -m -u $UID -g $GID -o -s /bin/bash $UNAME \
|
||||||
|
&& mkdir /usr/share/tranga-api \
|
||||||
|
&& mkdir /Manga \
|
||||||
|
&& chown 1000:1000 /usr/share/tranga-api \
|
||||||
|
&& chown 1000:1000 /Manga
|
||||||
|
USER $UNAME
|
||||||
|
|
||||||
|
WORKDIR /publish
|
||||||
|
COPY --chown=1000:1000 --from=build-env /publish .
|
||||||
|
USER 0
|
||||||
|
ENTRYPOINT ["dotnet", "/publish/API.dll"]
|
||||||
|
CMD ["-f", "-c", "-l", "/usr/share/tranga-api/logs"]
|
@ -1,4 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:7.0 as runtime
|
|
||||||
WORKDIR /publish
|
|
||||||
RUN apt-get update && apt-get install -y libx11-6 libx11-xcb1 libatk1.0-0 libgtk-3-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 libxshmfence1 libnss3
|
|
@ -1,32 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Logging;
|
|
||||||
|
|
||||||
public class FileLogger : LoggerBase
|
|
||||||
{
|
|
||||||
private string logFilePath { get; }
|
|
||||||
private const int MaxNumberOfLogFiles = 5;
|
|
||||||
|
|
||||||
public FileLogger(string logFilePath, TextWriter? stdOut, Encoding? encoding = null) : base (stdOut, encoding)
|
|
||||||
{
|
|
||||||
this.logFilePath = logFilePath;
|
|
||||||
|
|
||||||
//Remove oldest logfile if more than MaxNumberOfLogFiles
|
|
||||||
string parentFolderPath = Path.GetDirectoryName(logFilePath)!;
|
|
||||||
for (int fileCount = new DirectoryInfo(parentFolderPath).EnumerateFiles().Count(); fileCount > MaxNumberOfLogFiles - 1; fileCount--) //-1 because we create own logfile later
|
|
||||||
File.Delete(new DirectoryInfo(parentFolderPath).EnumerateFiles().MinBy(file => file.LastWriteTime)!.FullName);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Write(LogMessage logMessage)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.AppendAllText(logFilePath, logMessage.ToString());
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
stdOut?.WriteLine(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Logging;
|
|
||||||
|
|
||||||
public class FormattedConsoleLogger : LoggerBase
|
|
||||||
{
|
|
||||||
|
|
||||||
public FormattedConsoleLogger(TextWriter? stdOut, Encoding? encoding = null) : base(stdOut, encoding)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Write(LogMessage message)
|
|
||||||
{
|
|
||||||
//Nothing to do yet
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,63 +0,0 @@
|
|||||||
using System.Net.Mime;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Logging;
|
|
||||||
|
|
||||||
public class Logger : TextWriter
|
|
||||||
{
|
|
||||||
public override Encoding Encoding { get; }
|
|
||||||
public enum LoggerType
|
|
||||||
{
|
|
||||||
FileLogger,
|
|
||||||
ConsoleLogger
|
|
||||||
}
|
|
||||||
|
|
||||||
private FileLogger? _fileLogger;
|
|
||||||
private FormattedConsoleLogger? _formattedConsoleLogger;
|
|
||||||
private MemoryLogger _memoryLogger;
|
|
||||||
private TextWriter? stdOut;
|
|
||||||
|
|
||||||
public Logger(LoggerType[] enabledLoggers, TextWriter? stdOut, Encoding? encoding, string? logFilePath)
|
|
||||||
{
|
|
||||||
this.Encoding = encoding ?? Encoding.ASCII;
|
|
||||||
this.stdOut = stdOut ?? null;
|
|
||||||
if (enabledLoggers.Contains(LoggerType.FileLogger) && logFilePath is not null)
|
|
||||||
_fileLogger = new FileLogger(logFilePath, null, encoding);
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_fileLogger = null;
|
|
||||||
throw new ArgumentException($"logFilePath can not be null for LoggerType {LoggerType.FileLogger}");
|
|
||||||
}
|
|
||||||
_formattedConsoleLogger = enabledLoggers.Contains(LoggerType.ConsoleLogger) ? new FormattedConsoleLogger(null, encoding) : null;
|
|
||||||
_memoryLogger = new MemoryLogger(null, encoding);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void WriteLine(string caller, string? value)
|
|
||||||
{
|
|
||||||
value = value is null ? Environment.NewLine : string.Concat(value, Environment.NewLine);
|
|
||||||
|
|
||||||
Write(caller, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(string caller, string? value)
|
|
||||||
{
|
|
||||||
if (value is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_fileLogger?.Write(caller, value);
|
|
||||||
_formattedConsoleLogger?.Write(caller, value);
|
|
||||||
|
|
||||||
_memoryLogger.Write(caller, value);
|
|
||||||
stdOut?.Write(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string[] Tail(uint? lines)
|
|
||||||
{
|
|
||||||
return _memoryLogger.Tail(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string[] GetNewLines()
|
|
||||||
{
|
|
||||||
return _memoryLogger.GetNewLines();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,58 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Logging;
|
|
||||||
|
|
||||||
public abstract class LoggerBase : TextWriter
|
|
||||||
{
|
|
||||||
public override Encoding Encoding { get; }
|
|
||||||
protected TextWriter? stdOut { get; }
|
|
||||||
|
|
||||||
public LoggerBase(TextWriter? stdOut, Encoding? encoding = null)
|
|
||||||
{
|
|
||||||
this.Encoding = encoding ?? Encoding.ASCII;
|
|
||||||
this.stdOut = stdOut;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void WriteLine(string caller, string? value)
|
|
||||||
{
|
|
||||||
value = value is null ? Environment.NewLine : string.Join(value, Environment.NewLine);
|
|
||||||
|
|
||||||
LogMessage message = new LogMessage(DateTime.Now, caller, value);
|
|
||||||
|
|
||||||
Write(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(string caller, string? value)
|
|
||||||
{
|
|
||||||
if (value is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
LogMessage message = new LogMessage(DateTime.Now, caller, value);
|
|
||||||
|
|
||||||
stdOut?.Write(message.ToString());
|
|
||||||
|
|
||||||
Write(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract void Write(LogMessage message);
|
|
||||||
|
|
||||||
public class LogMessage
|
|
||||||
{
|
|
||||||
public DateTime logTime { get; }
|
|
||||||
public string caller { get; }
|
|
||||||
public string value { get; }
|
|
||||||
|
|
||||||
public LogMessage(DateTime now, string caller, string value)
|
|
||||||
{
|
|
||||||
this.logTime = now;
|
|
||||||
this.caller = caller;
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
string dateTimeString = $"{logTime.ToShortDateString()} {logTime.ToLongTimeString()}";
|
|
||||||
return $"[{dateTimeString}] {caller.Split(new char[]{'.','+'}).Last(),15} | {value}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
@ -1,57 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Logging;
|
|
||||||
|
|
||||||
public class MemoryLogger : LoggerBase
|
|
||||||
{
|
|
||||||
private readonly SortedList<DateTime, LogMessage> _logMessages = new();
|
|
||||||
private int _lastLogMessageIndex = 0;
|
|
||||||
|
|
||||||
public MemoryLogger(TextWriter? stdOut, Encoding? encoding = null) : base(stdOut, encoding)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Write(LogMessage value)
|
|
||||||
{
|
|
||||||
_logMessages.Add(value.logTime, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string[] GetLogMessage()
|
|
||||||
{
|
|
||||||
return Tail(Convert.ToUInt32(_logMessages.Count));
|
|
||||||
}
|
|
||||||
|
|
||||||
public string[] Tail(uint? length)
|
|
||||||
{
|
|
||||||
int retLength;
|
|
||||||
if (length is null || length > _logMessages.Count)
|
|
||||||
retLength = _logMessages.Count;
|
|
||||||
else
|
|
||||||
retLength = (int)length;
|
|
||||||
|
|
||||||
string[] ret = new string[retLength];
|
|
||||||
|
|
||||||
for (int retIndex = 0; retIndex < ret.Length; retIndex++)
|
|
||||||
{
|
|
||||||
ret[retIndex] = _logMessages.GetValueAtIndex(_logMessages.Count - retLength + retIndex).ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
_lastLogMessageIndex = _logMessages.Count - 1;
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string[] GetNewLines()
|
|
||||||
{
|
|
||||||
int logMessageCount = _logMessages.Count;
|
|
||||||
string[] ret = new string[logMessageCount - _lastLogMessageIndex];
|
|
||||||
|
|
||||||
for (int retIndex = 0; retIndex < ret.Length; retIndex++)
|
|
||||||
{
|
|
||||||
ret[retIndex] = _logMessages.GetValueAtIndex(_lastLogMessageIndex + retIndex).ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
_lastLogMessageIndex = logMessageCount;
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
}
|
|
232
README.md
232
README.md
@ -1,164 +1,188 @@
|
|||||||
<!-- PROJECT SHIELDS -->
|
<span id="readme-top"></span>
|
||||||
<!--
|
|
||||||
*** I'm using markdown "reference style" links for readability.
|
|
||||||
*** Reference links are enclosed in brackets [ ] instead of parentheses ( ).
|
|
||||||
*** See the bottom of this document for the declaration of the reference variables
|
|
||||||
*** for contributors-url, forks-url, etc. This is an optional, concise syntax you may use.
|
|
||||||
*** https://www.markdownguide.org/basic-syntax/#reference-style-links
|
|
||||||
-->
|
|
||||||
|
|
||||||
<!-- PROJECT LOGO -->
|
|
||||||
<br />
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<h3 align="center">Tranga</h3>
|
<h1 align="center">Tranga v2</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Automatic Manga and Metadata downloader
|
Automatic Manga and Metadata downloader
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga/master?label=master"></th>
|
||||||
|
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga%2Factions%2Fworkflows%2Fdocker-image-master.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga/cuttingedge?label=cuttingedge"></th>
|
||||||
|
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga%2Factions%2Fworkflows%2Fdocker-image-cuttingedge.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga/postgres-Server-V2?label=postgres-Server-V2"></th>
|
||||||
|
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga%2Factions%2Fworkflows%2Fdocker-image-serverv2.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- TABLE OF CONTENTS -->
|
|
||||||
<details>
|
|
||||||
<summary>Table of Contents</summary>
|
|
||||||
<ol>
|
|
||||||
<li>
|
|
||||||
<a href="#about-the-project">About The Project</a>
|
|
||||||
<ul>
|
|
||||||
<li><a href="#built-with">Built With</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#screenshots">Screenshots</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#getting-started">Getting Started</a>
|
|
||||||
<ul>
|
|
||||||
<li><a href="#prerequisites">Prerequisites</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li><a href="#roadmap">Roadmap</a></li>
|
|
||||||
<li><a href="#contributing">Contributing</a></li>
|
|
||||||
<li><a href="#license">License</a></li>
|
|
||||||
<li><a href="#acknowledgments">Acknowledgments</a></li>
|
|
||||||
</ol>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- ABOUT THE PROJECT -->
|
<!-- ABOUT THE PROJECT -->
|
||||||
## About The Project
|
## About The Project
|
||||||
|
|
||||||
Tranga can download Chapters and Metadata from Scanlation sites such as
|
Tranga can download Chapters and Metadata from "Scanlation" sites such as
|
||||||
|
|
||||||
- [MangaDex.org](https://mangadex.org/)
|
- [MangaDex.org](https://mangadex.org/) (Multilingual)
|
||||||
- [Manganato.com](https://manganato.com/)
|
- [Manganato.gg](https://manganato.com/) (en) (or natomanga.com, mangakakalot, nelomanga, ...)
|
||||||
- [Mangasee](https://mangasee123.com/)
|
- [MangaKatana.com](https://mangakatana.com) (en)
|
||||||
|
- [Mangaworld.bz](https://www.mangaworld.bz/) (it)
|
||||||
|
- [Bato.to](https://bato.to/v3x) (en)
|
||||||
|
- [ManhuaPlus](https://manhuaplus.org/) (en)
|
||||||
|
- [MangaHere](https://www.mangahere.cc/) (en)
|
||||||
|
- [Weebcentral](https://weebcentral.com) (en)
|
||||||
|
- [Webtoons](https://www.webtoons.com/en/) (en)
|
||||||
|
- ❓ Open an [issue](https://github.com/C9Glax/tranga/issues/new?assignees=&labels=New+Connector&projects=&template=new_connector.yml&title=%5BNew+Connector%5D%3A+)
|
||||||
|
|
||||||
and automatically start updates in [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/) to import them.
|
and trigger a library-scan with [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/).
|
||||||
|
Notifications can be sent to your devices using [Gotify](https://gotify.net/), [LunaSea](https://www.lunasea.app/) or [Ntfy](https://ntfy.sh/
|
||||||
|
), or any other service that can use REST Webhooks.
|
||||||
|
|
||||||
### Inspiration:
|
## What this program does and does *not* do
|
||||||
|
|
||||||
|
Tranga (the program in this repository) is a REST-API and worker in one. Meaning it will open a network-port
|
||||||
|
to listen for requests, and then work through these. Requests include searches for Manga, starting "Jobs" such
|
||||||
|
as downloading available chapters, creating a monitoring job (that will periodically do the aforementioned),
|
||||||
|
update metadata, and more.
|
||||||
|
|
||||||
|
This repository *does not* include a frontend. A frontend can take many forms, such as a website:
|
||||||
|
|
||||||
|
[tranga-website](https://github.com/C9Glax/tranga-website)
|
||||||
|
|
||||||
|
When downloading a chapter (meaning the images that make-up the manga) from a Scanlation-Website, Tranga will
|
||||||
|
additionally try and scrape Metadata from the same website ~~or enhance it from third-party sources~~
|
||||||
|
(tbd https://github.com/C9Glax/tranga/issues/280).
|
||||||
|
Downloaded images can be jpeg-compressed and/or made black and white to save on diskspace
|
||||||
|
(measured at least a 50% reduction in size, without a significant loss of quality).
|
||||||
|
|
||||||
|
Tranga will then package the contents of each chapter in a `.cbz`-archive and place it in a common folder per Manga.
|
||||||
|
If specified, Tranga will then notify library-Managers such as [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/) to trigger a scan for new
|
||||||
|
chapters. Tranga can also send notifications to your devices via third-party services such as [Gotify](https://gotify.net/), [LunaSea](https://www.lunasea.app/) or [Ntfy](https://ntfy.sh/
|
||||||
|
).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
This repository has no frontend, however checkout [tranga-website](https://github.com/C9Glax/tranga-website) for a default!
|
||||||
|
|
||||||
|
## Inspiration:
|
||||||
|
|
||||||
Because [Kaizoku](https://github.com/oae/kaizoku) was relying on [mangal](https://github.com/metafates/mangal) and mangal
|
Because [Kaizoku](https://github.com/oae/kaizoku) was relying on [mangal](https://github.com/metafates/mangal) and mangal
|
||||||
hasn't received bugfixes for it's issues with Titles not showing up, or throwing errors because of illegal characters,
|
hasn't received bugfixes for its issues with Titles not showing up, or throwing errors because of illegal characters,
|
||||||
there were no alternatives for automatic downloads. However [Kaizoku](https://github.com/oae/kaizoku) certainly had a great Web-UI.
|
there were no alternatives for automatic downloads. However, [Kaizoku](https://github.com/oae/kaizoku) certainly had a great Web-UI.
|
||||||
|
|
||||||
That is why I wanted to create my own project, in a language I understand, and that I am able to maintain myself.
|
That is why I wanted to create my own project, in a language I understand, and that I am able to maintain myself.
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
### Built With
|
## Endpoint Documentation
|
||||||
|
|
||||||
- .NET-Core
|
Endpoints are documented in Swagger. Just spin up an instance, and go to `http://<url>/swagger`.
|
||||||
- Newtonsoft.JSON
|
|
||||||
- [PuppeteerSharp](https://www.puppeteersharp.com/)
|
## Built With
|
||||||
- [Html Agility Pack (HAP)](https://html-agility-pack.net/)
|
|
||||||
- Love <3 Blåhaj 🦈
|
- .NET
|
||||||
|
- ASP.NET
|
||||||
|
- Entity Framework
|
||||||
|
- [PostgreSQL](https://www.postgresql.org/about/licence/)
|
||||||
|
- [Swagger](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/LICENSE)
|
||||||
|
- [Ngpsql](https://github.com/npgsql/npgsql/blob/main/LICENSE)
|
||||||
|
- [Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json/blob/master/LICENSE.md)
|
||||||
|
- [PuppeteerSharp](https://github.com/hardkoded/puppeteer-sharp/blob/master/LICENSE)
|
||||||
|
- [Html Agility Pack (HAP)](https://github.com/zzzprojects/html-agility-pack/blob/master/LICENSE)
|
||||||
|
- [Soenneker.Utils.String.NeedlemanWunsch](https://github.com/soenneker/soenneker.utils.string.needlemanwunsch/blob/main/LICENSE)
|
||||||
|
- [Sixlabors.ImageSharp](https://docs-v2.sixlabors.com/articles/imagesharp/index.html#license)
|
||||||
|
- 💙 Blåhaj 🦈
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
## Screenshots
|
<a href="https://star-history.com/#c9glax/tranga&Date">
|
||||||
|
<picture>
|
||||||

|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=c9glax/tranga&type=Date&theme=dark" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=c9glax/tranga&type=Date" />
|
||||||

|
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=c9glax/tranga&type=Date" />
|
||||||
|
</picture>
|
||||||
|  |  |
|
</a>
|
||||||
|-----------------------------------:|:-------------------------------------------------:|
|
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
|
||||||
|
|
||||||
<!-- GETTING STARTED -->
|
<!-- GETTING STARTED -->
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
There is two release types:
|
|
||||||
|
|
||||||
- CLI
|
|
||||||
- Docker
|
|
||||||
|
|
||||||
### CLI
|
|
||||||
|
|
||||||
Head over to [releases](https://git.bernloehr.eu/glax/Tranga/releases) and download. The CLI will guide you through setup.
|
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
Download [docker-compose.yaml](https://git.bernloehr.eu/glax/Tranga/src/branch/master/docker-compose.yaml) and configure to your needs.
|
An example `docker-compose.yaml` is provided. Mount `/Manga` to wherever you want your chapters (`.cbz`-Archives)
|
||||||
|
downloaded (where Komga/Kavita can access them for example).
|
||||||
|
The file also includes [tranga-website](https://github.com/C9Glax/tranga-website) as frontend. For its configuration refer to the
|
||||||
|
[Tranga-Website Repository](https://github.com/C9Glax/tranga-website) README.
|
||||||
|
|
||||||
Wherever you are mounting `/usr/share/Tranga-API` you also need to mount that same path + `/imageCache` in the webserver container.
|
For compatibility do not execute the compose as root (which you should not do anyways...) but as user that can
|
||||||
|
access the folder. Permission conflicts with Komga and Kavita should thus be limited.
|
||||||
|
|
||||||
### Prerequisites
|
### Bare-Metal
|
||||||
|
|
||||||
[.NET-Core 7.0 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/7.0)
|
While not supported/currently built, Tranga will also run Bare-Metal without issue.
|
||||||
|
|
||||||
<!-- ROADMAP -->
|
Configuration-Files will be stored per OS:
|
||||||
## Roadmap
|
- Linux `/usr/share/tranga-api`
|
||||||
|
- Windows `%appdata%/tranga-api`
|
||||||
|
|
||||||
- [ ] Docker ARM support
|
Downloads (default) are stored in - but this can be configured in `settings.json`:
|
||||||
- [ ] ?
|
- Linux `/Manga`
|
||||||
|
- Windows `%currentDirectory%/Downloads`
|
||||||
See the [open issues](https://git.bernloehr.eu/glax/Tranga/issues) for a full list of proposed features (and known issues).
|
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
|
||||||
|
|
||||||
|
#### Prerequisits
|
||||||
|
|
||||||
|
[.NET-Core 9.0](https://dotnet.microsoft.com/en-us/download/dotnet/9.0)
|
||||||
|
|
||||||
<!-- CONTRIBUTING -->
|
<!-- CONTRIBUTING -->
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
The following is copy & pasted:
|
If you want to contribute, please feel free to fork and create a Pull-Request!
|
||||||
|
|
||||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
General rules:
|
||||||
|
- Strongly-type your variables. This improves readability.
|
||||||
|
```csharp
|
||||||
|
var xyz = Object.GetSomething(); //Do not do this. What type is xyz?
|
||||||
|
Manga[] zyx = Object.GetAnotherThing(); //I can now easily see that zyx is an Array.
|
||||||
|
```
|
||||||
|
|
||||||
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement".
|
**A broad overview of where is what:**<br />
|
||||||
Don't forget to give the project a star! Thanks again!
|
|
||||||
|
|
||||||
1. Fork the Project
|
|
||||||
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
|
||||||
5. Open a Pull Request
|
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
|
||||||
|
|
||||||
|
- `Program.cs` Configuration for ASP.NET, Swagger (also in `NamedSwaggerGenOptions.cs`, Npgsql
|
||||||
|
- `Tranga.cs` Job(worker)-Logic
|
||||||
|
- `Schema/` Entity-Framework
|
||||||
|
- `Schema/Jobs/` + Logic for Jobs
|
||||||
|
- `Schema/**/` + Logic for **
|
||||||
|
- `Schema/PgsqlContext.cs` EF configuration
|
||||||
|
- `MangaDownloadClients/` Networking-Clients for Scraping
|
||||||
|
- `Controllers/` ASP.NET Controllers (Endpoints)
|
||||||
|
- `APIEndpointRecords/` Records for API-Requests with specific Request-Types (Body)
|
||||||
|
|
||||||
|
If you want to add a new Scanlationsite-Connector: <br />
|
||||||
|
1. Copy one of the existing connectors, or start from scratch and inherit from `API.Schema.MangaConnectors.MangaConnector`.
|
||||||
|
2. Add the new Connector as Object-Instance in `Program.cs` to the MangaConnector-Array `connectors`.
|
||||||
|
3. In `Schema/PgsqlContext.cs` add the Discriminator for the Connector (the value is the name of the connector, as defined
|
||||||
|
in the constructor).
|
||||||
|
|
||||||
<!-- LICENSE -->
|
<!-- LICENSE -->
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Distributed under the GNU GPLv3 License. See `LICENSE.txt` for more information.
|
Distributed under the GNU GPLv3 License. See [LICENSE.txt](https://github.com/C9Glax/tranga/blob/master/LICENSE.txt) for more information.
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- ACKNOWLEDGMENTS -->
|
<!-- ACKNOWLEDGMENTS -->
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
* [Choose an Open Source License](https://choosealicense.com)
|
* [Choose an Open Source License](https://choosealicense.com)
|
||||||
* [Font Awesome](https://fontawesome.com)
|
|
||||||
* [Best-README-Template](https://github.com/othneildrew/Best-README-Template/tree/master)
|
* [Best-README-Template](https://github.com/othneildrew/Best-README-Template/tree/master)
|
||||||
|
* [Shields.io](https://shields.io/)
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
@ -1,244 +0,0 @@
|
|||||||
using System.Runtime.InteropServices;
|
|
||||||
using Logging;
|
|
||||||
using Tranga;
|
|
||||||
using Tranga.TrangaTasks;
|
|
||||||
|
|
||||||
string applicationFolderPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Tranga-API");
|
|
||||||
string downloadFolderPath = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(applicationFolderPath, "Manga");
|
|
||||||
string logsFolderPath = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/var/logs/Tranga" : Path.Join(applicationFolderPath, "logs");
|
|
||||||
string logFilePath = Path.Join(logsFolderPath, $"log-{DateTime.Now:dd-M-yyyy-HH-mm-ss}.txt");
|
|
||||||
string settingsFilePath = Path.Join(applicationFolderPath, "settings.json");
|
|
||||||
|
|
||||||
Directory.CreateDirectory(logsFolderPath);
|
|
||||||
Logger logger = new(new[] { Logger.LoggerType.FileLogger, Logger.LoggerType.ConsoleLogger }, Console.Out, Console.Out.Encoding, logFilePath);
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga", "Loading settings.");
|
|
||||||
|
|
||||||
TrangaSettings settings;
|
|
||||||
if (File.Exists(settingsFilePath))
|
|
||||||
settings = TrangaSettings.LoadSettings(settingsFilePath, logger);
|
|
||||||
else
|
|
||||||
settings = new TrangaSettings(downloadFolderPath, applicationFolderPath, new HashSet<LibraryManager>());
|
|
||||||
|
|
||||||
Directory.CreateDirectory(settings.workingDirectory);
|
|
||||||
Directory.CreateDirectory(settings.downloadLocation);
|
|
||||||
Directory.CreateDirectory(settings.coverImageCache);
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga",$"Application-Folder: {settings.workingDirectory}");
|
|
||||||
logger.WriteLine("Tranga",$"Settings-File-Path: {settings.settingsFilePath}");
|
|
||||||
logger.WriteLine("Tranga",$"Download-Folder-Path: {settings.downloadLocation}");
|
|
||||||
logger.WriteLine("Tranga",$"Logfile-Path: {logFilePath}");
|
|
||||||
logger.WriteLine("Tranga",$"Image-Cache-Path: {settings.coverImageCache}");
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga", "Loading Taskmanager.");
|
|
||||||
TaskManager taskManager = new (settings, logger);
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
builder.Services.AddControllers().AddNewtonsoftJson();
|
|
||||||
|
|
||||||
string corsHeader = "Tranga";
|
|
||||||
builder.Services.AddCors(options =>
|
|
||||||
{
|
|
||||||
options.AddPolicy(name: corsHeader,
|
|
||||||
policy =>
|
|
||||||
{
|
|
||||||
policy.AllowAnyOrigin();
|
|
||||||
policy.WithMethods("GET", "POST", "DELETE");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
|
|
||||||
app.UseCors(corsHeader);
|
|
||||||
|
|
||||||
app.MapGet("/Controllers/Get", () => taskManager.GetAvailableConnectors().Keys.ToArray());
|
|
||||||
|
|
||||||
app.MapGet("/Publications/GetKnown", (string? internalId) =>
|
|
||||||
{
|
|
||||||
if(internalId is null)
|
|
||||||
return taskManager.GetAllPublications();
|
|
||||||
|
|
||||||
return new [] { taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == internalId) };
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/Publications/GetFromConnector", (string connectorName, string title) =>
|
|
||||||
{
|
|
||||||
Connector? connector = taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName).Value;
|
|
||||||
if (connector is null)
|
|
||||||
return Array.Empty<Publication>();
|
|
||||||
if(title.Length < 4)
|
|
||||||
return Array.Empty<Publication>();
|
|
||||||
return taskManager.GetPublicationsFromConnector(connector, title);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/Publications/GetChapters", (string connectorName, string internalId, string? language) =>
|
|
||||||
{
|
|
||||||
Connector? connector = taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName).Value;
|
|
||||||
if (connector is null)
|
|
||||||
return Array.Empty<Chapter>();
|
|
||||||
Publication? publication = taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == internalId);
|
|
||||||
if (publication is null)
|
|
||||||
return Array.Empty<Chapter>();
|
|
||||||
return connector.GetChapters((Publication)publication, language??"en");
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/Tasks/GetTypes", () => Enum.GetNames(typeof(TrangaTask.Task)));
|
|
||||||
|
|
||||||
|
|
||||||
app.MapPost("/Tasks/CreateMonitorTask",
|
|
||||||
(string connectorName, string internalId, string reoccurrenceTime, string? language) =>
|
|
||||||
{
|
|
||||||
Connector? connector =
|
|
||||||
taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName).Value;
|
|
||||||
if (connector is null)
|
|
||||||
return;
|
|
||||||
Publication? publication = taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == internalId);
|
|
||||||
if (publication is null)
|
|
||||||
return;
|
|
||||||
taskManager.AddTask(new DownloadNewChaptersTask(TrangaTask.Task.DownloadNewChapters, connectorName,
|
|
||||||
(Publication)publication,
|
|
||||||
TimeSpan.Parse(reoccurrenceTime), language ?? "en"));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapPost("/Tasks/CreateUpdateLibraryTask", (string reoccurrenceTime) =>
|
|
||||||
{
|
|
||||||
taskManager.AddTask(new UpdateLibrariesTask(TrangaTask.Task.UpdateLibraries, TimeSpan.Parse(reoccurrenceTime)));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapPost("/Tasks/CreateDownloadChaptersTask", (string connectorName, string internalId, string chapters, string? language) => {
|
|
||||||
|
|
||||||
Connector? connector =
|
|
||||||
taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName).Value;
|
|
||||||
if (connector is null)
|
|
||||||
return;
|
|
||||||
Publication? publication = taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == internalId);
|
|
||||||
if (publication is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Chapter[] availableChapters = connector.GetChapters((Publication)publication, language??"en");;
|
|
||||||
|
|
||||||
if (chapters.Contains('-'))
|
|
||||||
{
|
|
||||||
int start = Convert.ToInt32(chapters.Split('-')[0]);
|
|
||||||
int end = Convert.ToInt32(chapters.Split('-')[1]) + 1;
|
|
||||||
foreach (Chapter chapter in availableChapters[start..end])
|
|
||||||
{
|
|
||||||
taskManager.AddTask(new DownloadChapterTask(TrangaTask.Task.DownloadChapter, connectorName,
|
|
||||||
(Publication)publication, chapter, "en"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
taskManager.AddTask(new DownloadChapterTask(TrangaTask.Task.DownloadChapter, connectorName,
|
|
||||||
(Publication)publication, availableChapters[Convert.ToInt32(chapters)], "en"));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapDelete("/Tasks/Delete", (string taskType, string? connectorName, string? publicationId) =>
|
|
||||||
{
|
|
||||||
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
taskManager.DeleteTask(task, connectorName, publicationId);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/Tasks/Get", (string taskType, string? connectorName, string? searchString) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
return taskManager.GetTasksMatching(task, connectorName:connectorName, searchString:searchString);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
return Array.Empty<TrangaTask>();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/Tasks/GetProgress", (string taskType, string? connectorName, string? publicationId) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
TrangaTask? task = taskManager
|
|
||||||
.GetTasksMatching(pTask, connectorName: connectorName, internalId: publicationId)?.First();
|
|
||||||
|
|
||||||
if (task is null)
|
|
||||||
return -1f;
|
|
||||||
|
|
||||||
return task.progress;
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
return -1f;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapPost("/Tasks/Start", (string taskType, string? connectorName, string? internalId) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
TrangaTask? task = taskManager
|
|
||||||
.GetTasksMatching(pTask, connectorName: connectorName, internalId: internalId)?.FirstOrDefault();
|
|
||||||
|
|
||||||
if (task is null)
|
|
||||||
return;
|
|
||||||
taskManager.ExecuteTaskNow(task);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/Tasks/GetRunningTasks",
|
|
||||||
() => taskManager.GetAllTasks().Where(task => task.state is TrangaTask.ExecutionState.Running));
|
|
||||||
|
|
||||||
app.MapGet("/Queue/GetList",
|
|
||||||
() => taskManager.GetAllTasks().Where(task => task.state is TrangaTask.ExecutionState.Enqueued));
|
|
||||||
|
|
||||||
app.MapPost("/Queue/Enqueue", (string taskType, string? connectorName, string? publicationId) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
TrangaTask? task = taskManager
|
|
||||||
.GetTasksMatching(pTask, connectorName: connectorName, internalId: publicationId)?.First();
|
|
||||||
|
|
||||||
if (task is null)
|
|
||||||
return;
|
|
||||||
taskManager.AddTaskToQueue(task);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapDelete("/Queue/Dequeue", (string taskType, string? connectorName, string? publicationId) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
TrangaTask? task = taskManager
|
|
||||||
.GetTasksMatching(pTask, connectorName: connectorName, internalId: publicationId)?.First();
|
|
||||||
|
|
||||||
if (task is null)
|
|
||||||
return;
|
|
||||||
taskManager.RemoveTaskFromQueue(task);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/Settings/Get", () => taskManager.settings);
|
|
||||||
|
|
||||||
app.MapPost("/Settings/Update",
|
|
||||||
(string? downloadLocation, string? komgaUrl, string? komgaAuth, string? kavitaUrl, string? kavitaUsername, string? kavitaPassword) =>
|
|
||||||
taskManager.UpdateSettings(downloadLocation, komgaUrl, komgaAuth, kavitaUrl, kavitaUsername, kavitaPassword));
|
|
||||||
|
|
||||||
app.Run();
|
|
@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"iisSettings": {
|
|
||||||
"windowsAuthentication": false,
|
|
||||||
"anonymousAuthentication": true,
|
|
||||||
"iisExpress": {
|
|
||||||
"applicationUrl": "http://localhost:1716",
|
|
||||||
"sslPort": 44391
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"profiles": {
|
|
||||||
"http": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"applicationUrl": "http://localhost:5177"
|
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"applicationUrl": "https://localhost:7036;http://localhost:5177"
|
|
||||||
},
|
|
||||||
"IIS Express": {
|
|
||||||
"commandName": "IISExpress",
|
|
||||||
"launchBrowser": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<RootNamespace>Tranga_API</RootNamespace>
|
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="..\.dockerignore">
|
|
||||||
<Link>.dockerignore</Link>
|
|
||||||
</Content>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Logging\Logging.csproj" />
|
|
||||||
<ProjectReference Include="..\Tranga\Tranga.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.5" />
|
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="7.0.6" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
@ -1,22 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
|
||||||
<RootNamespace>Tranga_CLI</RootNamespace>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="..\.dockerignore">
|
|
||||||
<Link>.dockerignore</Link>
|
|
||||||
</Content>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Tranga\Tranga.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
@ -1,622 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Logging;
|
|
||||||
using Tranga;
|
|
||||||
using Tranga.LibraryManagers;
|
|
||||||
using Tranga.TrangaTasks;
|
|
||||||
|
|
||||||
namespace Tranga_CLI;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This is written with pure hatred for readability.
|
|
||||||
* At some point do this properly.
|
|
||||||
* Read at own risk.
|
|
||||||
*/
|
|
||||||
|
|
||||||
public static class Tranga_Cli
|
|
||||||
{
|
|
||||||
public static void Main(string[] args)
|
|
||||||
{
|
|
||||||
string applicationFolderPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Tranga");
|
|
||||||
string logsFolderPath = Path.Join(applicationFolderPath, "logs");
|
|
||||||
string logFilePath = Path.Join(logsFolderPath, $"log-{DateTime.Now:dd-M-yyyy-HH-mm-ss}.txt");
|
|
||||||
string settingsFilePath = Path.Join(applicationFolderPath, "settings.json");
|
|
||||||
|
|
||||||
Directory.CreateDirectory(applicationFolderPath);
|
|
||||||
Directory.CreateDirectory(logsFolderPath);
|
|
||||||
|
|
||||||
Console.WriteLine($"Logfile-Path: {logFilePath}");
|
|
||||||
Console.WriteLine($"Settings-File-Path: {settingsFilePath}");
|
|
||||||
|
|
||||||
Logger logger = new(new[] { Logger.LoggerType.FileLogger }, null, Console.Out.Encoding, logFilePath);
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Loading Taskmanager.");
|
|
||||||
TrangaSettings settings = File.Exists(settingsFilePath) ? TrangaSettings.LoadSettings(settingsFilePath, logger) : new TrangaSettings(Directory.GetCurrentDirectory(), applicationFolderPath, new HashSet<LibraryManager>());
|
|
||||||
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "User Input");
|
|
||||||
Console.WriteLine($"Output folder path [{settings.downloadLocation}]:");
|
|
||||||
string? tmpPath = Console.ReadLine();
|
|
||||||
while(tmpPath is null)
|
|
||||||
tmpPath = Console.ReadLine();
|
|
||||||
if (tmpPath.Length > 0)
|
|
||||||
settings.downloadLocation = tmpPath;
|
|
||||||
|
|
||||||
Console.WriteLine($"Komga BaseURL [{settings.libraryManagers.FirstOrDefault(lm => lm.GetType() == typeof(Komga))?.baseUrl}]:");
|
|
||||||
string? tmpUrlKomga = Console.ReadLine();
|
|
||||||
while (tmpUrlKomga is null)
|
|
||||||
tmpUrlKomga = Console.ReadLine();
|
|
||||||
if (tmpUrlKomga.Length > 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine("Username:");
|
|
||||||
string? tmpKomgaUser = Console.ReadLine();
|
|
||||||
while (tmpKomgaUser is null || tmpKomgaUser.Length < 1)
|
|
||||||
tmpKomgaUser = Console.ReadLine();
|
|
||||||
|
|
||||||
Console.WriteLine("Password:");
|
|
||||||
string tmpKomgaPass = string.Empty;
|
|
||||||
ConsoleKey key;
|
|
||||||
do
|
|
||||||
{
|
|
||||||
var keyInfo = Console.ReadKey(intercept: true);
|
|
||||||
key = keyInfo.Key;
|
|
||||||
|
|
||||||
if (key == ConsoleKey.Backspace && tmpKomgaPass.Length > 0)
|
|
||||||
{
|
|
||||||
Console.Write("\b \b");
|
|
||||||
tmpKomgaPass = tmpKomgaPass[0..^1];
|
|
||||||
}
|
|
||||||
else if (!char.IsControl(keyInfo.KeyChar))
|
|
||||||
{
|
|
||||||
Console.Write("*");
|
|
||||||
tmpKomgaPass += keyInfo.KeyChar;
|
|
||||||
}
|
|
||||||
} while (key != ConsoleKey.Enter);
|
|
||||||
|
|
||||||
settings.libraryManagers.RemoveWhere(lm => lm.GetType() == typeof(Komga));
|
|
||||||
settings.libraryManagers.Add(new Komga(tmpUrlKomga, tmpKomgaUser, tmpKomgaPass, logger));
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine($"Kavita BaseURL [{settings.libraryManagers.FirstOrDefault(lm => lm.GetType() == typeof(Kavita))?.baseUrl}]:");
|
|
||||||
string? tmpUrlKavita = Console.ReadLine();
|
|
||||||
while (tmpUrlKavita is null)
|
|
||||||
tmpUrlKavita = Console.ReadLine();
|
|
||||||
if (tmpUrlKavita.Length > 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine("Username:");
|
|
||||||
string? tmpKavitaUser = Console.ReadLine();
|
|
||||||
while (tmpKavitaUser is null || tmpKavitaUser.Length < 1)
|
|
||||||
tmpKavitaUser = Console.ReadLine();
|
|
||||||
|
|
||||||
Console.WriteLine("Password:");
|
|
||||||
string tmpKavitaPass = string.Empty;
|
|
||||||
ConsoleKey key;
|
|
||||||
do
|
|
||||||
{
|
|
||||||
var keyInfo = Console.ReadKey(intercept: true);
|
|
||||||
key = keyInfo.Key;
|
|
||||||
|
|
||||||
if (key == ConsoleKey.Backspace && tmpKavitaPass.Length > 0)
|
|
||||||
{
|
|
||||||
Console.Write("\b \b");
|
|
||||||
tmpKavitaPass = tmpKavitaPass[0..^1];
|
|
||||||
}
|
|
||||||
else if (!char.IsControl(keyInfo.KeyChar))
|
|
||||||
{
|
|
||||||
Console.Write("*");
|
|
||||||
tmpKavitaPass += keyInfo.KeyChar;
|
|
||||||
}
|
|
||||||
} while (key != ConsoleKey.Enter);
|
|
||||||
|
|
||||||
settings.libraryManagers.RemoveWhere(lm => lm.GetType() == typeof(Kavita));
|
|
||||||
settings.libraryManagers.Add(new Kavita(tmpUrlKavita, tmpKavitaUser, tmpKavitaPass, logger));
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Loaded.");
|
|
||||||
TaskMode(settings, logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TaskMode(TrangaSettings settings, Logger logger)
|
|
||||||
{
|
|
||||||
TaskManager taskManager = new (settings, logger);
|
|
||||||
ConsoleKey selection = ConsoleKey.EraseEndOfFile;
|
|
||||||
PrintMenu(taskManager, taskManager.settings.downloadLocation);
|
|
||||||
while (selection != ConsoleKey.Q)
|
|
||||||
{
|
|
||||||
int taskCount = taskManager.GetAllTasks().Length;
|
|
||||||
int taskRunningCount = taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Running);
|
|
||||||
int taskEnqueuedCount =
|
|
||||||
taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
|
|
||||||
Console.SetCursorPosition(0,1);
|
|
||||||
Console.WriteLine($"Tasks (Running/Queue/Total)): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
|
|
||||||
|
|
||||||
if (Console.KeyAvailable)
|
|
||||||
{
|
|
||||||
selection = Console.ReadKey().Key;
|
|
||||||
switch (selection)
|
|
||||||
{
|
|
||||||
case ConsoleKey.L:
|
|
||||||
while (!Console.KeyAvailable)
|
|
||||||
{
|
|
||||||
PrintTasks(taskManager.GetAllTasks(), logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Thread.Sleep(500);
|
|
||||||
}
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.C:
|
|
||||||
CreateTask(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.D:
|
|
||||||
DeleteTask(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.E:
|
|
||||||
ExecuteTaskNow(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.S:
|
|
||||||
SearchTasks(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.R:
|
|
||||||
while (!Console.KeyAvailable)
|
|
||||||
{
|
|
||||||
PrintTasks(
|
|
||||||
taskManager.GetAllTasks().Where(eTask => eTask.state == TrangaTask.ExecutionState.Running)
|
|
||||||
.ToArray(), logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Thread.Sleep(500);
|
|
||||||
}
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.K:
|
|
||||||
while (!Console.KeyAvailable)
|
|
||||||
{
|
|
||||||
PrintTasks(
|
|
||||||
taskManager.GetAllTasks()
|
|
||||||
.Where(qTask => qTask.state is TrangaTask.ExecutionState.Enqueued)
|
|
||||||
.ToArray(), logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Thread.Sleep(500);
|
|
||||||
}
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.F:
|
|
||||||
TailLog(logger);
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.G:
|
|
||||||
RemoveTaskFromQueue(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.B:
|
|
||||||
AddTaskToQueue(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.M:
|
|
||||||
AddMangaTaskToQueue(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
PrintMenu(taskManager, taskManager.settings.downloadLocation);
|
|
||||||
}
|
|
||||||
Thread.Sleep(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Exiting.");
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("Exiting.");
|
|
||||||
if (taskManager.GetAllTasks().Any(task => task.state == TrangaTask.ExecutionState.Running))
|
|
||||||
{
|
|
||||||
Console.WriteLine("Force quit (Even with running tasks?) y/N");
|
|
||||||
selection = Console.ReadKey().Key;
|
|
||||||
while(selection != ConsoleKey.Y && selection != ConsoleKey.N)
|
|
||||||
selection = Console.ReadKey().Key;
|
|
||||||
taskManager.Shutdown(selection == ConsoleKey.Y);
|
|
||||||
}else
|
|
||||||
// ReSharper disable once RedundantArgumentDefaultValue Better readability
|
|
||||||
taskManager.Shutdown(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PrintMenu(TaskManager taskManager, string folderPath)
|
|
||||||
{
|
|
||||||
int taskCount = taskManager.GetAllTasks().Length;
|
|
||||||
int taskRunningCount = taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Running);
|
|
||||||
int taskEnqueuedCount =
|
|
||||||
taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine($"Download Folder: {folderPath}");
|
|
||||||
Console.WriteLine($"Tasks (Running/Queue/Total)): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine($"{"C: Create Task",-30}{"L: List tasks",-30}{"B: Enqueue Task", -30}");
|
|
||||||
Console.WriteLine($"{"D: Delete Task",-30}{"S: Search Tasks", -30}{"K: List Task Queue", -30}");
|
|
||||||
Console.WriteLine($"{"E: Execute Task now",-30}{"R: List Running Tasks", -30}{"G: Remove Task from Queue", -30}");
|
|
||||||
Console.WriteLine($"{"M: New Download Manga Task",-30}{"", -30}{"", -30}");
|
|
||||||
Console.WriteLine($"{"",-30}{"F: Show Log",-30}{"Q: Exit",-30}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PrintTasks(TrangaTask[] tasks, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Printing Tasks");
|
|
||||||
int taskCount = tasks.Length;
|
|
||||||
int taskRunningCount = tasks.Count(task => task.state == TrangaTask.ExecutionState.Running);
|
|
||||||
int taskEnqueuedCount = tasks.Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
|
|
||||||
Console.Clear();
|
|
||||||
int tIndex = 0;
|
|
||||||
Console.WriteLine($"Tasks (Running/Queue/Total): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
|
|
||||||
string header =
|
|
||||||
$"{"",-5}{"Task",-20} | {"Last Executed",-20} | {"Reoccurrence",-12} | {"State",-10} | {"Progress",-9} | {"Finished",-20} | {"Remaining",-12} | {"Connector",-15} | Publication/Manga ";
|
|
||||||
Console.WriteLine(header);
|
|
||||||
Console.WriteLine(new string('-', header.Length));
|
|
||||||
foreach (TrangaTask trangaTask in tasks)
|
|
||||||
{
|
|
||||||
string[] taskSplit = trangaTask.ToString().Split(", ");
|
|
||||||
Console.WriteLine($"{tIndex++:000}: {taskSplit[0],-20} | {taskSplit[1],-20} | {taskSplit[2],-12} | {taskSplit[3],-10} | {taskSplit[4],-9} | {taskSplit[5],-20} | {taskSplit[6][..12],-12} | {(taskSplit.Length > 7 ? taskSplit[7] : ""),-15} | {(taskSplit.Length > 8 ? taskSplit[8] : "")} {(taskSplit.Length > 9 ? taskSplit[9] : "")} {(taskSplit.Length > 10 ? taskSplit[10] : "")}");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TrangaTask[] SelectTasks(TrangaTask[] tasks, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Select task");
|
|
||||||
if (tasks.Length < 1)
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("There are no available Tasks.");
|
|
||||||
logger.WriteLine("Tranga_CLI", "No available Tasks.");
|
|
||||||
return Array.Empty<TrangaTask>();
|
|
||||||
}
|
|
||||||
PrintTasks(tasks, logger);
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Selecting Task to Remove (from queue)");
|
|
||||||
Console.WriteLine("Enter q to abort");
|
|
||||||
Console.WriteLine($"Select Task(s) (0-{tasks.Length - 1}):");
|
|
||||||
|
|
||||||
string? selectedTask = Console.ReadLine();
|
|
||||||
while(selectedTask is null || selectedTask.Length < 1)
|
|
||||||
selectedTask = Console.ReadLine();
|
|
||||||
|
|
||||||
if (selectedTask.Length == 1 && selectedTask.ToLower() == "q")
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("aborted.");
|
|
||||||
logger.WriteLine("Tranga_CLI", "aborted");
|
|
||||||
return Array.Empty<TrangaTask>();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTask.Contains('-'))
|
|
||||||
{
|
|
||||||
int start = Convert.ToInt32(selectedTask.Split('-')[0]);
|
|
||||||
int end = Convert.ToInt32(selectedTask.Split('-')[1]);
|
|
||||||
return tasks[start..end];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
int selectedTaskIndex = Convert.ToInt32(selectedTask);
|
|
||||||
return new[] { tasks[selectedTaskIndex] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddMangaTaskToQueue(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Add Manga Download to queue");
|
|
||||||
|
|
||||||
Connector? connector = SelectConnector(taskManager.GetAvailableConnectors().Values.ToArray(), logger);
|
|
||||||
if (connector is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Publication? publication = SelectPublication(taskManager, connector, logger);
|
|
||||||
if (publication is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
TimeSpan reoccurrence = SelectReoccurrence(logger);
|
|
||||||
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
|
|
||||||
TrangaTask? newTask = taskManager.AddTask(TrangaTask.Task.DownloadNewChapters, connector.name, publication.Value.publicationId, reoccurrence, "en");
|
|
||||||
Console.WriteLine(newTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddTaskToQueue(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Add Task to queue");
|
|
||||||
|
|
||||||
TrangaTask[] tasks = taskManager.GetAllTasks().Where(rTask =>
|
|
||||||
rTask.state is not TrangaTask.ExecutionState.Enqueued and not TrangaTask.ExecutionState.Running).ToArray();
|
|
||||||
|
|
||||||
TrangaTask[] selectedTasks = SelectTasks(tasks, logger);
|
|
||||||
logger.WriteLine("Tranga_CLI", $"Sending {selectedTasks.Length} Tasks to TaskManager");
|
|
||||||
foreach(TrangaTask task in selectedTasks)
|
|
||||||
taskManager.AddTaskToQueue(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RemoveTaskFromQueue(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Remove Task from queue");
|
|
||||||
|
|
||||||
TrangaTask[] tasks = taskManager.GetAllTasks().Where(rTask => rTask.state is TrangaTask.ExecutionState.Enqueued).ToArray();
|
|
||||||
|
|
||||||
TrangaTask[] selectedTasks = SelectTasks(tasks, logger);
|
|
||||||
logger.WriteLine("Tranga_CLI", $"Sending {selectedTasks.Length} Tasks to TaskManager");
|
|
||||||
foreach(TrangaTask task in selectedTasks)
|
|
||||||
taskManager.RemoveTaskFromQueue(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TailLog(Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Show Log-lines");
|
|
||||||
Console.Clear();
|
|
||||||
|
|
||||||
string[] lines = logger.Tail(20);
|
|
||||||
foreach (string message in lines)
|
|
||||||
Console.Write(message);
|
|
||||||
|
|
||||||
while (!Console.KeyAvailable)
|
|
||||||
{
|
|
||||||
string[] newLines = logger.GetNewLines();
|
|
||||||
foreach(string message in newLines)
|
|
||||||
Console.Write(message);
|
|
||||||
Thread.Sleep(40);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CreateTask(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Creating Task");
|
|
||||||
TrangaTask.Task? tmpTask = SelectTaskType(logger);
|
|
||||||
if (tmpTask is null)
|
|
||||||
return;
|
|
||||||
TrangaTask.Task task = (TrangaTask.Task)tmpTask;
|
|
||||||
|
|
||||||
Connector? connector = null;
|
|
||||||
if (task != TrangaTask.Task.UpdateLibraries)
|
|
||||||
{
|
|
||||||
connector = SelectConnector(taskManager.GetAvailableConnectors().Values.ToArray(), logger);
|
|
||||||
if (connector is null)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Publication? publication = null;
|
|
||||||
if (task != TrangaTask.Task.UpdateLibraries)
|
|
||||||
{
|
|
||||||
publication = SelectPublication(taskManager, connector!, logger);
|
|
||||||
if (publication is null)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task is TrangaTask.Task.DownloadNewChapters)
|
|
||||||
{
|
|
||||||
TimeSpan reoccurrence = SelectReoccurrence(logger);
|
|
||||||
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
|
|
||||||
|
|
||||||
TrangaTask newTask = new DownloadNewChaptersTask(TrangaTask.Task.DownloadNewChapters, connector!.name, (Publication)publication!, reoccurrence, "en");
|
|
||||||
taskManager.AddTask(newTask);
|
|
||||||
Console.WriteLine(newTask);
|
|
||||||
}else if (task is TrangaTask.Task.DownloadChapter)
|
|
||||||
{
|
|
||||||
foreach (Chapter chapter in SelectChapters(connector!, (Publication)publication!, logger))
|
|
||||||
{
|
|
||||||
TrangaTask newTask = new DownloadChapterTask(TrangaTask.Task.DownloadChapter, connector!.name,
|
|
||||||
(Publication)publication!, chapter, "en");
|
|
||||||
taskManager.AddTask(newTask);
|
|
||||||
Console.WriteLine(newTask);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ExecuteTaskNow(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Executing Task");
|
|
||||||
TrangaTask[] tasks = taskManager.GetAllTasks().Where(nTask => nTask.state is not TrangaTask.ExecutionState.Running).ToArray();
|
|
||||||
|
|
||||||
TrangaTask[] selectedTasks = SelectTasks(tasks, logger);
|
|
||||||
logger.WriteLine("Tranga_CLI", $"Sending {selectedTasks.Length} Tasks to TaskManager");
|
|
||||||
foreach(TrangaTask task in selectedTasks)
|
|
||||||
taskManager.ExecuteTaskNow(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void DeleteTask(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Delete Task");
|
|
||||||
TrangaTask[] tasks = taskManager.GetAllTasks();
|
|
||||||
|
|
||||||
TrangaTask[] selectedTasks = SelectTasks(tasks, logger);
|
|
||||||
logger.WriteLine("Tranga_CLI", $"Sending {selectedTasks.Length} Tasks to TaskManager");
|
|
||||||
foreach(TrangaTask task in selectedTasks)
|
|
||||||
taskManager.DeleteTask(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TrangaTask.Task? SelectTaskType(Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Select TaskType");
|
|
||||||
Console.Clear();
|
|
||||||
string[] taskNames = Enum.GetNames<TrangaTask.Task>();
|
|
||||||
|
|
||||||
int tIndex = 0;
|
|
||||||
Console.WriteLine("Available Tasks:");
|
|
||||||
foreach (string taskName in taskNames)
|
|
||||||
Console.WriteLine($"{tIndex++}: {taskName}");
|
|
||||||
|
|
||||||
Console.WriteLine("Enter q to abort");
|
|
||||||
Console.WriteLine($"Select Task (0-{taskNames.Length - 1}):");
|
|
||||||
|
|
||||||
string? selectedTask = Console.ReadLine();
|
|
||||||
while(selectedTask is null || selectedTask.Length < 1)
|
|
||||||
selectedTask = Console.ReadLine();
|
|
||||||
|
|
||||||
if (selectedTask.Length == 1 && selectedTask.ToLower() == "q")
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("aborted.");
|
|
||||||
logger.WriteLine("Tranga_CLI", "aborted.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
int selectedTaskIndex = Convert.ToInt32(selectedTask);
|
|
||||||
string selectedTaskName = taskNames[selectedTaskIndex];
|
|
||||||
return Enum.Parse<TrangaTask.Task>(selectedTaskName);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Exception: {e.Message}");
|
|
||||||
logger.WriteLine("Tranga_CLI", e.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TimeSpan SelectReoccurrence(Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Select Reoccurrence");
|
|
||||||
Console.WriteLine("Select reoccurrence Timer (Format hh:mm:ss):");
|
|
||||||
return TimeSpan.Parse(Console.ReadLine()!, new CultureInfo("en-US"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Chapter[] SelectChapters(Connector connector, Publication publication, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Select Chapters");
|
|
||||||
Chapter[] availableChapters = connector.GetChapters(publication, "en");
|
|
||||||
int cIndex = 0;
|
|
||||||
Console.WriteLine("Chapters:");
|
|
||||||
foreach(Chapter chapter in availableChapters)
|
|
||||||
Console.WriteLine($"{cIndex++}: Vol.{chapter.volumeNumber} Ch.{chapter.chapterNumber} - {chapter.name}");
|
|
||||||
|
|
||||||
Console.WriteLine("Enter q to abort");
|
|
||||||
Console.WriteLine($"Select Chapter(s):");
|
|
||||||
|
|
||||||
string? selectedChapters = Console.ReadLine();
|
|
||||||
while(selectedChapters is null || selectedChapters.Length < 1)
|
|
||||||
selectedChapters = Console.ReadLine();
|
|
||||||
|
|
||||||
if (selectedChapters.Length == 1 && selectedChapters.ToLower() == "q")
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("aborted.");
|
|
||||||
logger.WriteLine("Tranga_CLI", "aborted.");
|
|
||||||
return Array.Empty<Chapter>();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedChapters.Contains('-'))
|
|
||||||
{
|
|
||||||
int start = Convert.ToInt32(selectedChapters.Split('-')[0]);
|
|
||||||
int end = Convert.ToInt32(selectedChapters.Split('-')[1]) + 1;
|
|
||||||
return availableChapters[start..end];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
return new Chapter[] { availableChapters[Convert.ToInt32(selectedChapters)] };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Connector? SelectConnector(Connector[] connectors, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Select Connector");
|
|
||||||
Console.Clear();
|
|
||||||
|
|
||||||
int cIndex = 0;
|
|
||||||
Console.WriteLine("Connectors:");
|
|
||||||
foreach (Connector connector in connectors)
|
|
||||||
Console.WriteLine($"{cIndex++}: {connector.name}");
|
|
||||||
|
|
||||||
Console.WriteLine("Enter q to abort");
|
|
||||||
Console.WriteLine($"Select Connector (0-{connectors.Length - 1}):");
|
|
||||||
|
|
||||||
string? selectedConnector = Console.ReadLine();
|
|
||||||
while(selectedConnector is null || selectedConnector.Length < 1)
|
|
||||||
selectedConnector = Console.ReadLine();
|
|
||||||
|
|
||||||
if (selectedConnector.Length == 1 && selectedConnector.ToLower() == "q")
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("aborted.");
|
|
||||||
logger.WriteLine("Tranga_CLI", "aborted.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
int selectedConnectorIndex = Convert.ToInt32(selectedConnector);
|
|
||||||
return connectors[selectedConnectorIndex];
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Exception: {e.Message}");
|
|
||||||
logger.WriteLine("Tranga_CLI", e.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Publication? SelectPublication(TaskManager taskManager, Connector connector, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Select Publication");
|
|
||||||
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine($"Connector: {connector.name}");
|
|
||||||
Console.WriteLine("Publication search query (leave empty for all):");
|
|
||||||
string? query = Console.ReadLine();
|
|
||||||
|
|
||||||
Publication[] publications = taskManager.GetPublicationsFromConnector(connector, query ?? "");
|
|
||||||
|
|
||||||
if (publications.Length < 1)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "No publications returned");
|
|
||||||
Console.WriteLine($"No publications for query '{query}' returned;");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
int pIndex = 0;
|
|
||||||
Console.WriteLine("Publications:");
|
|
||||||
foreach(Publication publication in publications)
|
|
||||||
Console.WriteLine($"{pIndex++}: {publication.sortName}");
|
|
||||||
|
|
||||||
Console.WriteLine("Enter q to abort");
|
|
||||||
Console.WriteLine($"Select publication to Download (0-{publications.Length - 1}):");
|
|
||||||
|
|
||||||
string? selectedPublication = Console.ReadLine();
|
|
||||||
while(selectedPublication is null || selectedPublication.Length < 1)
|
|
||||||
selectedPublication = Console.ReadLine();
|
|
||||||
|
|
||||||
if (selectedPublication.Length == 1 && selectedPublication.ToLower() == "q")
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("aborted.");
|
|
||||||
logger.WriteLine("Tranga_CLI", "aborted.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
int selectedPublicationIndex = Convert.ToInt32(selectedPublication);
|
|
||||||
return publications[selectedPublicationIndex];
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Exception: {e.Message}");
|
|
||||||
logger.WriteLine("Tranga_CLI", e.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SearchTasks(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Search task");
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("Enter search query:");
|
|
||||||
string? query = Console.ReadLine();
|
|
||||||
while (query is null || query.Length < 4)
|
|
||||||
query = Console.ReadLine();
|
|
||||||
PrintTasks(taskManager.GetAllTasks().Where(qTask =>
|
|
||||||
qTask.ToString().ToLower().Contains(query, StringComparison.OrdinalIgnoreCase)).ToArray(), logger);
|
|
||||||
}
|
|
||||||
}
|
|
28
Tranga.sln
28
Tranga.sln
@ -1,12 +1,6 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga", ".\Tranga\Tranga.csproj", "{545E81B9-D96B-4C8F-A97F-2C02414DE566}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{EDB07E7B-351F-4FCC-9AEF-777838E5551E}"
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga-CLI", "Tranga-CLI\Tranga-CLI.csproj", "{4899E3B2-B259-479A-B43E-042D043E9501}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logging", "Logging\Logging.csproj", "{415BE889-BB7D-426F-976F-8D977876A462}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga-API", "Tranga-API\Tranga-API.csproj", "{48F4E495-75BC-4402-8E03-DEC5B79D7E83}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@ -14,21 +8,9 @@ Global
|
|||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.Build.0 = Release|Any CPU
|
{EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{4899E3B2-B259-479A-B43E-042D043E9501}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{4899E3B2-B259-479A-B43E-042D043E9501}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{4899E3B2-B259-479A-B43E-042D043E9501}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{4899E3B2-B259-479A-B43E-042D043E9501}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
@ -1,6 +1,14 @@
|
|||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=altnames/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=authorsartists/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Gotify/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=jjob/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Komga/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Komga/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=lunasea/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=mangakatana/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Manganato/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Manganato/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Mangasee/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Mangasee/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Mangaworld/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Ntfy/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Taskmanager/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Taskmanager/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Tranga/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Tranga/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user