mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-08 19:42:40 +08:00
Compare commits
1088 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cd768b3d0 | ||
|
|
abc26b65ed | ||
|
|
dc1a41da90 | ||
|
|
a95dac1b32 | ||
|
|
18d9620687 | ||
|
|
8808dcee52 | ||
|
|
17adc4deab | ||
|
|
9351489166 | ||
|
|
e2148cb77f | ||
|
|
e322204094 | ||
|
|
0fa884157a | ||
|
|
96468213fe | ||
|
|
d044a9db00 | ||
|
|
d5f5e0d526 | ||
|
|
14a3bb8fc2 | ||
|
|
5921d43ae8 | ||
|
|
635061c054 | ||
|
|
3c8c6e5375 | ||
|
|
dd063bb16b | ||
|
|
750711611b | ||
|
|
d3983c51c2 | ||
|
|
b9dec73773 | ||
|
|
b310367d25 | ||
|
|
55beea87fd | ||
|
|
4510382f74 | ||
|
|
9b9ae9401e | ||
|
|
e10464c278 | ||
|
|
542531a1ca | ||
|
|
04c21232e3 | ||
|
|
48a19fd57c | ||
|
|
59cb69a96b | ||
|
|
e7d94f7f70 | ||
|
|
27d2d01a20 | ||
|
|
8b4495c857 | ||
|
|
15bdb694cc | ||
|
|
3ef9c5ea2c | ||
|
|
ab6577f752 | ||
|
|
49a82d7a48 | ||
|
|
bdcbb168a0 | ||
|
|
2e1cb0bd76 | ||
|
|
851864cd49 | ||
|
|
b5d7b6fb53 | ||
|
|
92bab2fc2f | ||
|
|
0dad6860c4 | ||
|
|
de4a7becc2 | ||
|
|
2eeb24e22d | ||
|
|
e4a67ea052 | ||
|
|
a4df2f5213 | ||
|
|
4f89780a0f | ||
|
|
26d6201b30 | ||
|
|
c9a9ff2692 | ||
|
|
0be49953b4 | ||
|
|
0de952f090 | ||
|
|
2b570bf48f | ||
|
|
9476017af5 | ||
|
|
54f808485e | ||
|
|
fa5c82899b | ||
|
|
4a57071809 | ||
|
|
4631db9a45 | ||
|
|
0f09da55b0 | ||
|
|
b14b41c2c1 | ||
|
|
cf05ae20c5 | ||
|
|
897758d829 | ||
|
|
85a77a66dd | ||
|
|
c450dfc0fa | ||
|
|
3d782a7475 | ||
|
|
4734851213 | ||
|
|
9c8635002d | ||
|
|
4cd3cb2b60 | ||
|
|
fa890ca29c | ||
|
|
bbf1ec4c50 | ||
|
|
523d458489 | ||
|
|
45ec668875 | ||
|
|
60122644b8 | ||
|
|
07a77e0001 | ||
|
|
d112f49a69 | ||
|
|
8cb061ff75 | ||
|
|
01e08c8e69 | ||
|
|
3549b38ee8 | ||
|
|
f5fb888c85 | ||
|
|
8bcb6a7cb6 | ||
|
|
ac81dd943c | ||
|
|
663d282b5e | ||
|
|
c7b389dd9b | ||
|
|
bad37a1846 | ||
|
|
9c09981583 | ||
|
|
2d8e66cbe2 | ||
|
|
db28986d22 | ||
|
|
727bed46b7 | ||
|
|
8e0df90177 | ||
|
|
34bbb86c16 | ||
|
|
0403f1f48c | ||
|
|
1db452e268 | ||
|
|
81ca11650d | ||
|
|
2e4671fdbc | ||
|
|
da80ad33d9 | ||
|
|
a6f28569ab | ||
|
|
5dd36e95e0 | ||
|
|
1eaeea62db | ||
|
|
4282c5dfc2 | ||
|
|
2e661f8759 | ||
|
|
31ca41828e | ||
|
|
c9ebe76eb1 | ||
|
|
71ac12ab7a | ||
|
|
81c0e15a1c | ||
|
|
2bde4923f9 | ||
|
|
22fb6305cf | ||
|
|
4bb5772e10 | ||
|
|
549658e871 | ||
|
|
80f47594f4 | ||
|
|
2614eeadb0 | ||
|
|
a0af827319 | ||
|
|
0233853794 | ||
|
|
6b24ccdc35 | ||
|
|
7d76ee2e65 | ||
|
|
1dd9228d01 | ||
|
|
a5b4221a00 | ||
|
|
37ba75b53c | ||
|
|
b8553e2b86 | ||
|
|
d28f3ed74b | ||
|
|
185c78b05c | ||
|
|
f23cab861a | ||
|
|
bbddec763a | ||
|
|
06c3985aa4 | ||
|
|
9503a603e6 | ||
|
|
6e9ab24d95 | ||
|
|
7524379af6 | ||
|
|
eebf3dec68 | ||
|
|
a89dd636a4 | ||
|
|
7fb025bff4 | ||
|
|
c44c0f6321 | ||
|
|
585bcb924f | ||
|
|
0ce3c3d90f | ||
|
|
9cb69f4879 | ||
|
|
c5b13f2fee | ||
|
|
235af9e558 | ||
|
|
cb274d1587 | ||
|
|
63643e6d26 | ||
|
|
0726600936 | ||
|
|
6151bd64dd | ||
|
|
32dc0f69f9 | ||
|
|
5b563cf173 | ||
|
|
3dbb534883 | ||
|
|
7304fad460 | ||
|
|
9f829c2129 | ||
|
|
32e71beca8 | ||
|
|
3c1c04f356 | ||
|
|
c473594663 | ||
|
|
a8ce9648e2 | ||
|
|
760285b085 | ||
|
|
ccdad3e8dc | ||
|
|
f33e9bee21 | ||
|
|
4183dca80f | ||
|
|
6f6fd6a42e | ||
|
|
13bb31fd93 | ||
|
|
5bac94cbc5 | ||
|
|
daa8d80ec9 | ||
|
|
b095f01b09 | ||
|
|
f43efab831 | ||
|
|
946b7905b3 | ||
|
|
544625a9a3 | ||
|
|
d7c6c27679 | ||
|
|
70adbfe6b5 | ||
|
|
d8f9ab93e5 | ||
|
|
e06d07937e | ||
|
|
f94d248383 | ||
|
|
c139aeebf5 | ||
|
|
89a8625817 | ||
|
|
59acda5dec | ||
|
|
57d9e4a370 | ||
|
|
8b6a2a3d99 | ||
|
|
3e10642bdd | ||
|
|
c8e63b6ae0 | ||
|
|
03c92ad41c | ||
|
|
690b454bb1 | ||
|
|
2e6c1bef63 | ||
|
|
0fd428f809 | ||
|
|
6083a8a859 | ||
|
|
bb7d262ea3 | ||
|
|
ca9a37d12a | ||
|
|
595ca631f4 | ||
|
|
cbffddc57f | ||
|
|
a5f5d41104 | ||
|
|
56f07b3dd6 | ||
|
|
fba10fe6a0 | ||
|
|
5639e0b7d0 | ||
|
|
a6ad58ca33 | ||
|
|
00447f2475 | ||
|
|
9d14fc47fe | ||
|
|
70c459f810 | ||
|
|
a0af2f4b68 | ||
|
|
603eefb22f | ||
|
|
34625ee384 | ||
|
|
ca78fb7c22 | ||
|
|
3c710dd266 | ||
|
|
514e7add4b | ||
|
|
bdbf1e9084 | ||
|
|
6149cef1d3 | ||
|
|
b8fac86c6e | ||
|
|
9f450dd8be | ||
|
|
24c2d3f8ca | ||
|
|
4248b8fa4e | ||
|
|
deaa2e5644 | ||
|
|
dc43aabe2a | ||
|
|
02981d38c0 | ||
|
|
85fd9b3c09 | ||
|
|
39ad54f3d9 | ||
|
|
aa9a2c46aa | ||
|
|
c43a1411c9 | ||
|
|
928aaf0c19 | ||
|
|
ea8a4a3ec4 | ||
|
|
c4dc468479 | ||
|
|
87ddfbca90 | ||
|
|
164ce8f7c4 | ||
|
|
c2fd6e3342 | ||
|
|
16b79754c3 | ||
|
|
9cfb1f789f | ||
|
|
e3faa388cf | ||
|
|
b75ec92368 | ||
|
|
f91763ef7c | ||
|
|
edf8b03d3b | ||
|
|
ea48eb5c56 | ||
|
|
282f723d34 | ||
|
|
dde3b76573 | ||
|
|
f571711386 | ||
|
|
e8e8d36a13 | ||
|
|
782a9a4759 | ||
|
|
d0184bd34c | ||
|
|
e4c0643c39 | ||
|
|
305c08c7dd | ||
|
|
9521a3ef09 | ||
|
|
b4c6a206af | ||
|
|
fa7eeec345 | ||
|
|
7350216fc4 | ||
|
|
36122dda31 | ||
|
|
5851673b43 | ||
|
|
0d81105a0b | ||
|
|
b934b0975b | ||
|
|
035b4b0608 | ||
|
|
b98a033cd2 | ||
|
|
c69853ce4b | ||
|
|
e00a440336 | ||
|
|
c0eb6b0600 | ||
|
|
4d1c8c3764 | ||
|
|
62628e526c | ||
|
|
ad7761a785 | ||
|
|
e545b8d900 | ||
|
|
f2f1ecfdf1 | ||
|
|
fdec997ed0 | ||
|
|
9b653ceec9 | ||
|
|
fbaaed1c61 | ||
|
|
639abf67c2 | ||
|
|
1f56ceaea9 | ||
|
|
16a4f61fec | ||
|
|
ea0aba96fd | ||
|
|
4393dad77c | ||
|
|
d099c0e702 | ||
|
|
a299d786fe | ||
|
|
3500f5b9a6 | ||
|
|
64233c89d7 | ||
|
|
8c727da58a | ||
|
|
152a87d109 | ||
|
|
6a2cde0664 | ||
|
|
c86cc2cb51 | ||
|
|
6d7a63ff61 | ||
|
|
c044e59481 | ||
|
|
3c31bf24e5 | ||
|
|
d89c80ac89 | ||
|
|
8236d6c8d7 | ||
|
|
3646540a7f | ||
|
|
c1ecdfc61d | ||
|
|
7587946d51 | ||
|
|
3ad64baaeb | ||
|
|
24c43b53a2 | ||
|
|
53a6a1c691 | ||
|
|
c3ba83c7ca | ||
|
|
d9b349873e | ||
|
|
4dcefb141a | ||
|
|
c674e32046 | ||
|
|
8aa1027aae | ||
|
|
b4cb9c3fb3 | ||
|
|
d82ab5d60d | ||
|
|
979b636eec | ||
|
|
bf8a75b201 | ||
|
|
87111c8736 | ||
|
|
9b97e478aa | ||
|
|
2af7abee3c | ||
|
|
2c8a41ebad | ||
|
|
c632cfd6b9 | ||
|
|
7f05df2fb3 | ||
|
|
ff33432809 | ||
|
|
0a57e69bcf | ||
|
|
7af8b15dbb | ||
|
|
bc4931d971 | ||
|
|
cfb029b6b4 | ||
|
|
6fa50101a6 | ||
|
|
843fbc83f4 | ||
|
|
55f8fb3b66 | ||
|
|
a47774472d | ||
|
|
713f4ca356 | ||
|
|
b06795510a | ||
|
|
0f57ec099a | ||
|
|
8325caabdc | ||
|
|
44d276d7e7 | ||
|
|
935340561b | ||
|
|
a60fde3b91 | ||
|
|
163a855d5c | ||
|
|
c9b1e75361 | ||
|
|
a9932d0866 | ||
|
|
11d29919bf | ||
|
|
4fe755332d | ||
|
|
0095e0f4dd | ||
|
|
322c72ab54 | ||
|
|
4d51459a47 | ||
|
|
d51de30898 | ||
|
|
90f9edbf24 | ||
|
|
8aa10457a7 | ||
|
|
ab584720c6 | ||
|
|
56ad281cb6 | ||
|
|
61281cca02 | ||
|
|
b53dbbc38e | ||
|
|
3f88cfba28 | ||
|
|
e855d8b9af | ||
|
|
171720e629 | ||
|
|
8aa6b33fba | ||
|
|
505fc803db | ||
|
|
b5146620a6 | ||
|
|
7d44f24347 | ||
|
|
4dccc6e860 | ||
|
|
ee6585c737 | ||
|
|
62e5e8a69f | ||
|
|
e942a99ff0 | ||
|
|
b3fe49684b | ||
|
|
dcf1985361 | ||
|
|
8f4f4cc004 | ||
|
|
f49baadb76 | ||
|
|
5233484fc5 | ||
|
|
84c4cc8b5d | ||
|
|
77036eccd8 | ||
|
|
dcdb08ec80 | ||
|
|
cd7f688e78 | ||
|
|
cb12a052ac | ||
|
|
995c359f20 | ||
|
|
690066ad32 | ||
|
|
73942e315a | ||
|
|
48badb3243 | ||
|
|
d5eb12cc4e | ||
|
|
7d7539df4c | ||
|
|
14a8f44f8c | ||
|
|
a7be470f33 | ||
|
|
a677169f60 | ||
|
|
b72ef4f2aa | ||
|
|
403054751b | ||
|
|
b3e5c734d4 | ||
|
|
5732125ff6 | ||
|
|
eb66cf7aad | ||
|
|
a317c35eab | ||
|
|
ab138560c1 | ||
|
|
f0fbad889d | ||
|
|
1323cd5dc6 | ||
|
|
2c43d8e145 | ||
|
|
0214beb679 | ||
|
|
7d73cdef33 | ||
|
|
fcfab2c750 | ||
|
|
e048be17a5 | ||
|
|
024f1de4f1 | ||
|
|
d2c9f7a778 | ||
|
|
1784d2ec61 | ||
|
|
8f4a213f55 | ||
|
|
6a8c684af0 | ||
|
|
aefba83319 | ||
|
|
e411f4062a | ||
|
|
3c6802860d | ||
|
|
3daad5ea90 | ||
|
|
6835c38c24 | ||
|
|
32be9b71d5 | ||
|
|
84bc0e0fb4 | ||
|
|
8105dc9c82 | ||
|
|
840c968454 | ||
|
|
8c5f19a0f4 | ||
|
|
bf813aa906 | ||
|
|
e5ac7f10d4 | ||
|
|
d049e04fa2 | ||
|
|
ef45b08ee5 | ||
|
|
d77644ab16 | ||
|
|
1a90b4a3cb | ||
|
|
9e5d401a85 | ||
|
|
6da6dc2b8c | ||
|
|
2ca7021df0 | ||
|
|
1682cdad37 | ||
|
|
ec5e898feb | ||
|
|
ccf6fb1b36 | ||
|
|
83c8d619d0 | ||
|
|
dd5b8219a1 | ||
|
|
34fd927972 | ||
|
|
6db5ad2697 | ||
|
|
0be1f27970 | ||
|
|
54603798fc | ||
|
|
2f1e947323 | ||
|
|
ef2cfe1c1d | ||
|
|
f8edf79c59 | ||
|
|
dec80b6567 | ||
|
|
4dac9237ef | ||
|
|
12f5f373b3 | ||
|
|
76472770bf | ||
|
|
f5baf77c3c | ||
|
|
126276c727 | ||
|
|
5d6ba83fc5 | ||
|
|
047c3b596d | ||
|
|
2240ff08a1 | ||
|
|
4d6bf56fa0 | ||
|
|
10ad9a5601 | ||
|
|
fb39500428 | ||
|
|
615a52cef9 | ||
|
|
791be0583a | ||
|
|
a324731061 | ||
|
|
539d9cf537 | ||
|
|
699312ff28 | ||
|
|
b92b0ec149 | ||
|
|
51536062f1 | ||
|
|
4c230b4c1e | ||
|
|
a7752ceb17 | ||
|
|
ed59a90d78 | ||
|
|
11d65e7527 | ||
|
|
b6ac5f0f84 | ||
|
|
c336e62885 | ||
|
|
b868cdb25e | ||
|
|
04339539d1 | ||
|
|
2d146880ec | ||
|
|
6eec4ef7f4 | ||
|
|
9841f3dd18 | ||
|
|
03e0118fb7 | ||
|
|
c7c222b357 | ||
|
|
2d8e45cd1b | ||
|
|
e2bd5cc245 | ||
|
|
47ddfec30e | ||
|
|
9344b2a324 | ||
|
|
8496fcccc5 | ||
|
|
c6fa3b9d25 | ||
|
|
ce13987748 | ||
|
|
5221fc4f6a | ||
|
|
d4dc388d3f | ||
|
|
42966c2537 | ||
|
|
7921dcd86b | ||
|
|
4c69bb6c48 | ||
|
|
0aad809c82 | ||
|
|
f514a5a416 | ||
|
|
793a7460c6 | ||
|
|
6dcc979fd5 | ||
|
|
5a07732712 | ||
|
|
61d71b32ff | ||
|
|
ba62ca3d18 | ||
|
|
612271bf0c | ||
|
|
3b99fb5c96 | ||
|
|
bb61f8197c | ||
|
|
b54f04a35b | ||
|
|
d47639bada | ||
|
|
ae9bab2981 | ||
|
|
2116b094ad | ||
|
|
288883a13b | ||
|
|
07c988abae | ||
|
|
fd4a3b5671 | ||
|
|
71adfad94d | ||
|
|
7faaaf3dcd | ||
|
|
25e7db5ac9 | ||
|
|
07bd5f1926 | ||
|
|
9439d02351 | ||
|
|
cbea7ccdf6 | ||
|
|
93661dfde4 | ||
|
|
b98f5351cf | ||
|
|
83a7261fcd | ||
|
|
daa2b7a8cd | ||
|
|
d245fedb3f | ||
|
|
b0fee2cb3c | ||
|
|
9a102056d8 | ||
|
|
3905463940 | ||
|
|
746fde592d | ||
|
|
3e5f5554da | ||
|
|
01fb6e8772 | ||
|
|
b7448232e6 | ||
|
|
05f1a24199 | ||
|
|
4072799c13 | ||
|
|
9744032f93 | ||
|
|
eb9a92d76d | ||
|
|
89a4932823 | ||
|
|
cef06a8894 | ||
|
|
c741edffb0 | ||
|
|
e7c543fcb9 | ||
|
|
2a61720b0a | ||
|
|
73484647ba | ||
|
|
c9d461f8c8 | ||
|
|
9bdc056359 | ||
|
|
6a8a1e799d | ||
|
|
c3c55f3a13 | ||
|
|
6f881a80d6 | ||
|
|
a75c4110a8 | ||
|
|
3e031c6191 | ||
|
|
a4b7ca824e | ||
|
|
ecab2b63c9 | ||
|
|
620e3d55d1 | ||
|
|
3716d7fd47 | ||
|
|
60764d198a | ||
|
|
3178d9da88 | ||
|
|
7264313c9c | ||
|
|
71c36881fb | ||
|
|
5a0f7ae838 | ||
|
|
10fb61bd57 | ||
|
|
6d4b4c6ba7 | ||
|
|
798a737f06 | ||
|
|
0a9e125f89 | ||
|
|
21b3525f23 | ||
|
|
8e842c385d | ||
|
|
8e8a587bca | ||
|
|
155aa2580b | ||
|
|
01aa381848 | ||
|
|
6c13fa02c1 | ||
|
|
c50576b508 | ||
|
|
7bc4a6906a | ||
|
|
c80318f442 | ||
|
|
cd4229a915 | ||
|
|
fbe306ba90 | ||
|
|
0dac3f1b1d | ||
|
|
7a90e6c1a7 | ||
|
|
42a4a8639d | ||
|
|
a687642a6a | ||
|
|
dbba7fc92a | ||
|
|
a0afd86b6a | ||
|
|
22b76f9919 | ||
|
|
c7a869b750 | ||
|
|
00052efbbc | ||
|
|
a36332581a | ||
|
|
8c81e6ae02 | ||
|
|
a7285f2b1a | ||
|
|
271b33ecdc | ||
|
|
b4d07cf6ab | ||
|
|
382035768e | ||
|
|
fcb825c1e2 | ||
|
|
abfeea63f7 | ||
|
|
b0ce7e6531 | ||
|
|
303aa9b580 | ||
|
|
d016d239e3 | ||
|
|
e35838c326 | ||
|
|
de15f9b56e | ||
|
|
560773a11a | ||
|
|
dd6df471dc | ||
|
|
eea5c056f3 | ||
|
|
a2d503b2f5 | ||
|
|
cf13e4a4fa | ||
|
|
45fb8e86bf | ||
|
|
099dcda185 | ||
|
|
c07e12cc5a | ||
|
|
b23f78e94d | ||
|
|
812a9a55d0 | ||
|
|
2e289e80d1 | ||
|
|
0d3dfdcbda | ||
|
|
87eae72f51 | ||
|
|
17fa7101bd | ||
|
|
312bd53079 | ||
|
|
4bc7d47576 | ||
|
|
71445b56f1 | ||
|
|
9ce9e0a4ef | ||
|
|
ae196f1aeb | ||
|
|
38e09b894d | ||
|
|
247d5ff255 | ||
|
|
0091e462fa | ||
|
|
7b314970b5 | ||
|
|
7ac881e3e3 | ||
|
|
8874723632 | ||
|
|
262bda94c4 | ||
|
|
d6e2cab5ef | ||
|
|
6d3e33a05d | ||
|
|
f2d0bec0ac | ||
|
|
dea78f4bfd | ||
|
|
f85f4b1342 | ||
|
|
d03771f8ab | ||
|
|
4b655dfac4 | ||
|
|
cdfcdd80bf | ||
|
|
64d3942ba9 | ||
|
|
16cce73f82 | ||
|
|
846edff84a | ||
|
|
d038bf31d3 | ||
|
|
376a69af5c | ||
|
|
380bb9bb3d | ||
|
|
f59e10ae1d | ||
|
|
c8d2d80cc5 | ||
|
|
f0bb9ddfca | ||
|
|
9ab86e4a85 | ||
|
|
e33f1a3ffc | ||
|
|
e2213e1ef6 | ||
|
|
bbc4a1bfa5 | ||
|
|
61e7ec9a36 | ||
|
|
534ad0bad6 | ||
|
|
db3040a50e | ||
|
|
8dd74e7dd8 | ||
|
|
206cdb2663 | ||
|
|
ca334813b7 | ||
|
|
5fc93ee8e6 | ||
|
|
9cef7b2615 | ||
|
|
a3916207ae | ||
|
|
b6e1702051 | ||
|
|
2cfc8b1ec7 | ||
|
|
2f7570eec1 | ||
|
|
070481cab0 | ||
|
|
26cd2c6cfe | ||
|
|
1ff571eb46 | ||
|
|
d8fcb4d240 | ||
|
|
778f97c1f3 | ||
|
|
1d6d9aa96d | ||
|
|
3bdd96a8ee | ||
|
|
935ad73d32 | ||
|
|
a85d55f3a8 | ||
|
|
d7c659b736 | ||
|
|
e5cedab873 | ||
|
|
3653d73f4f | ||
|
|
4af57ed861 | ||
|
|
10445c6f56 | ||
|
|
dc6051f0b0 | ||
|
|
2a524eaf22 | ||
|
|
9a810f440d | ||
|
|
27ba8db4ea | ||
|
|
7130194d5f | ||
|
|
d70afc36c9 | ||
|
|
78017b8a0e | ||
|
|
e87fdc896c | ||
|
|
7bb6d448ed | ||
|
|
6415fd9286 | ||
|
|
2dd4395698 | ||
|
|
68b6e67a93 | ||
|
|
71b35e39ab | ||
|
|
9ff6015fec | ||
|
|
124817b733 | ||
|
|
8f8f3af7cd | ||
|
|
882fe6cd00 | ||
|
|
18262f98f7 | ||
|
|
fe5a90ac2f | ||
|
|
22869b7932 | ||
|
|
e702c16a74 | ||
|
|
408690c0ae | ||
|
|
4aaf5997df | ||
|
|
f50104bc86 | ||
|
|
ee10fc18a7 | ||
|
|
818ef63aec | ||
|
|
4af374f86d | ||
|
|
277b252ad8 | ||
|
|
cc7671efd0 | ||
|
|
419276eb85 | ||
|
|
7d97b9142a | ||
|
|
c3c041f675 | ||
|
|
d790e6b731 | ||
|
|
8b714a4710 | ||
|
|
1f0d01d2ed | ||
|
|
7727cd4f58 | ||
|
|
bb6b3a57af | ||
|
|
a70a4c272c | ||
|
|
99bd4da54b | ||
|
|
3e09a5e57f | ||
|
|
1375179138 | ||
|
|
a8b1fbbef0 | ||
|
|
d490fcf5af | ||
|
|
cdbe5b2e2f | ||
|
|
15b1c756a7 | ||
|
|
3dfad93977 | ||
|
|
3fcd83b0a7 | ||
|
|
03e48881a6 | ||
|
|
6eb0b4cb5b | ||
|
|
556d8586a7 | ||
|
|
0ce6e51925 | ||
|
|
c2dec7b955 | ||
|
|
b3733ed9ed | ||
|
|
be5106c819 | ||
|
|
4875db08e8 | ||
|
|
f0593996a1 | ||
|
|
6e113cc9c6 | ||
|
|
7ffc5e6624 | ||
|
|
d1689300b9 | ||
|
|
5fc7a7dd8a | ||
|
|
f59609131c | ||
|
|
efd1733b56 | ||
|
|
31289d24e2 | ||
|
|
98be091ca6 | ||
|
|
4bfdf1dede | ||
|
|
c6a43a5dde | ||
|
|
3a20946f62 | ||
|
|
d892400ca7 | ||
|
|
44b7199087 | ||
|
|
c3fe22a76f | ||
|
|
7d9a3d39b3 | ||
|
|
c932d2b7f0 | ||
|
|
4739d43c45 | ||
|
|
b33e777028 | ||
|
|
e5718a50b2 | ||
|
|
a911bab7b0 | ||
|
|
21908bdc6f | ||
|
|
573a943467 | ||
|
|
bcc29afa2b | ||
|
|
ce693435df | ||
|
|
dad5d76664 | ||
|
|
897369d300 | ||
|
|
3d34c26731 | ||
|
|
2e4536edb6 | ||
|
|
68e16d18fe | ||
|
|
0cd071813f | ||
|
|
49f7aa30c8 | ||
|
|
74caf8a482 | ||
|
|
fb78a07662 | ||
|
|
84f5ce8a0b | ||
|
|
3f5f689965 | ||
|
|
0591b59837 | ||
|
|
4cc2551487 | ||
|
|
f15ccadc2d | ||
|
|
453ef94e4d | ||
|
|
e57b6adba1 | ||
|
|
acf8c67681 | ||
|
|
be4df15d01 | ||
|
|
bd2ef934d9 | ||
|
|
b90622a88e | ||
|
|
6868712b4e | ||
|
|
4099c5e1b5 | ||
|
|
e018f77e37 | ||
|
|
3aac617f35 | ||
|
|
8a46ebc4a0 | ||
|
|
26f63c4ea7 | ||
|
|
0e92e9fc60 | ||
|
|
0fe911b6b4 | ||
|
|
592b9a89c9 | ||
|
|
08e0df1abc | ||
|
|
8f012eee50 | ||
|
|
da766a400d | ||
|
|
ec309180da | ||
|
|
ab3b674a6e | ||
|
|
9231144518 | ||
|
|
13c04de87c | ||
|
|
70f533684f | ||
|
|
c94866631b | ||
|
|
40a77b438e | ||
|
|
f5de48ca30 | ||
|
|
89a2c00e64 | ||
|
|
35afb50b26 | ||
|
|
0e3e01bf9c | ||
|
|
6e3ebd73c6 | ||
|
|
add9b875aa | ||
|
|
b1790ee730 | ||
|
|
47d7800250 | ||
|
|
4849c281d3 | ||
|
|
c36acd7bb4 | ||
|
|
986e96a88e | ||
|
|
493b7c2d24 | ||
|
|
0539ddab85 | ||
|
|
202fdf8905 | ||
|
|
9191ed0a21 | ||
|
|
9697cf3901 | ||
|
|
e6a11294fd | ||
|
|
cd046d8023 | ||
|
|
4d08928b8c | ||
|
|
bc8a243a6d | ||
|
|
3b804e13a8 | ||
|
|
f126f927b4 | ||
|
|
d4f202c2b1 | ||
|
|
77a1d56c5b | ||
|
|
7415f94da2 | ||
|
|
fa50d8b884 | ||
|
|
40776c10bc | ||
|
|
6578a2f977 | ||
|
|
e780485fc6 | ||
|
|
8213cdba63 | ||
|
|
8d5b0d4035 | ||
|
|
3eaa22d068 | ||
|
|
4797983f43 | ||
|
|
0e7e2fc44b | ||
|
|
9a51286c54 | ||
|
|
ddbf93f2c5 | ||
|
|
418411b10d | ||
|
|
dceb7340dd | ||
|
|
e7e9ca539d | ||
|
|
333d187615 | ||
|
|
761e66b200 | ||
|
|
eec52fa5ba | ||
|
|
b6c3c03748 | ||
|
|
4eebaa5d75 | ||
|
|
f6dfe9cb88 | ||
|
|
c36c94971e | ||
|
|
e83a15ad1f | ||
|
|
16aa353cf6 | ||
|
|
5adfa89d10 | ||
|
|
b1805c1a46 | ||
|
|
7e51d70cd6 | ||
|
|
b5cba64227 | ||
|
|
f20c81efae | ||
|
|
bfbd93b912 | ||
|
|
6be074e647 | ||
|
|
5f96a562d4 | ||
|
|
cefbd70469 | ||
|
|
30c9c66087 | ||
|
|
1ecbc2f0be | ||
|
|
884a0feb62 | ||
|
|
5f44f07515 | ||
|
|
a902b79684 | ||
|
|
4e13f59b36 | ||
|
|
cbccac87f0 | ||
|
|
eb3c09a3d3 | ||
|
|
2a9a36ac88 | ||
|
|
af2f52a050 | ||
|
|
7a61fa1ee2 | ||
|
|
ac3009d58f | ||
|
|
e835feb056 | ||
|
|
cd391d14f9 | ||
|
|
d7844968ab | ||
|
|
70ea398f14 | ||
|
|
860d55a0e2 | ||
|
|
0e35cec6e2 | ||
|
|
5778e86260 | ||
|
|
967d0b1205 | ||
|
|
0b2d419000 | ||
|
|
149104063c | ||
|
|
498168a2d3 | ||
|
|
88e307416d | ||
|
|
3bb2eedb33 | ||
|
|
36c046ad6a | ||
|
|
85396df221 | ||
|
|
2f0f58783e | ||
|
|
2d989d4229 | ||
|
|
ecc8b6b385 | ||
|
|
aa90c5d5c0 | ||
|
|
5f7d93f170 | ||
|
|
0fbe51f257 | ||
|
|
be941ebdd1 | ||
|
|
4d900c2eb0 | ||
|
|
93c473afe7 | ||
|
|
4c9a66f586 | ||
|
|
375e16e0dc | ||
|
|
91085d13a3 | ||
|
|
3f83894dc6 | ||
|
|
5946684ee6 | ||
|
|
7e3f25879f | ||
|
|
48dcc3ee1b | ||
|
|
fca0a4b511 | ||
|
|
d6831a8881 | ||
|
|
39a646ed92 | ||
|
|
595965c5d0 | ||
|
|
3bb6f8a0c0 | ||
|
|
1924a2017e | ||
|
|
60140fd2e6 | ||
|
|
65b5219e45 | ||
|
|
ae2f649aee | ||
|
|
bf3e860a18 | ||
|
|
0b44a91493 | ||
|
|
16077b3341 | ||
|
|
a7cedde721 | ||
|
|
ecd53192dc | ||
|
|
a03c76e211 | ||
|
|
de427fd7a9 | ||
|
|
c37e02009f | ||
|
|
a96b8a4e07 | ||
|
|
79b4d5fb8e | ||
|
|
de128f5e6a | ||
|
|
ef8ddcde07 | ||
|
|
eaff557d70 | ||
|
|
38f7a31200 | ||
|
|
97f16289c9 | ||
|
|
e15f5ab93e | ||
|
|
15fd312765 | ||
|
|
eea316865f | ||
|
|
05bbfbbd54 | ||
|
|
6039a9d0d5 | ||
|
|
0159b02916 | ||
|
|
8bbd4dc913 | ||
|
|
9e3ded6ad5 | ||
|
|
fe63275a6b | ||
|
|
81ed465607 | ||
|
|
d9aa281ce1 | ||
|
|
56648d664e | ||
|
|
da49d5577a | ||
|
|
f3dbdefdb1 | ||
|
|
d4302759e6 | ||
|
|
914f192fb2 | ||
|
|
522b554e36 | ||
|
|
4c54ab5319 | ||
|
|
d7f4ed069c | ||
|
|
7ea0c5ee4c | ||
|
|
e773a9d9d4 | ||
|
|
b570542fab | ||
|
|
09716e98ba | ||
|
|
9236b361e2 | ||
|
|
f281d8c068 | ||
|
|
83ed17d5c1 | ||
|
|
e2671dd4ed | ||
|
|
4c4d640331 | ||
|
|
6c4307c918 | ||
|
|
5a7062c699 | ||
|
|
7da01f7404 | ||
|
|
2b695cb8c6 | ||
|
|
599817eec7 | ||
|
|
11fa33be0a | ||
|
|
b5ac9d4ce4 | ||
|
|
78f0ac0042 | ||
|
|
00ecd7adc5 | ||
|
|
c39cb3bffc | ||
|
|
2fa902bfff | ||
|
|
f8bcd351ae | ||
|
|
6013d99bf6 | ||
|
|
e7c3977f7b | ||
|
|
47e1218fe0 | ||
|
|
a71a95892f | ||
|
|
b5f53e309f | ||
|
|
3164ba2d98 | ||
|
|
89854d188d | ||
|
|
79c7475435 | ||
|
|
2ee477c35e | ||
|
|
5bcd90c569 | ||
|
|
1a49c7c59e | ||
|
|
d995932a1c | ||
|
|
1b0bbbbbfd | ||
|
|
2aa93fa341 | ||
|
|
a970f90c6f | ||
|
|
44f612fed5 | ||
|
|
564a48dd8f | ||
|
|
9d029de56a | ||
|
|
2dd3fc5d8c | ||
|
|
9c335dbdfb | ||
|
|
0e30ea92f1 | ||
|
|
a0ced4e43c | ||
|
|
cfaaf65edc | ||
|
|
35be18bb1a | ||
|
|
02296e1758 | ||
|
|
0b84b05cdd | ||
|
|
99e3d5acca | ||
|
|
8001511484 | ||
|
|
8420b2ea85 | ||
|
|
9af883acbb | ||
|
|
e21ba5ad51 | ||
|
|
1293fafd34 | ||
|
|
4bcc6bd733 | ||
|
|
53a514feb6 | ||
|
|
e697889aad | ||
|
|
8b0fba054e | ||
|
|
32ff385444 | ||
|
|
8456c7f4a3 | ||
|
|
fcbfb63645 | ||
|
|
1fa7d15982 | ||
|
|
a173978f6b | ||
|
|
2f069afc77 | ||
|
|
ea998b4e41 | ||
|
|
ba27d02854 | ||
|
|
f78df58906 | ||
|
|
308683a7e9 | ||
|
|
b3f4a6f251 | ||
|
|
d1841d8f15 | ||
|
|
c8d6de3e9b | ||
|
|
938f5c8cea | ||
|
|
d166930b0a | ||
|
|
e1ac3c0d15 | ||
|
|
59da489e05 | ||
|
|
be12c736fb | ||
|
|
71c52aae7b | ||
|
|
dbfe2af53c | ||
|
|
cca898f5b6 | ||
|
|
9abd780aa2 | ||
|
|
2e89eeca2c | ||
|
|
dbb3bead6b | ||
|
|
d0b88ec7f6 | ||
|
|
5898bc7eb1 | ||
|
|
cfe113f6c3 | ||
|
|
83500128c9 | ||
|
|
2bff3a80da | ||
|
|
3dd7b33f3e | ||
|
|
8de487b0bf | ||
|
|
ce88a6818f | ||
|
|
6172832f41 | ||
|
|
a0ed228f4b | ||
|
|
01fd56a019 | ||
|
|
087fcd340a | ||
|
|
b3b09f3c03 | ||
|
|
11d17bf21a | ||
|
|
b1ee80edee | ||
|
|
107d496adb | ||
|
|
9f1112b58d | ||
|
|
989d6e3fe7 | ||
|
|
3999c64853 | ||
|
|
760e3d6de0 | ||
|
|
02111a3b9f | ||
|
|
e6af2c0f34 | ||
|
|
bd4c639761 | ||
|
|
d39b7ec021 | ||
|
|
63ca5f5017 | ||
|
|
2202cf457b | ||
|
|
5d04b7abd6 | ||
|
|
0588d5d5f3 | ||
|
|
5a59e443d7 | ||
|
|
470f4df979 | ||
|
|
84bda71330 | ||
|
|
ea883255cb | ||
|
|
e9abb69fb5 | ||
|
|
ff63390794 | ||
|
|
78b3135276 | ||
|
|
15bd2c09ed | ||
|
|
34d44857e4 | ||
|
|
dccded2d3e | ||
|
|
295cafc060 | ||
|
|
c792e97f67 | ||
|
|
d30a02987d | ||
|
|
84d4c9cf73 | ||
|
|
21ecd1f708 | ||
|
|
248b9a8e8c | ||
|
|
3c7abfada6 | ||
|
|
f363656e0a | ||
|
|
e9ee9dbce1 | ||
|
|
ab0b8653ab | ||
|
|
20711e17fb | ||
|
|
a89bd8b816 | ||
|
|
3692cfea64 | ||
|
|
81d9d39029 | ||
|
|
f5a61ceff1 | ||
|
|
404a7b8337 | ||
|
|
71ce3a2920 | ||
|
|
3a27656769 | ||
|
|
27b1e0ffd5 | ||
|
|
1401ea74dd | ||
|
|
cb93a63970 | ||
|
|
da4ff99570 | ||
|
|
b3c0dc813b | ||
|
|
a7b51d9fcc | ||
|
|
76f1de42a8 | ||
|
|
bad016b2b4 | ||
|
|
5cd48d5447 | ||
|
|
41ff5363ea | ||
|
|
85014f4acb | ||
|
|
d9a68daddd | ||
|
|
141e78f274 | ||
|
|
de98ccd33c | ||
|
|
d490dadfdd | ||
|
|
f46bbf73ba | ||
|
|
17eba86f7a | ||
|
|
fdf25b8c66 | ||
|
|
516cb443b9 | ||
|
|
7c4c3b3f9a | ||
|
|
e298a1a8a0 | ||
|
|
fd9eef2089 | ||
|
|
78dab04c96 | ||
|
|
c34475653f | ||
|
|
eb6a6eee0a | ||
|
|
48f6a45194 | ||
|
|
c8ae6bcc78 | ||
|
|
7f6beb2a78 | ||
|
|
ea160afd90 | ||
|
|
29df0813fd | ||
|
|
b014c4a4e5 | ||
|
|
f173c21695 | ||
|
|
dc41f4946a | ||
|
|
fed754f03a | ||
|
|
382d9ed525 | ||
|
|
e3707f39bb | ||
|
|
9df8d3d360 | ||
|
|
5b3c310cda | ||
|
|
79d692771e | ||
|
|
f74ffed3ae | ||
|
|
0325d7f4f1 | ||
|
|
3926298907 | ||
|
|
d98376b490 | ||
|
|
219690afc0 | ||
|
|
bcb1fc1600 | ||
|
|
923be7e1e9 | ||
|
|
951353ee0b | ||
|
|
52bdfa7f9a | ||
|
|
4af29aa76d | ||
|
|
8efa6a742b | ||
|
|
ada5e1cca5 | ||
|
|
859191203f | ||
|
|
cab4055315 | ||
|
|
cacee7abfe | ||
|
|
61694f4c2b | ||
|
|
9c328e3d1c | ||
|
|
b2fe86c744 | ||
|
|
600e32d3e4 | ||
|
|
3ad733bab4 | ||
|
|
1799b63abb | ||
|
|
d71dc13e32 | ||
|
|
f4633788e9 | ||
|
|
2250e7db39 | ||
|
|
b1bb0ced7a | ||
|
|
28aecd79c6 | ||
|
|
d097ef45eb | ||
|
|
dac718edc8 | ||
|
|
598ab23a2c | ||
|
|
8be6e28933 | ||
|
|
bd6805be58 | ||
|
|
c147d36cb2 | ||
|
|
7a5d210167 | ||
|
|
ef335f2b8e |
14
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
14
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -9,8 +9,9 @@ body:
|
||||
请确认以下信息:
|
||||
1. 请按此模板提交issues,不按模板提交的问题将直接关闭。
|
||||
2. 如果你的问题可以直接在以往 issue 或者 Telegram频道 中找到,那么你的 issue 将会被直接关闭。
|
||||
3. 提交问题务必描述清楚、附上日志,描述不清导致无法理解和分析的问题会被直接关闭。
|
||||
3. **$\color{red}{提交问题务必描述清楚、附上日志}$**,描述不清导致无法理解和分析的问题会被直接关闭。
|
||||
4. 此仓库为后端仓库,如果是前端 WebUI 问题请在[前端仓库](https://github.com/jxxghp/MoviePilot-Frontend)提 issue。
|
||||
5. **$\color{red}{不要通过issues来寻求解决你的环境问题、配置安装类问题、咨询类问题}$**,否则直接关闭并加入用户 $\color{red}{黑名单}$ !实在没有精力陪一波又一波的伸手党玩。
|
||||
- type: checkboxes
|
||||
id: ensure
|
||||
attributes:
|
||||
@@ -32,6 +33,16 @@ body:
|
||||
description: 遇到问题时程序所在的版本号
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: 运行环境
|
||||
description: 当前程序运行环境
|
||||
options:
|
||||
- Docker
|
||||
- Windows
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: type
|
||||
attributes:
|
||||
@@ -40,7 +51,6 @@ body:
|
||||
options:
|
||||
- 主程序运行问题
|
||||
- 插件问题
|
||||
- Docker或运行环境问题
|
||||
- 其他问题
|
||||
validations:
|
||||
required: true
|
||||
|
||||
21
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
21
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -14,6 +14,27 @@ body:
|
||||
description: 目前使用的程序版本
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: 运行环境
|
||||
description: 当前程序运行环境
|
||||
options:
|
||||
- Docker
|
||||
- Windows
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: type
|
||||
attributes:
|
||||
label: 功能改进类型
|
||||
description: 你需要在下面哪个方面改进功能
|
||||
options:
|
||||
- 主程序
|
||||
- 插件
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-request
|
||||
attributes:
|
||||
|
||||
174
.github/workflows/build.yml
vendored
174
.github/workflows/build.yml
vendored
@@ -8,23 +8,20 @@ on:
|
||||
- version.py
|
||||
|
||||
jobs:
|
||||
build:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Docker Image
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
-
|
||||
name: Release version
|
||||
- name: Release version
|
||||
id: release_version
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
-
|
||||
name: Docker meta
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
@@ -33,23 +30,19 @@ jobs:
|
||||
type=raw,value=${{ env.app_version }}
|
||||
type=raw,value=latest
|
||||
|
||||
-
|
||||
name: Set Up QEMU
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
-
|
||||
name: Set Up Buildx
|
||||
- name: Set Up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
-
|
||||
name: Login DockerHub
|
||||
- name: Login DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
-
|
||||
name: Build Image
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
@@ -62,3 +55,154 @@ jobs:
|
||||
MOVIEPILOT_VERSION=${{ env.app_version }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
||||
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
||||
|
||||
Windows-build:
|
||||
runs-on: windows-latest
|
||||
name: Build Windows Binary
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Init Python 3.11.4
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11.4'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install Dependent Packages
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install wheel pyinstaller
|
||||
pip install -r requirements.txt
|
||||
shell: pwsh
|
||||
|
||||
- name: Prepare Frontend
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "http://nginx.org/download/nginx-1.25.2.zip" -OutFile "nginx.zip"
|
||||
Expand-Archive -Path "nginx.zip" -DestinationPath "nginx-1.25.2"
|
||||
Move-Item -Path "nginx-1.25.2/nginx-1.25.2" -Destination "nginx"
|
||||
Remove-Item -Path "nginx.zip"
|
||||
Remove-Item -Path "nginx-1.25.2" -Recurse -Force
|
||||
$FRONTEND_VERSION = (Invoke-WebRequest -Uri "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | ConvertFrom-Json).tag_name
|
||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/$FRONTEND_VERSION/dist.zip" -OutFile "dist.zip"
|
||||
Expand-Archive -Path "dist.zip" -DestinationPath "dist"
|
||||
Move-Item -Path "dist/dist/*" -Destination "nginx/html" -Force
|
||||
Remove-Item -Path "dist.zip"
|
||||
Remove-Item -Path "dist" -Recurse -Force
|
||||
Move-Item -Path "nginx/html/nginx.conf" -Destination "nginx/conf/nginx.conf" -Force
|
||||
New-Item -Path "nginx/temp" -ItemType Directory -Force
|
||||
New-Item -Path "nginx/temp/__keep__.txt" -ItemType File -Force
|
||||
New-Item -Path "nginx/logs" -ItemType Directory -Force
|
||||
New-Item -Path "nginx/logs/__keep__.txt" -ItemType File -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
|
||||
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
|
||||
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force
|
||||
Remove-Item -Path "MoviePilot-Plugins-main.zip"
|
||||
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" -OutFile "MoviePilot-Resources-main.zip"
|
||||
Expand-Archive -Path "MoviePilot-Resources-main.zip" -DestinationPath "MoviePilot-Resources-main"
|
||||
Move-Item -Path "MoviePilot-Resources-main/MoviePilot-Resources-main/resources/*" -Destination "app/helper/" -Force
|
||||
Remove-Item -Path "MoviePilot-Resources-main.zip"
|
||||
Remove-Item -Path "MoviePilot-Resources-main" -Recurse -Force
|
||||
shell: pwsh
|
||||
|
||||
- name: Pyinstaller
|
||||
run: |
|
||||
pyinstaller frozen.spec
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Windows File
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: windows
|
||||
path: dist/MoviePilot.exe
|
||||
|
||||
Linux-build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Linux Amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Init Python 3.11.4
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11.4'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install Dependent Packages
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install wheel pyinstaller
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Prepare Frontend
|
||||
run: |
|
||||
wget https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip
|
||||
unzip main.zip
|
||||
mv MoviePilot-Plugins-main/plugins/* app/plugins/
|
||||
rm main.zip
|
||||
rm -rf MoviePilot-Plugins-main
|
||||
|
||||
wget https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip
|
||||
unzip main.zip
|
||||
mv MoviePilot-Resources-main/resources/* app/helper/
|
||||
rm main.zip
|
||||
rm -rf MoviePilot-Resources-main
|
||||
|
||||
- name: Pyinstaller
|
||||
run: |
|
||||
pyinstaller frozen.spec
|
||||
mv dist/MoviePilot dist/MoviePilot_Amd64
|
||||
|
||||
- name: Upload Linux File
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: linux-amd64
|
||||
path: dist/MoviePilot_Amd64
|
||||
|
||||
Create-release:
|
||||
permissions: write-all
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Windows-build, Docker-build, Linux-build-amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Release Version
|
||||
id: release_version
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v3
|
||||
|
||||
- name: get release_informations
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir releases
|
||||
mv ./windows/MoviePilot.exe ./releases/MoviePilot_Win_v${{ env.app_version }}.exe
|
||||
mv ./linux-amd64/MoviePilot_Amd64 ./releases/MoviePilot_Amd64_v${{ env.app_version }}
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ env.app_version }}
|
||||
release_name: v${{ env.app_version }}
|
||||
body: ${{ github.event.commits[0].message }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload Release Asset
|
||||
uses: dwenegar/upload-release-assets@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
release_id: ${{ steps.create_release.outputs.id }}
|
||||
assets_path: |
|
||||
./releases/
|
||||
|
||||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
@@ -1,36 +0,0 @@
|
||||
name: MoviePilot Release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- version.py
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Docker Image
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
-
|
||||
name: Release Version
|
||||
id: release_version
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
-
|
||||
name: Generate Release
|
||||
uses: actions/create-release@latest
|
||||
with:
|
||||
tag_name: v${{ env.app_version }}
|
||||
release_name: v${{ env.app_version }}
|
||||
body: ${{ github.event.commits[0].message }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,8 +1,16 @@
|
||||
.idea/
|
||||
*.c
|
||||
build/
|
||||
dist/
|
||||
nginx/
|
||||
test.py
|
||||
app/helper/sites.py
|
||||
app/helper/*.so
|
||||
app/helper/*.pyd
|
||||
app/helper/*.bin
|
||||
app/plugins/**
|
||||
!app/plugins/__init__.py
|
||||
config/user.db
|
||||
config/sites/**
|
||||
*.pyc
|
||||
*.log
|
||||
|
||||
83
Dockerfile
83
Dockerfile
@@ -1,41 +1,21 @@
|
||||
FROM python:3.11.4-slim-bullseye
|
||||
ARG MOVIEPILOT_VERSION
|
||||
ENV LANG="C.UTF-8" \
|
||||
HOME="/moviepilot" \
|
||||
TERM="xterm" \
|
||||
TZ="Asia/Shanghai" \
|
||||
HOME="/moviepilot" \
|
||||
CONFIG_DIR="/config" \
|
||||
TERM="xterm" \
|
||||
PUID=0 \
|
||||
PGID=0 \
|
||||
UMASK=000 \
|
||||
MOVIEPILOT_AUTO_UPDATE=true \
|
||||
MOVIEPILOT_AUTO_UPDATE_DEV=false \
|
||||
PORT=3001 \
|
||||
NGINX_PORT=3000 \
|
||||
CONFIG_DIR="/config" \
|
||||
API_TOKEN="moviepilot" \
|
||||
PROXY_HOST="" \
|
||||
MOVIEPILOT_AUTO_UPDATE=release \
|
||||
AUTH_SITE="iyuu" \
|
||||
DOWNLOAD_PATH="/downloads" \
|
||||
DOWNLOAD_CATEGORY="false" \
|
||||
TORRENT_TAG="MOVIEPILOT" \
|
||||
LIBRARY_PATH="" \
|
||||
LIBRARY_CATEGORY="false" \
|
||||
TRANSFER_TYPE="copy" \
|
||||
COOKIECLOUD_HOST="https://movie-pilot.org/cookiecloud" \
|
||||
COOKIECLOUD_KEY="" \
|
||||
COOKIECLOUD_PASSWORD="" \
|
||||
MESSAGER="telegram" \
|
||||
TELEGRAM_TOKEN="" \
|
||||
TELEGRAM_CHAT_ID="" \
|
||||
DOWNLOADER="qbittorrent" \
|
||||
QB_HOST="127.0.0.1:8080" \
|
||||
QB_USER="admin" \
|
||||
QB_PASSWORD="adminadmin" \
|
||||
MEDIASERVER="emby" \
|
||||
EMBY_HOST="http://127.0.0.1:8096" \
|
||||
EMBY_API_KEY=""
|
||||
IYUU_SIGN=""
|
||||
WORKDIR "/app"
|
||||
COPY . .
|
||||
RUN apt-get update \
|
||||
RUN apt-get update -y \
|
||||
&& apt-get -y install \
|
||||
musl-dev \
|
||||
nginx \
|
||||
@@ -50,32 +30,30 @@ RUN apt-get update \
|
||||
dumb-init \
|
||||
jq \
|
||||
haproxy \
|
||||
fuse3 \
|
||||
rsync \
|
||||
ffmpeg \
|
||||
&& \
|
||||
if [ "$(uname -m)" = "x86_64" ]; \
|
||||
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
|
||||
elif [ "$(uname -m)" = "aarch64" ]; \
|
||||
then ln -s /usr/lib/aarch64-linux-musl/libc.so /lib/libc.musl-aarch64.so.1; \
|
||||
fi \
|
||||
&& cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
||||
&& cp -f /app/update /usr/local/bin/mp_update \
|
||||
&& cp -f /app/entrypoint /entrypoint \
|
||||
&& chmod +x /entrypoint /usr/local/bin/mp_update \
|
||||
&& mkdir -p ${HOME} /var/lib/haproxy/server-state \
|
||||
&& groupadd -r moviepilot -g 911 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 911 \
|
||||
&& curl https://rclone.org/install.sh | bash \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf \
|
||||
/tmp/* \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y build-essential \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install Cython \
|
||||
&& pip install -r requirements.txt \
|
||||
&& playwright install-deps chromium \
|
||||
&& python_ver=$(python3 -V | awk '{print $2}') \
|
||||
&& echo "/app/" > /usr/local/lib/python${python_ver%.*}/site-packages/app.pth \
|
||||
&& echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \
|
||||
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
|
||||
&& locale-gen zh_CN.UTF-8 \
|
||||
&& FRONTEND_VERSION=$(curl -sL "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | jq -r .tag_name) \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
|
||||
&& mv /dist /public \
|
||||
&& apt-get remove -y build-essential \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
@@ -84,6 +62,27 @@ RUN apt-get update \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY . .
|
||||
RUN cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
||||
&& cp -f /app/update /usr/local/bin/mp_update \
|
||||
&& cp -f /app/entrypoint /entrypoint \
|
||||
&& chmod +x /entrypoint /usr/local/bin/mp_update \
|
||||
&& mkdir -p ${HOME} /var/lib/haproxy/server-state \
|
||||
&& groupadd -r moviepilot -g 911 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 911 \
|
||||
&& python_ver=$(python3 -V | awk '{print $2}') \
|
||||
&& echo "/app/" > /usr/local/lib/python${python_ver%.*}/site-packages/app.pth \
|
||||
&& echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \
|
||||
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
|
||||
&& locale-gen zh_CN.UTF-8 \
|
||||
&& FRONTEND_VERSION=$(curl -sL "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | jq -r .tag_name) \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
|
||||
&& mv /dist /public \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
||||
&& mv -f /tmp/MoviePilot-Plugins-main/plugins/* /app/app/plugins/ \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
||||
&& mv -f /tmp/MoviePilot-Resources-main/resources/* /app/app/helper/ \
|
||||
&& rm -rf /tmp/*
|
||||
EXPOSE 3000
|
||||
VOLUME [ "/config" ]
|
||||
ENTRYPOINT [ "/entrypoint" ]
|
||||
|
||||
356
README.md
356
README.md
@@ -4,239 +4,212 @@
|
||||
|
||||
# 仅用于学习交流使用,请勿在任何国内平台宣传该项目!
|
||||
|
||||
Docker:https://hub.docker.com/r/jxxghp/moviepilot
|
||||
|
||||
发布频道:https://t.me/moviepilot_channel
|
||||
|
||||
## 主要特性
|
||||
- 前后端分离,基于FastApi + Vue3,前端项目地址:[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)
|
||||
- 前后端分离,基于FastApi + Vue3,前端项目地址:[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend),API:http://localhost:3001/docs
|
||||
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
|
||||
- 重新设计了用户界面,更加美观易用。
|
||||
|
||||
## 安装
|
||||
|
||||
1. **安装CookieCloud插件**
|
||||
### 注意:管理员用户不要使用弱密码!如非必要不要暴露到公网。如被盗取管理账号权限,将会导致站点Cookie等敏感数据泄露!
|
||||
|
||||
### 1. **安装CookieCloud插件**
|
||||
|
||||
站点信息需要通过CookieCloud同步获取,因此需要安装CookieCloud插件,将浏览器中的站点Cookie数据同步到云端后再同步到MoviePilot使用。 插件下载地址请点击 [这里](https://github.com/easychen/CookieCloud/releases)。
|
||||
|
||||
2. **安装CookieCloud服务端(可选)**
|
||||
### 2. **安装CookieCloud服务端(可选)**
|
||||
|
||||
MoviePilot内置了公共CookieCloud服务器,如果需要自建服务,可参考 [CookieCloud](https://github.com/easychen/CookieCloud) 项目进行搭建,docker镜像请点击 [这里](https://hub.docker.com/r/easychen/cookiecloud)。
|
||||
|
||||
**声明:** 本项目不会收集用户敏感数据,Cookie同步也是基于CookieCloud项目实现,非本项目提供的能力。技术角度上CookieCloud采用端到端加密,在个人不泄露`用户KEY`和`端对端加密密码`的情况下第三方无法窃取任何用户信息(包括服务器持有者)。如果你不放心,可以不使用公共服务或者不使用本项目,但如果使用后发生了任何信息泄露与本项目无关!
|
||||
|
||||
3. **安装配套管理软件**
|
||||
### 3. **安装配套管理软件**
|
||||
|
||||
MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- 下载器支持:qBittorrent、Transmission,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,推荐使用QB。
|
||||
- 媒体服务器支持:Jellyfin、Emby、Plex,推荐使用Emby。
|
||||
|
||||
4. **安装MoviePilot**
|
||||
### 4. **安装MoviePilot**
|
||||
|
||||
目前仅提供docker镜像,点击 [这里](https://hub.docker.com/r/jxxghp/moviepilot) 或执行命令:
|
||||
- Docker镜像
|
||||
|
||||
```shell
|
||||
docker pull jxxghp/moviepilot:latest
|
||||
```
|
||||
点击 [这里](https://hub.docker.com/r/jxxghp/moviepilot) 或执行命令:
|
||||
|
||||
```shell
|
||||
docker pull jxxghp/moviepilot:latest
|
||||
```
|
||||
|
||||
- Windows
|
||||
|
||||
下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases),双击运行后自动生成配置文件目录,访问:http://localhost:3000
|
||||
|
||||
- 群晖套件
|
||||
|
||||
添加套件源:https://spk7.imnks.com/
|
||||
|
||||
- 本地运行
|
||||
|
||||
1) 将工程 [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins) plugins目录下的所有文件复制到`app/plugins`目录
|
||||
2) 将工程 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) resources目录下的所有文件复制到`app/helper`目录
|
||||
3) 执行命令:`pip install -r requirements.txt` 安装依赖
|
||||
4) 执行命令:`PYTHONPATH=. python app/main.py` 启动服务
|
||||
5) 根据前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend) 说明,启动前端服务
|
||||
|
||||
## 配置
|
||||
|
||||
项目的所有配置均通过环境变量进行设置,部分环境建立容器后会自动显示待配置项,如未自动显示配置项则需要手动增加对应环境变量。
|
||||
大部分配置可启动后通过WEB管理界面进行配置,但仍有部分配置需要通过环境变量/配置文件进行配置。
|
||||
|
||||
配置文件映射路径:`/config`
|
||||
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件(或通过WEB界面配置) > 默认值。
|
||||
|
||||
### 1. **基础设置**
|
||||
> ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
|
||||
|
||||
### 1. **环境变量**
|
||||
|
||||
- **❗NGINX_PORT:** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突
|
||||
- **❗PORT:** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突
|
||||
- **PUID**:运行程序用户的`uid`,默认`0`
|
||||
- **PGID**:运行程序用户的`gid`,默认`0`
|
||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
|
||||
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`false`,默认`true` **注意:如果出现网络问题可以配置`PROXY_HOST`,具体看下方`PROXY_HOST`解释**
|
||||
- **NGINX_PORT:** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突
|
||||
- **PORT:** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突
|
||||
- **SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
|
||||
- **SUPERUSER_PASSWORD:** 超级管理员初始密码,默认`password`,建议修改为复杂密码
|
||||
- **API_TOKEN:** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
||||
- **PROXY_HOST:** 网络代理(可选),访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`
|
||||
- **TMDB_API_DOMAIN:** TMDB API地址,默认`api.themoviedb.org`,也可配置为`api.tmdb.org`或其它中转代理服务地址,能连通即可
|
||||
- **DOWNLOAD_PATH:** 下载保存目录,**注意:需要将`moviepilot`及`下载器`的映射路径保持一致**,否则会导致下载文件无法转移
|
||||
- **DOWNLOAD_MOVIE_PATH:** 电影下载保存目录,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_TV_PATH:** 电视剧下载保存目录,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_ANIME_PATH:** 动漫下载保存目录,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_CATEGORY:** 下载二级分类开关,`true`/`false`,默认`false`,开启后会根据配置`category.yaml`自动在下载目录下建立二级目录分类
|
||||
- **PROXY_HOST:** 网络代理,访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`、`socks5://user:pass@host:port`
|
||||
- **MOVIEPILOT_AUTO_UPDATE:** 重启时自动更新,`true`/`release`/`dev`/`false`,默认`release`,需要能正常连接Github **注意:如果出现网络问题可以配置`PROXY_HOST`**
|
||||
- **❗AUTH_SITE:** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
|
||||
|
||||
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
|
||||
认证资源`v1.1.4`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`
|
||||
|
||||
| 站点 | 参数 |
|
||||
|:------------:|:-----------------------------------------------------:|
|
||||
| iyuu | `IYUU_SIGN`:IYUU登录令牌 |
|
||||
| hhclub | `HHCLUB_USERNAME`:用户名<br/>`HHCLUB_PASSKEY`:密钥 |
|
||||
| audiences | `AUDIENCES_UID`:用户ID<br/>`AUDIENCES_PASSKEY`:密钥 |
|
||||
| hddolby | `HDDOLBY_ID`:用户ID<br/>`HDDOLBY_PASSKEY`:密钥 |
|
||||
| zmpt | `ZMPT_UID`:用户ID<br/>`ZMPT_PASSKEY`:密钥 |
|
||||
| freefarm | `FREEFARM_UID`:用户ID<br/>`FREEFARM_PASSKEY`:密钥 |
|
||||
| hdfans | `HDFANS_UID`:用户ID<br/>`HDFANS_PASSKEY`:密钥 |
|
||||
| wintersakura | `WINTERSAKURA_UID`:用户ID<br/>`WINTERSAKURA_PASSKEY`:密钥 |
|
||||
| leaves | `LEAVES_UID`:用户ID<br/>`LEAVES_PASSKEY`:密钥 |
|
||||
| ptba | `PTBA_UID`:用户ID<br/>`PTBA_PASSKEY`:密钥 |
|
||||
| icc2022 | `ICC2022_UID`:用户ID<br/>`ICC2022_PASSKEY`:密钥 |
|
||||
| ptlsp | `PTLSP_UID`:用户ID<br/>`PTLSP_PASSKEY`:密钥 |
|
||||
| xingtan | `XINGTAN_UID`:用户ID<br/>`XINGTAN_PASSKEY`:密钥 |
|
||||
| ptvicomo | `PTVICOMO_UID`:用户ID<br/>`PTVICOMO_PASSKEY`:密钥 |
|
||||
| agsvpt | `AGSVPT_UID`:用户ID<br/>`AGSVPT_PASSKEY`:密钥 |
|
||||
| hdkyl | `HDKYL_UID`:用户ID<br/>`HDKYL_PASSKEY`:密钥 |
|
||||
|
||||
|
||||
### 2. **环境变量 / 配置文件**
|
||||
|
||||
配置文件名:`app.env`,放配置文件根目录。
|
||||
|
||||
- **❗SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面,**注意:启动一次后再次修改该值不会生效,除非删除数据库文件!**
|
||||
- **❗API_TOKEN:** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
||||
- **BIG_MEMORY_MODE:** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快
|
||||
- **META_CACHE_EXPIRE:** 元数据识别缓存过期时间(小时),数字型,不配置或者配置为0时使用系统默认(大内存模式为7天,否则为3天),调大该值可减少themoviedb的访问次数
|
||||
- **GITHUB_TOKEN:** Github token,提高自动更新、插件安装等请求Github Api的限流阈值,格式:ghp_****
|
||||
- **DEV:** 开发者模式,`true`/`false`,默认`false`,开启后会暂停所有定时任务
|
||||
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`,需要能正常连接Github,仅支持Docker镜像
|
||||
---
|
||||
- **TMDB_API_DOMAIN:** TMDB API地址,默认`api.themoviedb.org`,也可配置为`api.tmdb.org`、`tmdb.movie-pilot.org` 或其它中转代理服务地址,能连通即可
|
||||
- **TMDB_IMAGE_DOMAIN:** TMDB图片地址,默认`image.tmdb.org`,可配置为其它中转代理以加速TMDB图片显示,如:`static-mdb.v.geilijiasu.com`
|
||||
- **WALLPAPER:** 登录首页电影海报,`tmdb`/`bing`,默认`tmdb`
|
||||
- **RECOGNIZE_SOURCE:** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类
|
||||
- **FANART_ENABLE:** Fanart开关,`true`/`false`,默认`true`,关闭后刮削的图片类型会大幅减少
|
||||
- **SCRAP_SOURCE:** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
|
||||
- **SCRAP_FOLLOW_TMDB:** 新增已入库媒体是否跟随TMDB信息变化,`true`/`false`,默认`true`,为`false`时即使TMDB信息变化了也会仍然按历史记录中已入库的信息进行刮削
|
||||
---
|
||||
- **AUTO_DOWNLOAD_USER:** 远程交互搜索时自动择优下载的用户ID(消息通知渠道的用户ID),多个用户使用,分割,设置为 all 代表全部用户自动择优下载,未设置需要手动选择资源或者回复`0`才自动择优下载
|
||||
---
|
||||
- **OCR_HOST:** OCR识别服务器地址,格式:`http(s)://ip:port`,用于识别站点验证码实现自动登录获取Cookie等,不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
|
||||
---
|
||||
- **DOWNLOAD_SUBTITLE:** 下载站点字幕,`true`/`false`,默认`true`
|
||||
- **REFRESH_MEDIASERVER:** 入库刷新媒体库,`true`/`false`,默认`true`
|
||||
- **SCRAP_METADATA:** 刮削入库的媒体文件,`true`/`false`,默认`true`
|
||||
- **SCRAP_FOLLOW_TMDB:** 新增已入库媒体是否跟随TMDB信息变化,`true`/`false`,默认`true`
|
||||
- **TORRENT_TAG:** 种子标签,默认为`MOVIEPILOT`,设置后只有MoviePilot添加的下载才会处理,留空所有下载器中的任务均会处理
|
||||
- **LIBRARY_PATH:** 媒体库目录,多个目录使用`,`分隔
|
||||
- **LIBRARY_MOVIE_NAME:** 电影媒体库目录名,默认`电影`
|
||||
- **LIBRARY_TV_NAME:** 电视剧媒体库目录名,默认`电视剧`
|
||||
- **LIBRARY_ANIME_NAME:** 动漫媒体库目录名,默认`电视剧/动漫`
|
||||
- **LIBRARY_CATEGORY:** 媒体库二级分类开关,`true`/`false`,默认`false`,开启后会根据配置`category.yaml`自动在媒体库目录下建立二级目录分类
|
||||
- **TRANSFER_TYPE:** 转移方式,支持`link`/`copy`/`move`/`softlink` **注意:在`link`和`softlink`转移方式下,转移后的文件会继承源文件的权限掩码,不受`UMASK`影响**
|
||||
- **COOKIECLOUD_HOST:** CookieCloud服务器地址,格式:`http(s)://ip:port`,不配置默认使用内建服务器`https://movie-pilot.org/cookiecloud`
|
||||
- **COOKIECLOUD_KEY:** CookieCloud用户KEY
|
||||
- **COOKIECLOUD_PASSWORD:** CookieCloud端对端加密密码
|
||||
- **COOKIECLOUD_INTERVAL:** CookieCloud同步间隔(分钟)
|
||||
- **OCR_HOST:** OCR识别服务器地址,格式:`http(s)://ip:port`,用于识别站点二维码实现自动登录获取Cookie等,不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
|
||||
- **USER_AGENT:** CookieCloud对应的浏览器UA,可选,设置后可增加连接站点的成功率,同步站点后可以在管理界面中修改
|
||||
- **AUTO_DOWNLOAD_USER:** 交互搜索自动下载用户ID,使用,分割
|
||||
- **SUBSCRIBE_MODE:** 订阅模式,`rss`/`spider`,默认`spider`,`rss`模式通过定时刷新RSS来匹配订阅(RSS地址会自动获取,也可手动维护),对站点压力小,同时可设置订阅刷新周期,24小时运行,但订阅和下载通知不能过滤和显示免费,推荐使用rss模式。
|
||||
- **SUBSCRIBE_RSS_INTERVAL:** RSS订阅模式刷新时间间隔(分钟),默认`30`分钟,不能小于5分钟。
|
||||
- **SUBSCRIBE_SEARCH:** 订阅搜索,`true`/`false`,默认`false`,开启后会每隔24小时对所有订阅进行全量搜索,以补齐缺失剧集(一般情况下正常订阅即可,订阅搜索只做为兜底,会增加站点压力,不建议开启)。
|
||||
- **MESSAGER:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
||||
---
|
||||
- **MOVIE_RENAME_FORMAT:** 电影重命名格式,基于jinjia2语法
|
||||
|
||||
- `wechat`设置项:
|
||||
`MOVIE_RENAME_FORMAT`支持的配置项:
|
||||
|
||||
- **WECHAT_CORPID:** WeChat企业ID
|
||||
- **WECHAT_APP_SECRET:** WeChat应用Secret
|
||||
- **WECHAT_APP_ID:** WeChat应用ID
|
||||
- **WECHAT_TOKEN:** WeChat消息回调的Token
|
||||
- **WECHAT_ENCODING_AESKEY:** WeChat消息回调的EncodingAESKey
|
||||
- **WECHAT_ADMINS:** WeChat管理员列表,多个管理员用英文逗号分隔(可选)
|
||||
- **WECHAT_PROXY:** WeChat代理服务器(后面不要加/)
|
||||
> `title`: TMDB/豆瓣中的标题
|
||||
> `en_title`: TMDB中的英文标题 (暂不支持豆瓣)
|
||||
> `original_title`: TMDB/豆瓣中的原语种标题
|
||||
> `name`: 从文件名中识别的名称(同时存在中英文时,优先使用中文)
|
||||
> `en_name`:从文件名中识别的英文名称(可能为空)
|
||||
> `original_name`: 原文件名(包括文件外缀)
|
||||
> `year`: 年份
|
||||
> `resourceType`:资源类型
|
||||
> `effect`:特效
|
||||
> `edition`: 版本(资源类型+特效)
|
||||
> `videoFormat`: 分辨率
|
||||
> `releaseGroup`: 制作组/字幕组
|
||||
> `customization`: 自定义占位符
|
||||
> `videoCodec`: 视频编码
|
||||
> `audioCodec`: 音频编码
|
||||
> `tmdbid`: TMDB ID(非TMDB识别源时为空)
|
||||
> `imdbid`: IMDB ID(可能为空)
|
||||
> `doubanid`:豆瓣ID(非豆瓣识别源时为空)
|
||||
> `part`:段/节
|
||||
> `fileExt`:文件扩展名
|
||||
> `customization`:自定义占位符
|
||||
|
||||
`MOVIE_RENAME_FORMAT`默认配置格式:
|
||||
|
||||
```
|
||||
{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}
|
||||
```
|
||||
|
||||
- `telegram`设置项:
|
||||
- **TV_RENAME_FORMAT:** 电视剧重命名格式,基于jinjia2语法
|
||||
|
||||
- **TELEGRAM_TOKEN:** Telegram Bot Token
|
||||
- **TELEGRAM_CHAT_ID:** Telegram Chat ID
|
||||
- **TELEGRAM_USERS:** Telegram 用户ID,多个使用,分隔,只有用户ID在列表中才可以使用Bot,如未设置则均可以使用Bot
|
||||
- **TELEGRAM_ADMINS:** Telegram 管理员ID,多个使用,分隔,只有管理员才可以操作Bot菜单,如未设置则均可以操作菜单
|
||||
`TV_RENAME_FORMAT`额外支持的配置项:
|
||||
|
||||
> `season`: 季号
|
||||
> `episode`: 集号
|
||||
> `season_episode`: 季集 SxxExx
|
||||
> `episode_title`: 集标题
|
||||
|
||||
`TV_RENAME_FORMAT`默认配置格式:
|
||||
|
||||
```
|
||||
{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}
|
||||
```
|
||||
|
||||
|
||||
- `slack`设置项:
|
||||
### 3. **优先级规则**
|
||||
|
||||
- **SLACK_OAUTH_TOKEN:** Slack Bot User OAuth Token
|
||||
- **SLACK_APP_TOKEN:** Slack App-Level Token
|
||||
- **SLACK_CHANNEL:** Slack 频道名称,默认`全体`
|
||||
|
||||
|
||||
- **DOWNLOADER:** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
|
||||
|
||||
- `qbittorrent`设置项:
|
||||
|
||||
- **QB_HOST:** qbittorrent地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **QB_USER:** qbittorrent用户名
|
||||
- **QB_PASSWORD:** qbittorrent密码
|
||||
- **QB_CATEGORY:** qbittorrent分类自动管理,`true`/`false`,默认`false`,开启后会将下载二级分类传递到下载器,由下载器管理下载目录,需要同步开启`DOWNLOAD_CATEGORY`
|
||||
|
||||
- `transmission`设置项:
|
||||
|
||||
- **TR_HOST:** transmission地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **TR_USER:** transmission用户名
|
||||
- **TR_PASSWORD:** transmission密码
|
||||
|
||||
- **DOWNLOADER_MONITOR:** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
|
||||
|
||||
- **MEDIASERVER:** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
|
||||
|
||||
- `emby`设置项:
|
||||
|
||||
- **EMBY_HOST:** Emby服务器地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **EMBY_API_KEY:** Emby Api Key,在`设置->高级->API密钥`处生成
|
||||
|
||||
- `jellyfin`设置项:
|
||||
|
||||
- **JELLYFIN_HOST:** Jellyfin服务器地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **JELLYFIN_API_KEY:** Jellyfin Api Key,在`设置->高级->API密钥`处生成
|
||||
|
||||
- `plex`设置项:
|
||||
|
||||
- **PLEX_HOST:** Plex服务器地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **PLEX_TOKEN:** Plex网页Url中的`X-Plex-Token`,通过浏览器F12->网络从请求URL中获取
|
||||
|
||||
- **MEDIASERVER_SYNC_INTERVAL:** 媒体服务器同步间隔(小时),默认`6`,留空则不同步
|
||||
|
||||
|
||||
### 2. **用户认证**
|
||||
|
||||
- **AUTH_SITE:** 认证站点,支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`ptlsp`
|
||||
|
||||
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
|
||||
|
||||
| 站点 | 参数 |
|
||||
|:------------:|:-----------------------------------------------------:|
|
||||
| iyuu | `IYUU_SIGN`:IYUU登录令牌 |
|
||||
| hhclub | `HHCLUB_USERNAME`:用户名<br/>`HHCLUB_PASSKEY`:密钥 |
|
||||
| audiences | `AUDIENCES_UID`:用户ID<br/>`AUDIENCES_PASSKEY`:密钥 |
|
||||
| hddolby | `HDDOLBY_ID`:用户ID<br/>`HDDOLBY_PASSKEY`:密钥 |
|
||||
| zmpt | `ZMPT_UID`:用户ID<br/>`ZMPT_PASSKEY`:密钥 |
|
||||
| freefarm | `FREEFARM_UID`:用户ID<br/>`FREEFARM_PASSKEY`:密钥 |
|
||||
| hdfans | `HDFANS_UID`:用户ID<br/>`HDFANS_PASSKEY`:密钥 |
|
||||
| wintersakura | `WINTERSAKURA_UID`:用户ID<br/>`WINTERSAKURA_PASSKEY`:密钥 |
|
||||
| leaves | `LEAVES_UID`:用户ID<br/>`LEAVES_PASSKEY`:密钥 |
|
||||
| 1ptba | `1PTBA_UID`:用户ID<br/>`1PTBA_PASSKEY`:密钥 |
|
||||
| icc2022 | `ICC2022_UID`:用户ID<br/>`ICC2022_PASSKEY`:密钥 |
|
||||
| ptlsp | `PTLSP_UID`:用户ID<br/>`PTLSP_PASSKEY`:密钥 |
|
||||
|
||||
|
||||
### 2. **进阶配置**
|
||||
|
||||
- **BIG_MEMORY_MODE:** 大内存模式,默认为`false`,开启后会占用更多的内存,但响应速度会更快
|
||||
|
||||
- **MOVIE_RENAME_FORMAT:** 电影重命名格式
|
||||
|
||||
`MOVIE_RENAME_FORMAT`支持的配置项:
|
||||
|
||||
> `title`: 标题
|
||||
> `original_name`: 原文件名
|
||||
> `original_title`: 原语种标题
|
||||
> `name`: 识别名称
|
||||
> `year`: 年份
|
||||
> `edition`: 版本
|
||||
> `videoFormat`: 分辨率
|
||||
> `releaseGroup`: 制作组/字幕组
|
||||
> `effect`: 特效
|
||||
> `videoCodec`: 视频编码
|
||||
> `audioCodec`: 音频编码
|
||||
> `tmdbid`: TMDBID
|
||||
> `imdbid`: IMDBID
|
||||
> `part`:段/节
|
||||
> `fileExt`:文件扩展名
|
||||
|
||||
`MOVIE_RENAME_FORMAT`默认配置格式:
|
||||
|
||||
```
|
||||
{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}
|
||||
```
|
||||
|
||||
- **TV_RENAME_FORMAT:** 电视剧重命名格式
|
||||
|
||||
`TV_RENAME_FORMAT`额外支持的配置项:
|
||||
|
||||
> `season`: 季号
|
||||
> `episode`: 集号
|
||||
> `season_episode`: 季集 SxxExx
|
||||
|
||||
`TV_RENAME_FORMAT`默认配置格式:
|
||||
|
||||
```
|
||||
{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}
|
||||
```
|
||||
|
||||
|
||||
### 3. **过滤规则**
|
||||
|
||||
在`设定`-`规则`中设定,规则说明:
|
||||
|
||||
- 仅支持使用内置规则进行排列组合,内置规则有:`蓝光原盘`、`4K`、`1080P`、`中文字幕`、`特效字幕`、`H265`、`H264`、`杜比`、`HDR`、`REMUX`、`WEB-DL`、`免费`、`国语配音` 等
|
||||
- 仅支持使用内置规则进行排列组合,通过设置多层规则来实现优先级顺序匹配
|
||||
- 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高
|
||||
- 不符合过滤规则所有层级规则的资源将不会被选中
|
||||
|
||||
### 4. **插件扩展**
|
||||
|
||||
- **PLUGIN_MARKET:** 插件市场仓库地址,仅支持Github仓库`main`分支,多个地址使用`,`分隔,默认为官方插件仓库:`https://github.com/jxxghp/MoviePilot-Plugins` ,通过查看[MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)项目的fork,或者查看频道置顶了解更多第三方插件仓库。
|
||||
|
||||
|
||||
## 使用
|
||||
|
||||
- 通过CookieCloud同步快速同步站点,不需要使用的站点可在WEB管理界面中禁用,无法同步的站点可手动新增。
|
||||
- 通过WEB进行管理,将WEB添加到手机桌面获得类App使用效果,管理界面端口:`3000`,后台API端口:`3001`。
|
||||
- 通过下载器监控或使用目录监控插件实现自动整理入库刮削(二选一)。
|
||||
- 通过微信/Telegram/Slack远程管理,其中微信/Telegram将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示),微信需要在官方页面设置回调地址,地址相对路径为:`/api/v1/message/`。
|
||||
- 设置媒体服务器Webhook,通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot`(`3001`端口),其中`moviepilot`为设置的`API_TOKEN`。
|
||||
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr(`3001`端口),可使用Overseerr/Jellyseerr浏览订阅。
|
||||
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`
|
||||
### 1. **WEB后台管理**
|
||||
- 通过设置的超级管理员用户登录后台管理界面(`SUPERUSER`配置项,默认用户:admin,默认端口:3000)
|
||||
> ❗**注意:超级管理员用户初始密码为自动生成,需要在首次运行时的后台日志中查看!** 如首次运行日志丢失,则需要删除配置文件目录下的`user.db`文件,然后重启服务。
|
||||
### 2. **站点维护**
|
||||
- 通过CookieCloud同步快速添加站点,不需要使用的站点可在WEB管理界面中禁用或删除,无法同步的站点也可手动新增。
|
||||
- 需要通过环境变量设置用户认证信息且认证成功后才能使用站点相关功能,未认证通过时站点相关的插件也会无法显示。
|
||||
### 3. **文件整理**
|
||||
- 默认通过监控下载器实现下载完成后自动整理入库并刮削媒体信息,需要后台打开`下载器监控`开关,且仅会处理通过MoviePilot添加下载的任务。
|
||||
- 使用`目录监控`等插件实现更灵活的自动整理。
|
||||
### 4. **通知交互**
|
||||
- 支持通过`微信`/`Telegram`/`Slack`/`SynologyChat`/`VoceChat`等渠道远程管理和订阅下载,其中 微信/Telegram 将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示)。
|
||||
- `微信`回调地址、`SynologyChat`传入地址地址相对路径均为:`/api/v1/message/`;`VoceChat`的Webhook地址相对路径为:`/api/v1/message/?token=moviepilot`,其中moviepilot为设置的`API_TOKEN`。
|
||||
### 5. **订阅与搜索**
|
||||
- 通过MoviePilot管理后台搜索和订阅。
|
||||
- 将MoviePilot做为`Radarr`或`Sonarr`服务器添加到`Overseerr`或`Jellyseerr`,可使用`Overseerr/Jellyseerr`浏览和添加订阅。
|
||||
- 安装`豆瓣榜单订阅`、`猫眼订阅`等插件,实现自动订阅豆瓣榜单、猫眼榜单等。
|
||||
### 6. **其他**
|
||||
- 通过设置媒体服务器Webhook指向MoviePilot(相对路径为`/api/v1/webhook?token=moviepilot`,其中`moviepilot`为设置的`API_TOKEN`),可实现通过MoviePilot发送播放通知,以及配合各类插件实现播放限速等功能。
|
||||
- 映射宿主机`docker.sock`文件到容器`/var/run/docker.sock`,可支持应用内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`。
|
||||
- 将WEB页面添加到手机桌面图标可获得与App一样的使用体验。
|
||||
|
||||
**注意**
|
||||
|
||||
1) 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
|
||||
2) 使用反向代理时,需要添加以下配置,否则可能会导致部分功能无法访问(`ip:port`修改为实际值):
|
||||
### **注意**
|
||||
- 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
|
||||
- 使用反向代理时,需要添加以下配置,否则可能会导致部分功能无法访问(`ip:port`修改为实际值):
|
||||
```nginx configuration
|
||||
location / {
|
||||
proxy_pass http://ip:port;
|
||||
@@ -246,7 +219,7 @@ location / {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
3) 新建的企业微信应用需要固定公网IP的代理才能收到消息,代理添加以下代码:
|
||||
- 新建的企业微信应用需要固定公网IP的代理才能收到消息,代理添加以下代码:
|
||||
```nginx configuration
|
||||
location /cgi-bin/gettoken {
|
||||
proxy_pass https://qyapi.weixin.qq.com;
|
||||
@@ -259,6 +232,13 @@ location /cgi-bin/menu/create {
|
||||
}
|
||||
```
|
||||
|
||||
- 部分插件功能基于文件系统监控实现(如`目录监控`等),需在宿主机上(不是docker容器内)执行以下命令并重启:
|
||||
```shell
|
||||
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
|
||||
echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
|
||||
sudo sysctl -p
|
||||
```
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, filebrowser, transfer
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, filebrowser, transfer, mediaserver
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
||||
@@ -21,3 +21,4 @@ api_router.include_router(download.router, prefix="/download", tags=["download"]
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
|
||||
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
|
||||
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from requests import Session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.dashboard import DashboardChain
|
||||
from app.core.config import settings
|
||||
from app.core.security import verify_token
|
||||
from app.core.security import verify_token, verify_uri_token
|
||||
from app.db import get_db
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.scheduler import Scheduler
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
from app.utils.timer import TimerUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
|
||||
def statistic(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询媒体数量统计信息
|
||||
"""
|
||||
media_statistic = DashboardChain(db).media_statistic()
|
||||
if media_statistic:
|
||||
return schemas.Statistic(
|
||||
movie_count=media_statistic.movie_count,
|
||||
tv_count=media_statistic.tv_count,
|
||||
episode_count=media_statistic.episode_count,
|
||||
user_count=media_statistic.user_count
|
||||
)
|
||||
media_statistics: Optional[List[schemas.Statistic]] = DashboardChain().media_statistic()
|
||||
if media_statistics:
|
||||
# 汇总各媒体库统计信息
|
||||
ret_statistic = schemas.Statistic()
|
||||
for media_statistic in media_statistics:
|
||||
ret_statistic.movie_count += media_statistic.movie_count
|
||||
ret_statistic.tv_count += media_statistic.tv_count
|
||||
ret_statistic.episode_count += media_statistic.episode_count
|
||||
ret_statistic.user_count += media_statistic.user_count
|
||||
return ret_statistic
|
||||
else:
|
||||
return schemas.Statistic()
|
||||
|
||||
|
||||
@router.get("/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic)
|
||||
def statistic2(_: str = Depends(verify_uri_token)) -> Any:
|
||||
"""
|
||||
查询媒体数量统计信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return statistic()
|
||||
|
||||
|
||||
@router.get("/storage", summary="存储空间", response_model=schemas.Storage)
|
||||
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -48,6 +54,14 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/storage2", summary="存储空间(API_TOKEN)", response_model=schemas.Storage)
|
||||
def storage2(_: str = Depends(verify_uri_token)) -> Any:
|
||||
"""
|
||||
查询存储空间信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return storage()
|
||||
|
||||
|
||||
@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo])
|
||||
def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -57,20 +71,28 @@ def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
|
||||
def downloader(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询下载器信息
|
||||
"""
|
||||
transfer_info = DashboardChain(db).downloader_info()
|
||||
free_space = SystemUtils.free_space(Path(settings.DOWNLOAD_PATH))
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=transfer_info.download_speed,
|
||||
upload_speed=transfer_info.upload_speed,
|
||||
download_size=transfer_info.download_size,
|
||||
upload_size=transfer_info.upload_size,
|
||||
free_space=free_space
|
||||
)
|
||||
downloader_info = schemas.DownloaderInfo()
|
||||
transfer_infos = DashboardChain().downloader_info()
|
||||
if transfer_infos:
|
||||
for transfer_info in transfer_infos:
|
||||
downloader_info.download_speed += transfer_info.download_speed
|
||||
downloader_info.upload_speed += transfer_info.upload_speed
|
||||
downloader_info.download_size += transfer_info.download_size
|
||||
downloader_info.upload_size += transfer_info.upload_size
|
||||
downloader_info.free_space = SystemUtils.free_space(settings.SAVE_PATH)
|
||||
return downloader_info
|
||||
|
||||
|
||||
@router.get("/downloader2", summary="下载器信息(API_TOKEN)", response_model=schemas.DownloaderInfo)
|
||||
def downloader2(_: str = Depends(verify_uri_token)) -> Any:
|
||||
"""
|
||||
查询下载器信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return downloader()
|
||||
|
||||
|
||||
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
|
||||
@@ -78,37 +100,15 @@ def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询后台服务信息
|
||||
"""
|
||||
# 返回计时任务
|
||||
schedulers = []
|
||||
# 去重
|
||||
added = []
|
||||
jobs = Scheduler().list()
|
||||
# 按照下次运行时间排序
|
||||
jobs.sort(key=lambda x: x.next_run_time)
|
||||
for job in jobs:
|
||||
if job.name not in added:
|
||||
added.append(job.name)
|
||||
else:
|
||||
continue
|
||||
if not StringUtils.is_chinese(job.name):
|
||||
continue
|
||||
if not job.next_run_time:
|
||||
status = "已停止"
|
||||
next_run = ""
|
||||
else:
|
||||
next_run = TimerUtils.time_difference(job.next_run_time)
|
||||
if not next_run:
|
||||
status = "正在运行"
|
||||
else:
|
||||
status = "阻塞" if job.pending else "等待"
|
||||
schedulers.append(schemas.ScheduleInfo(
|
||||
id=job.id,
|
||||
name=job.name,
|
||||
status=status,
|
||||
next_run=next_run
|
||||
))
|
||||
return Scheduler().list()
|
||||
|
||||
return schedulers
|
||||
|
||||
@router.get("/schedule2", summary="后台服务(API_TOKEN)", response_model=List[schemas.ScheduleInfo])
|
||||
def schedule2(_: str = Depends(verify_uri_token)) -> Any:
|
||||
"""
|
||||
查询下载器信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return schedule()
|
||||
|
||||
|
||||
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
|
||||
@@ -129,9 +129,25 @@ def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
return SystemUtils.cpu_usage()
|
||||
|
||||
|
||||
@router.get("/cpu2", summary="获取当前CPU使用率(API_TOKEN)", response_model=int)
|
||||
def cpu2(_: str = Depends(verify_uri_token)) -> Any:
|
||||
"""
|
||||
获取当前CPU使用率 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return cpu()
|
||||
|
||||
|
||||
@router.get("/memory", summary="获取当前内存使用量和使用率", response_model=List[int])
|
||||
def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取当前内存使用率
|
||||
"""
|
||||
return SystemUtils.memory_usage()
|
||||
|
||||
|
||||
@router.get("/memory2", summary="获取当前内存使用量和使用率(API_TOKEN)", response_model=List[int])
|
||||
def memory2(_: str = Depends(verify_uri_token)) -> Any:
|
||||
"""
|
||||
获取当前内存使用率 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return memory()
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.schemas import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
@@ -30,30 +28,14 @@ def douban_img(imgurl: str) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/recognize/{doubanid}", summary="豆瓣ID识别", response_model=schemas.Context)
|
||||
def recognize_doubanid(doubanid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = DoubanChain(db).recognize_by_doubanid(doubanid=doubanid)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
else:
|
||||
return schemas.Context()
|
||||
|
||||
|
||||
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
|
||||
def movie_showing(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣正在热映
|
||||
"""
|
||||
movies = DoubanChain(db).movie_showing(page=page, count=count)
|
||||
movies = DoubanChain().movie_showing(page=page, count=count)
|
||||
if not movies:
|
||||
return []
|
||||
medias = [MediaInfo(douban_info=movie) for movie in movies]
|
||||
@@ -65,13 +47,12 @@ def douban_movies(sort: str = "R",
|
||||
tags: str = "",
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣电影信息
|
||||
"""
|
||||
movies = DoubanChain(db).douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
if not movies:
|
||||
return []
|
||||
medias = [MediaInfo(douban_info=movie) for movie in movies]
|
||||
@@ -86,13 +67,12 @@ def douban_tvs(sort: str = "R",
|
||||
tags: str = "",
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
tvs = DoubanChain(db).douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
if not tvs:
|
||||
return []
|
||||
medias = [MediaInfo(douban_info=tv) for tv in tvs]
|
||||
@@ -106,47 +86,117 @@ def douban_tvs(sort: str = "R",
|
||||
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
||||
def movie_top250(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
movies = DoubanChain(db).movie_top250(page=page, count=count)
|
||||
movies = DoubanChain().movie_top250(page=page, count=count)
|
||||
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
|
||||
|
||||
|
||||
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
def tv_weekly_chinese(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
中国每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain(db).tv_weekly_chinese(page=page, count=count)
|
||||
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
|
||||
|
||||
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
def tv_weekly_global(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
全球每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain(db).tv_weekly_global(page=page, count=count)
|
||||
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
|
||||
|
||||
@router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
|
||||
def tv_animation(page: int = 1,
|
||||
count: int = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门动画剧集
|
||||
"""
|
||||
tvs = DoubanChain().tv_animation(page=page, count=count)
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
|
||||
|
||||
@router.get("/movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
|
||||
def movie_hot(page: int = 1,
|
||||
count: int = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门电影
|
||||
"""
|
||||
movies = DoubanChain().movie_hot(page=page, count=count)
|
||||
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
|
||||
|
||||
|
||||
@router.get("/tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
|
||||
def tv_hot(page: int = 1,
|
||||
count: int = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门电视剧
|
||||
"""
|
||||
tvs = DoubanChain().tv_hot(page=page, count=count)
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
|
||||
|
||||
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.DoubanPerson])
|
||||
def douban_credits(doubanid: str,
|
||||
type_name: str,
|
||||
page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询演员阵容,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
doubaninfos = DoubanChain().movie_credits(doubanid=doubanid, page=page)
|
||||
elif mediatype == MediaType.TV:
|
||||
doubaninfos = DoubanChain().tv_credits(doubanid=doubanid, page=page)
|
||||
else:
|
||||
return []
|
||||
if not doubaninfos:
|
||||
return []
|
||||
else:
|
||||
return [schemas.DoubanPerson(**doubaninfo) for doubaninfo in doubaninfos]
|
||||
|
||||
|
||||
@router.get("/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def douban_recommend(doubanid: str,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
doubaninfos = DoubanChain().movie_recommend(doubanid=doubanid)
|
||||
elif mediatype == MediaType.TV:
|
||||
doubaninfos = DoubanChain().tv_recommend(doubanid=doubanid)
|
||||
else:
|
||||
return []
|
||||
if not doubaninfos:
|
||||
return []
|
||||
else:
|
||||
return [MediaInfo(douban_info=doubaninfo).to_dict() for doubaninfo in doubaninfos]
|
||||
|
||||
|
||||
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
|
||||
def douban_info(doubanid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询豆瓣媒体信息
|
||||
"""
|
||||
doubaninfo = DoubanChain(db).douban_info(doubanid=doubanid)
|
||||
doubaninfo = DoubanChain().douban_info(doubanid=doubanid)
|
||||
if doubaninfo:
|
||||
return MediaInfo(douban_info=doubaninfo).to_dict()
|
||||
else:
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.context import MediaInfo, Context, TorrentInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.schemas import NotExistMediaInfo, MediaType
|
||||
from app.db.models.user import User
|
||||
from app.db.userauth import get_current_active_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
||||
def read_downloading(
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询正在下载的任务
|
||||
"""
|
||||
return DownloadChain(db).downloading()
|
||||
return DownloadChain().downloading()
|
||||
|
||||
|
||||
@router.post("/", summary="添加下载", response_model=schemas.Response)
|
||||
def add_downloading(
|
||||
media_in: schemas.MediaInfo,
|
||||
torrent_in: schemas.TorrentInfo,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
添加下载任务
|
||||
@@ -49,78 +45,40 @@ def add_downloading(
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrentinfo
|
||||
)
|
||||
did = DownloadChain(db).download_single(context=context)
|
||||
did = DownloadChain().download_single(context=context, userid=current_user.name, username=current_user.name)
|
||||
return schemas.Response(success=True if did else False, data={
|
||||
"download_id": did
|
||||
})
|
||||
|
||||
|
||||
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[NotExistMediaInfo])
|
||||
def exists(media_in: schemas.MediaInfo,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询缺失媒体信息
|
||||
"""
|
||||
# 媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
meta = MetaInfo(title=media_in.title)
|
||||
if media_in.tmdb_id:
|
||||
mediainfo.from_dict(media_in.dict())
|
||||
elif media_in.douban_id:
|
||||
context = DoubanChain(db).recognize_by_doubanid(doubanid=media_in.douban_id)
|
||||
if context:
|
||||
mediainfo = context.media_info
|
||||
meta = context.meta_info
|
||||
else:
|
||||
context = MediaChain(db).recognize_by_title(title=f"{media_in.title} {media_in.year}")
|
||||
if context:
|
||||
mediainfo = context.media_info
|
||||
meta = context.meta_info
|
||||
# 查询缺失信息
|
||||
if not mediainfo or not mediainfo.tmdb_id:
|
||||
raise HTTPException(status_code=404, detail="媒体信息不存在")
|
||||
exist_flag, no_exists = DownloadChain(db).get_no_exists_info(meta=meta, mediainfo=mediainfo)
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影已存在时返回空列表,存在时返回空对像列表
|
||||
return [] if exist_flag else [NotExistMediaInfo()]
|
||||
elif no_exists and no_exists.get(mediainfo.tmdb_id):
|
||||
# 电视剧返回缺失的剧集
|
||||
return list(no_exists.get(mediainfo.tmdb_id).values())
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
|
||||
def start_downloading(
|
||||
hashString: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
开如下载任务
|
||||
"""
|
||||
ret = DownloadChain(db).set_downloading(hashString, "start")
|
||||
ret = DownloadChain().set_downloading(hashString, "start")
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
|
||||
def stop_downloading(
|
||||
hashString: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
控制下载任务
|
||||
暂停下载任务
|
||||
"""
|
||||
ret = DownloadChain(db).set_downloading(hashString, "stop")
|
||||
ret = DownloadChain().set_downloading(hashString, "stop")
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
||||
def remove_downloading(
|
||||
hashString: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
控制下载任务
|
||||
删除下载任务
|
||||
"""
|
||||
ret = DownloadChain(db).remove_downloading(hashString)
|
||||
ret = DownloadChain().remove_downloading(hashString)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
@@ -16,10 +16,16 @@ router = APIRouter()
|
||||
IMAGE_TYPES = [".jpg", ".png", ".gif", ".bmp", ".jpeg", ".webp"]
|
||||
|
||||
|
||||
@router.get("/list", summary="所有插件", response_model=List[schemas.FileItem])
|
||||
def list_path(path: str, sort: str = 'time', _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
@router.get("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
|
||||
def list_path(path: str,
|
||||
sort: str = 'time',
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询当前目录下所有目录和文件
|
||||
:param path: 目录路径
|
||||
:param sort: 排序方式,name:按名称排序,time:按修改时间排序
|
||||
:param _: token
|
||||
:return: 所有目录和文件
|
||||
"""
|
||||
# 返回结果
|
||||
ret_items = []
|
||||
|
||||
@@ -6,11 +6,12 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas import MediaType
|
||||
from app.schemas.types import EventType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -41,17 +42,26 @@ def delete_download_history(history_in: schemas.DownloadHistory,
|
||||
def transfer_history(title: str = None,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
status: bool = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询转移历史记录
|
||||
"""
|
||||
if title == "失败":
|
||||
title = None
|
||||
status = False
|
||||
elif title == "成功":
|
||||
title = None
|
||||
status = True
|
||||
|
||||
if title:
|
||||
total = TransferHistory.count_by_title(db, title)
|
||||
result = TransferHistory.list_by_title(db, title, page, count)
|
||||
total = TransferHistory.count_by_title(db, title=title, status=status)
|
||||
result = TransferHistory.list_by_title(db, title=title, page=page,
|
||||
count=count, status=status)
|
||||
else:
|
||||
result = TransferHistory.list_by_page(db, page, count)
|
||||
total = TransferHistory.count(db)
|
||||
result = TransferHistory.list_by_page(db, page=page, count=count, status=status)
|
||||
total = TransferHistory.count(db, status=status)
|
||||
|
||||
return schemas.Response(success=True,
|
||||
data={
|
||||
@@ -62,37 +72,34 @@ def transfer_history(title: str = None,
|
||||
|
||||
@router.delete("/transfer", summary="删除转移历史记录", response_model=schemas.Response)
|
||||
def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
delete_file: bool = False,
|
||||
deletesrc: bool = False,
|
||||
deletedest: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除转移历史记录
|
||||
"""
|
||||
# 触发删除事件
|
||||
if delete_file:
|
||||
history = TransferHistory.get(db, history_in.id)
|
||||
if not history:
|
||||
return schemas.Response(success=False, msg="记录不存在")
|
||||
# 册除文件
|
||||
if history.dest:
|
||||
TransferChain(db).delete_files(Path(history.dest))
|
||||
history = TransferHistory.get(db, history_in.id)
|
||||
if not history:
|
||||
return schemas.Response(success=False, msg="记录不存在")
|
||||
# 册除媒体库文件
|
||||
if deletedest and history.dest:
|
||||
state, msg = TransferChain().delete_files(Path(history.dest))
|
||||
if not state:
|
||||
return schemas.Response(success=False, msg=msg)
|
||||
# 删除源文件
|
||||
if deletesrc and history.src:
|
||||
state, msg = TransferChain().delete_files(Path(history.src))
|
||||
if not state:
|
||||
return schemas.Response(success=False, msg=msg)
|
||||
# 发送事件
|
||||
eventmanager.send_event(
|
||||
EventType.DownloadFileDeleted,
|
||||
{
|
||||
"src": history.src,
|
||||
"hash": history.download_hash
|
||||
}
|
||||
)
|
||||
# 删除记录
|
||||
TransferHistory.delete(db, history_in.id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/transfer", summary="历史记录重新转移", response_model=schemas.Response)
|
||||
def redo_transfer_history(history_in: schemas.TransferHistory,
|
||||
mtype: str,
|
||||
new_tmdbid: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
历史记录重新转移
|
||||
"""
|
||||
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id,
|
||||
mtype=MediaType(mtype), tmdbid=new_tmdbid)
|
||||
if state:
|
||||
return schemas.Response(success=True)
|
||||
else:
|
||||
return schemas.Response(success=False, message=errmsg)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import random
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -15,7 +14,7 @@ from app.core.security import get_password_hash
|
||||
from app.db import get_db
|
||||
from app.db.models.user import User
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.web import WebUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -35,12 +34,13 @@ async def login_access_token(
|
||||
)
|
||||
if not user:
|
||||
# 请求协助认证
|
||||
logger.warn("登录用户本地不匹配,尝试辅助认证 ...")
|
||||
token = UserChain(db).user_authenticate(form_data.username, form_data.password)
|
||||
logger.warn(f"登录用户 {form_data.username} 本地用户名或密码不匹配,尝试辅助认证 ...")
|
||||
token = UserChain().user_authenticate(form_data.username, form_data.password)
|
||||
if not token:
|
||||
logger.warn(f"用户 {form_data.username} 登录失败!")
|
||||
raise HTTPException(status_code=401, detail="用户名或密码不正确")
|
||||
else:
|
||||
logger.info(f"辅助认证成功,用户信息: {token}")
|
||||
logger.info(f"用户 {form_data.username} 辅助认证成功,用户信息: {token},以普通用户登录...")
|
||||
# 加入用户信息表
|
||||
user = User.get_by_name(db=db, name=form_data.username)
|
||||
if not user:
|
||||
@@ -48,12 +48,21 @@ async def login_access_token(
|
||||
user = User(name=form_data.username, is_active=True,
|
||||
is_superuser=False, hashed_password=get_password_hash(token))
|
||||
user.create(db)
|
||||
else:
|
||||
# 辅助验证用户若未启用,则禁止登录
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="用户未启用")
|
||||
# 普通用户权限
|
||||
user.is_superuser = False
|
||||
elif not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="用户未启用")
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
logger.info(f"用户 {user.name} 登录成功!")
|
||||
return schemas.Token(
|
||||
access_token=security.create_access_token(
|
||||
user.id, expires_delta=access_token_expires
|
||||
userid=user.id,
|
||||
username=user.name,
|
||||
super_user=user.is_superuser,
|
||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
),
|
||||
token_type="bearer",
|
||||
super_user=user.is_superuser,
|
||||
@@ -62,42 +71,41 @@ async def login_access_token(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/wallpaper", summary="登录页面电影海报", response_model=schemas.Response)
|
||||
def wallpaper() -> Any:
|
||||
"""
|
||||
获取登录页面电影海报
|
||||
"""
|
||||
if settings.WALLPAPER == "tmdb":
|
||||
return tmdb_wallpaper()
|
||||
elif settings.WALLPAPER == "bing":
|
||||
return bing_wallpaper()
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@router.get("/bing", summary="Bing每日壁纸", response_model=schemas.Response)
|
||||
def bing_wallpaper() -> Any:
|
||||
"""
|
||||
获取Bing每日壁纸
|
||||
"""
|
||||
url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
|
||||
try:
|
||||
resp = RequestUtils(timeout=5).get_res(url)
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return schemas.Response(success=False)
|
||||
if resp and resp.status_code == 200:
|
||||
try:
|
||||
result = resp.json()
|
||||
if isinstance(result, dict):
|
||||
for image in result.get('images') or []:
|
||||
return schemas.Response(success=False,
|
||||
message=f"https://cn.bing.com{image.get('url')}" if 'url' in image else '')
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
url = WebUtils.get_bing_wallpaper()
|
||||
if url:
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
message=url
|
||||
)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@router.get("/tmdb", summary="TMDB电影海报", response_model=schemas.Response)
|
||||
def tmdb_wallpaper(db: Session = Depends(get_db)) -> Any:
|
||||
def tmdb_wallpaper() -> Any:
|
||||
"""
|
||||
获取TMDB电影海报
|
||||
"""
|
||||
infos = TmdbChain(db).tmdb_trending()
|
||||
if infos:
|
||||
# 随机一个电影
|
||||
while True:
|
||||
info = random.choice(infos)
|
||||
if info and info.get("backdrop_path"):
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
message=f"https://image.tmdb.org/t/p/original{info.get('backdrop_path')}"
|
||||
)
|
||||
wallpager = TmdbChain().get_random_wallpager()
|
||||
if wallpager:
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
message=wallpager
|
||||
)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.core.security import verify_token, verify_uri_token
|
||||
from app.schemas import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
@@ -20,91 +17,118 @@ router = APIRouter()
|
||||
@router.get("/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context)
|
||||
def recognize(title: str,
|
||||
subtitle: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据标题、副标题识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = MediaChain(db).recognize_by_title(title=title, subtitle=subtitle)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
metainfo = MetaInfo(title, subtitle)
|
||||
mediainfo = MediaChain().recognize_by_meta(metainfo)
|
||||
if mediainfo:
|
||||
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
|
||||
return schemas.Context()
|
||||
|
||||
|
||||
@router.get("/recognize2", summary="识别种子媒体信息(API_TOKEN)", response_model=schemas.Context)
|
||||
def recognize2(title: str,
|
||||
subtitle: str = None,
|
||||
_: str = Depends(verify_uri_token)) -> Any:
|
||||
"""
|
||||
根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
# 识别媒体信息
|
||||
return recognize(title, subtitle)
|
||||
|
||||
|
||||
@router.get("/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context)
|
||||
def recognize(path: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def recognize_file(path: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = MediaChain(db).recognize_by_path(path)
|
||||
context = MediaChain().recognize_by_path(path)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
return schemas.Context()
|
||||
|
||||
|
||||
@router.get("/recognize_file2", summary="识别文件媒体信息(API_TOKEN)", response_model=schemas.Context)
|
||||
def recognize_file2(path: str,
|
||||
_: str = Depends(verify_uri_token)) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
# 识别媒体信息
|
||||
return recognize_file(path)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索媒体信息", response_model=List[schemas.MediaInfo])
|
||||
def search_by_title(title: str,
|
||||
page: int = 1,
|
||||
count: int = 8,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
模糊搜索媒体信息列表
|
||||
"""
|
||||
_, medias = MediaChain(db).search(title=title)
|
||||
_, medias = MediaChain().search(title=title)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/exists", summary="本地是否存在", response_model=schemas.Response)
|
||||
def exists(title: str = None,
|
||||
year: int = None,
|
||||
mtype: str = None,
|
||||
tmdbid: int = None,
|
||||
season: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
@router.get("/scrape", summary="刮削媒体信息", response_model=schemas.Response)
|
||||
def scrape(path: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
判断本地是否存在
|
||||
刮削媒体信息
|
||||
"""
|
||||
meta = MetaInfo(title)
|
||||
if not season:
|
||||
season = meta.begin_season
|
||||
exist = MediaServerOper(db).exists(
|
||||
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
|
||||
)
|
||||
return schemas.Response(success=True if exist else False, data={
|
||||
"item": exist or {}
|
||||
})
|
||||
if not path:
|
||||
return schemas.Response(success=False, message="刮削路径无效")
|
||||
scrape_path = Path(path)
|
||||
if not scrape_path.exists():
|
||||
return schemas.Response(success=False, message="刮削路径不存在")
|
||||
# 识别
|
||||
chain = MediaChain()
|
||||
meta = MetaInfoPath(scrape_path)
|
||||
mediainfo = chain.recognize_media(meta)
|
||||
if not media_info:
|
||||
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
|
||||
# 刮削
|
||||
chain.scrape_metadata(path=scrape_path, mediainfo=mediainfo, transfer_type=settings.TRANSFER_TYPE)
|
||||
return schemas.Response(success=True, message="刮削完成")
|
||||
|
||||
|
||||
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||
def tmdb_info(mediaid: str, type_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def media_info(mediaid: str, type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧
|
||||
"""
|
||||
mtype = MediaType(type_name)
|
||||
tmdbid, doubanid = None, None
|
||||
if mediaid.startswith("tmdb:"):
|
||||
result = TmdbChain(db).tmdb_info(int(mediaid[5:]), mtype)
|
||||
return MediaInfo(tmdb_info=result).to_dict()
|
||||
tmdbid = int(mediaid[5:])
|
||||
elif mediaid.startswith("douban:"):
|
||||
# 查询豆瓣信息
|
||||
doubaninfo = DoubanChain(db).douban_info(doubanid=mediaid[7:])
|
||||
if not doubaninfo:
|
||||
return schemas.MediaInfo()
|
||||
result = DoubanChain(db).recognize_by_doubaninfo(doubaninfo)
|
||||
if result:
|
||||
# TMDB
|
||||
return result.media_info.to_dict()
|
||||
else:
|
||||
# 豆瓣
|
||||
return MediaInfo(douban_info=doubaninfo).to_dict()
|
||||
else:
|
||||
doubanid = mediaid[7:]
|
||||
if not tmdbid and not doubanid:
|
||||
return schemas.MediaInfo()
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
if not tmdbid and doubanid:
|
||||
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
|
||||
if tmdbinfo:
|
||||
tmdbid = tmdbinfo.get("id")
|
||||
else:
|
||||
return schemas.MediaInfo()
|
||||
else:
|
||||
if not doubanid and tmdbid:
|
||||
doubaninfo = MediaChain().get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=mtype)
|
||||
if doubaninfo:
|
||||
doubanid = doubaninfo.get("id")
|
||||
else:
|
||||
return schemas.MediaInfo()
|
||||
mediainfo = MediaChain().recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
if mediainfo:
|
||||
MediaChain().obtain_images(mediainfo)
|
||||
return mediainfo.to_dict()
|
||||
return schemas.MediaInfo()
|
||||
|
||||
146
app/api/endpoints/mediaserver.py
Normal file
146
app/api/endpoints/mediaserver.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.core.config import settings
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.db.models import MediaServerItem
|
||||
from app.schemas import MediaType, NotExistMediaInfo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/play/{itemid}", summary="在线播放")
|
||||
def play_item(itemid: str) -> schemas.Response:
|
||||
"""
|
||||
获取媒体服务器播放页面地址
|
||||
"""
|
||||
if not itemid:
|
||||
return schemas.Response(success=False, msg="参数错误")
|
||||
if not settings.MEDIASERVER:
|
||||
return schemas.Response(success=False, msg="未配置媒体服务器")
|
||||
mediaserver = settings.MEDIASERVER.split(",")[0]
|
||||
play_url = MediaServerChain().get_play_url(server=mediaserver, item_id=itemid)
|
||||
# 重定向到play_url
|
||||
if not play_url:
|
||||
return schemas.Response(success=False, msg="未找到播放地址")
|
||||
return schemas.Response(success=True, data={
|
||||
"url": play_url
|
||||
})
|
||||
|
||||
|
||||
@router.get("/exists", summary="本地是否存在", response_model=schemas.Response)
|
||||
def exists(title: str = None,
|
||||
year: int = None,
|
||||
mtype: str = None,
|
||||
tmdbid: int = None,
|
||||
season: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
判断本地是否存在
|
||||
"""
|
||||
meta = MetaInfo(title)
|
||||
if not season:
|
||||
season = meta.begin_season
|
||||
# 返回对象
|
||||
ret_info = {}
|
||||
# 本地数据库是否存在
|
||||
exist: MediaServerItem = MediaServerOper(db).exists(
|
||||
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
|
||||
)
|
||||
if exist:
|
||||
ret_info = {
|
||||
"id": exist.item_id
|
||||
}
|
||||
"""
|
||||
else:
|
||||
# 服务器是否存在
|
||||
mediainfo = MediaInfo()
|
||||
mediainfo.from_dict({
|
||||
"title": meta.name,
|
||||
"year": year or meta.year,
|
||||
"type": mtype or meta.type,
|
||||
"tmdb_id": tmdbid,
|
||||
"season": season
|
||||
})
|
||||
exist: schemas.ExistMediaInfo = MediaServerChain().media_exists(
|
||||
mediainfo=mediainfo
|
||||
)
|
||||
if exist:
|
||||
ret_info = {
|
||||
"id": exist.itemid
|
||||
}
|
||||
"""
|
||||
return schemas.Response(success=True if exist else False, data={
|
||||
"item": ret_info
|
||||
})
|
||||
|
||||
|
||||
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[schemas.NotExistMediaInfo])
|
||||
def not_exists(media_in: schemas.MediaInfo,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询缺失媒体信息
|
||||
"""
|
||||
# 媒体信息
|
||||
meta = MetaInfo(title=media_in.title)
|
||||
mtype = MediaType(media_in.type) if media_in.type else None
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if media_in.season:
|
||||
meta.begin_season = media_in.season
|
||||
meta.type = MediaType.TV
|
||||
if media_in.year:
|
||||
meta.year = media_in.year
|
||||
if media_in.tmdb_id or media_in.douban_id:
|
||||
mediainfo = MediaChain().recognize_media(meta=meta, mtype=mtype,
|
||||
tmdbid=media_in.tmdb_id, doubanid=media_in.douban_id)
|
||||
else:
|
||||
mediainfo = MediaChain().recognize_by_meta(metainfo=meta)
|
||||
# 查询缺失信息
|
||||
if not mediainfo:
|
||||
raise HTTPException(status_code=404, detail="媒体信息不存在")
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影已存在时返回空列表,存在时返回空对像列表
|
||||
return [] if exist_flag else [NotExistMediaInfo()]
|
||||
elif no_exists and no_exists.get(mediakey):
|
||||
# 电视剧返回缺失的剧集
|
||||
return list(no_exists.get(mediakey).values())
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem])
|
||||
def latest(count: int = 18,
|
||||
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
return MediaServerChain().latest(count=count, username=userinfo.username) or []
|
||||
|
||||
|
||||
@router.get("/playing", summary="正在播放条目", response_model=List[schemas.MediaServerPlayItem])
|
||||
def playing(count: int = 12,
|
||||
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取媒体服务器正在播放条目
|
||||
"""
|
||||
return MediaServerChain().playing(count=count, username=userinfo.username) or []
|
||||
|
||||
|
||||
@router.get("/library", summary="媒体库列表", response_model=List[schemas.MediaServerLibrary])
|
||||
def library(userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取媒体服务器媒体库列表
|
||||
"""
|
||||
return MediaServerChain().librarys(username=userinfo.username) or []
|
||||
@@ -2,14 +2,12 @@ from typing import Union, Any, List
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from fastapi import Request
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
from app import schemas
|
||||
from app.chain.message import MessageChain
|
||||
from app.core.config import settings
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
@@ -19,39 +17,36 @@ from app.schemas.types import SystemConfigKey, NotificationType
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def start_message_chain(db: Session, body: Any, form: Any, args: Any):
|
||||
def start_message_chain(body: Any, form: Any, args: Any):
|
||||
"""
|
||||
启动链式任务
|
||||
"""
|
||||
MessageChain(db).process(body=body, form=form, args=args)
|
||||
MessageChain().process(body=body, form=form, args=args)
|
||||
|
||||
|
||||
@router.post("/", summary="接收用户消息", response_model=schemas.Response)
|
||||
async def user_message(background_tasks: BackgroundTasks, request: Request,
|
||||
db: Session = Depends(get_db)):
|
||||
async def user_message(background_tasks: BackgroundTasks, request: Request):
|
||||
"""
|
||||
用户消息响应
|
||||
"""
|
||||
body = await request.body()
|
||||
form = await request.form()
|
||||
args = request.query_params
|
||||
background_tasks.add_task(start_message_chain, db, body, form, args)
|
||||
background_tasks.add_task(start_message_chain, body, form, args)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/", summary="微信验证")
|
||||
def wechat_verify(echostr: str, msg_signature: str,
|
||||
timestamp: Union[str, int], nonce: str) -> Any:
|
||||
"""
|
||||
用户消息响应
|
||||
微信验证响应
|
||||
"""
|
||||
logger.info(f"收到微信验证请求: {echostr}")
|
||||
try:
|
||||
wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN,
|
||||
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
|
||||
sReceiveId=settings.WECHAT_CORPID)
|
||||
except Exception as err:
|
||||
logger.error(f"微信请求验证失败: {err}")
|
||||
logger.error(f"微信请求验证失败: {str(err)}")
|
||||
return str(err)
|
||||
ret, sEchoStr = wxcpt.VerifyURL(sMsgSignature=msg_signature,
|
||||
sTimeStamp=timestamp,
|
||||
@@ -63,6 +58,28 @@ def wechat_verify(echostr: str, msg_signature: str,
|
||||
return PlainTextResponse(sEchoStr)
|
||||
|
||||
|
||||
def vocechat_verify(token: str) -> Any:
|
||||
"""
|
||||
VoceChat验证响应
|
||||
"""
|
||||
if token == settings.API_TOKEN:
|
||||
return {"status": "OK"}
|
||||
return {"status": "ERROR"}
|
||||
|
||||
|
||||
@router.get("/", summary="回调请求验证")
|
||||
def incoming_verify(token: str = None, echostr: str = None, msg_signature: str = None,
|
||||
timestamp: Union[str, int] = None, nonce: str = None) -> Any:
|
||||
"""
|
||||
微信/VoceChat等验证响应
|
||||
"""
|
||||
logger.info(f"收到验证请求: token={token}, echostr={echostr}, "
|
||||
f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}")
|
||||
if echostr and msg_signature and timestamp and nonce:
|
||||
return wechat_verify(echostr, msg_signature, timestamp, nonce)
|
||||
return vocechat_verify(token)
|
||||
|
||||
|
||||
@router.get("/switchs", summary="查询通知消息渠道开关", response_model=List[NotificationSwitch])
|
||||
def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -73,7 +90,9 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
switchs = SystemConfigOper().get(SystemConfigKey.NotificationChannels)
|
||||
if not switchs:
|
||||
for noti in NotificationType:
|
||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True, telegram=True, slack=True))
|
||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
||||
telegram=True, slack=True,
|
||||
synologychat=True, vocechat=True))
|
||||
else:
|
||||
for switch in switchs:
|
||||
return_list.append(NotificationSwitch(**switch))
|
||||
|
||||
@@ -1,28 +1,66 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.plugin import PluginHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
||||
def all_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def all_plugins(_: schemas.TokenPayload = Depends(verify_token), state: str = "all") -> Any:
|
||||
"""
|
||||
查询所有插件清单
|
||||
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
||||
"""
|
||||
return PluginManager().get_plugin_apps()
|
||||
# 本地插件
|
||||
local_plugins = PluginManager().get_local_plugins()
|
||||
# 已安装插件
|
||||
installed_plugins = [plugin for plugin in local_plugins if plugin.get("installed")]
|
||||
# 未安装的本地插件
|
||||
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.get("installed")]
|
||||
if state == "installed":
|
||||
return installed_plugins
|
||||
|
||||
# 在线插件
|
||||
online_plugins = PluginManager().get_online_plugins()
|
||||
if not online_plugins:
|
||||
# 没有获取在线插件
|
||||
if state == "market":
|
||||
# 返回未安装的本地插件
|
||||
return not_installed_plugins
|
||||
return local_plugins
|
||||
|
||||
# 插件市场插件清单
|
||||
market_plugins = []
|
||||
# 已安装插件IDS
|
||||
_installed_ids = [plugin["id"] for plugin in installed_plugins]
|
||||
# 未安装的线上插件或者有更新的插件
|
||||
for plugin in online_plugins:
|
||||
if plugin["id"] not in _installed_ids:
|
||||
market_plugins.append(plugin)
|
||||
elif plugin.get("has_update"):
|
||||
market_plugins.append(plugin)
|
||||
# 未安装的本地插件,且不在线上插件中
|
||||
_plugin_ids = [plugin["id"] for plugin in market_plugins]
|
||||
for plugin in not_installed_plugins:
|
||||
if plugin["id"] not in _plugin_ids:
|
||||
market_plugins.append(plugin)
|
||||
# 返回插件清单
|
||||
if state == "market":
|
||||
# 返回未安装的插件
|
||||
return market_plugins
|
||||
# 返回所有插件
|
||||
return installed_plugins + market_plugins
|
||||
|
||||
|
||||
@router.get("/installed", summary="已安装插件", response_model=List[str])
|
||||
def installed_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def installed(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询用户已安装插件清单
|
||||
"""
|
||||
@@ -30,20 +68,31 @@ def installed_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
||||
def install_plugin(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def install(plugin_id: str,
|
||||
repo_url: str = "",
|
||||
force: bool = False,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
安装插件
|
||||
"""
|
||||
# 已安装插件
|
||||
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 如果是非本地括件,或者强制安装时,则需要下载安装
|
||||
if repo_url and (force or plugin_id not in PluginManager().get_plugin_ids()):
|
||||
# 下载安装
|
||||
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
|
||||
if not state:
|
||||
# 安装失败
|
||||
return schemas.Response(success=False, message=msg)
|
||||
# 安装插件
|
||||
if plugin_id not in install_plugins:
|
||||
install_plugins.append(plugin_id)
|
||||
# 保存设置
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 重载插件管理器
|
||||
PluginManager().init_config()
|
||||
# 重载插件管理器
|
||||
PluginManager().init_config()
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -63,11 +112,25 @@ def plugin_form(plugin_id: str,
|
||||
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
||||
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
"""
|
||||
根据插件ID获取插件配置信息
|
||||
根据插件ID获取插件数据页面
|
||||
"""
|
||||
return PluginManager().get_plugin_page(plugin_id)
|
||||
|
||||
|
||||
@router.get("/reset/{plugin_id}", summary="重置插件配置", response_model=schemas.Response)
|
||||
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据插件ID重置插件配置
|
||||
"""
|
||||
# 删除配置
|
||||
PluginManager().delete_plugin_config(plugin_id)
|
||||
# 重新生效插件
|
||||
PluginManager().reload_plugin(plugin_id, {})
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/{plugin_id}", summary="获取插件配置")
|
||||
def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
||||
"""
|
||||
@@ -80,12 +143,14 @@ def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token
|
||||
def set_plugin_config(plugin_id: str, conf: dict,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据插件ID获取插件配置信息
|
||||
更新插件配置
|
||||
"""
|
||||
# 保存配置
|
||||
PluginManager().save_plugin_config(plugin_id, conf)
|
||||
# 重新生效插件
|
||||
PluginManager().reload_plugin(plugin_id, conf)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -105,6 +170,8 @@ def uninstall_plugin(plugin_id: str,
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 重载插件管理器
|
||||
PluginManager().init_config()
|
||||
# 移除插件服务
|
||||
Scheduler().remove_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
|
||||
@@ -1,51 +1,57 @@
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.core.config import settings
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
|
||||
async def search_latest(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询搜索结果
|
||||
"""
|
||||
torrents = SearchChain(db).last_search_results()
|
||||
torrents = SearchChain().last_search_results()
|
||||
return [torrent.to_dict() for torrent in torrents]
|
||||
|
||||
|
||||
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=List[schemas.Context])
|
||||
def search_by_tmdbid(mediaid: str,
|
||||
mtype: str = None,
|
||||
area: str = "title",
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def search_by_id(mediaid: str,
|
||||
mtype: str = None,
|
||||
area: str = "title",
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/
|
||||
"""
|
||||
torrents = []
|
||||
if mtype:
|
||||
mtype = MediaType(mtype)
|
||||
if mediaid.startswith("tmdb:"):
|
||||
tmdbid = int(mediaid.replace("tmdb:", ""))
|
||||
if mtype:
|
||||
mtype = MediaType(mtype)
|
||||
torrents = SearchChain(db).search_by_tmdbid(tmdbid=tmdbid, mtype=mtype, area=area)
|
||||
if settings.RECOGNIZE_SOURCE == "douban":
|
||||
# 通过TMDBID识别豆瓣ID
|
||||
doubaninfo = MediaChain().get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=mtype)
|
||||
if doubaninfo:
|
||||
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
|
||||
mtype=mtype, area=area)
|
||||
else:
|
||||
torrents = SearchChain().search_by_id(tmdbid=tmdbid, mtype=mtype, area=area)
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid.replace("douban:", "")
|
||||
# 识别豆瓣信息
|
||||
context = DoubanChain(db).recognize_by_doubanid(doubanid)
|
||||
if not context or not context.media_info or not context.media_info.tmdb_id:
|
||||
return []
|
||||
torrents = SearchChain(db).search_by_tmdbid(tmdbid=context.media_info.tmdb_id,
|
||||
mtype=context.media_info.type,
|
||||
area=area)
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
# 通过豆瓣ID识别TMDBID
|
||||
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
|
||||
if tmdbinfo:
|
||||
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
|
||||
mtype=mtype, area=area)
|
||||
else:
|
||||
torrents = SearchChain().search_by_id(doubanid=doubanid, mtype=mtype, area=area)
|
||||
else:
|
||||
return []
|
||||
return [torrent.to_dict() for torrent in torrents]
|
||||
@@ -55,10 +61,9 @@ def search_by_tmdbid(mediaid: str,
|
||||
async def search_by_title(keyword: str = None,
|
||||
page: int = 0,
|
||||
site: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
|
||||
"""
|
||||
torrents = SearchChain(db).search_by_title(title=keyword, page=page, site=site)
|
||||
torrents = SearchChain().search_by_title(title=keyword, page=page, site=site)
|
||||
return [torrent.to_dict() for torrent in torrents]
|
||||
|
||||
@@ -5,7 +5,6 @@ from sqlalchemy.orm import Session
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
from app import schemas
|
||||
from app.chain.cookiecloud import CookieCloudChain
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.event import EventManager
|
||||
@@ -15,19 +14,13 @@ from app.db.models.site import Site
|
||||
from app.db.models.siteicon import SiteIcon
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def start_cookiecloud_sync(db: Session):
|
||||
"""
|
||||
后台启动CookieCloud站点同步
|
||||
"""
|
||||
CookieCloudChain(db).process(manual=True)
|
||||
|
||||
|
||||
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
|
||||
def read_sites(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
@@ -38,7 +31,7 @@ def read_sites(db: Session = Depends(get_db),
|
||||
|
||||
|
||||
@router.post("/", summary="新增站点", response_model=schemas.Response)
|
||||
def update_site(
|
||||
def add_site(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
site_in: schemas.Site,
|
||||
@@ -52,7 +45,7 @@ def update_site(
|
||||
domain = StringUtils.get_url_domain(site_in.url)
|
||||
site_info = SitesHelper().get_indexer(domain)
|
||||
if not site_info:
|
||||
return schemas.Response(success=False, message="该站点不支持")
|
||||
return schemas.Response(success=False, message="该站点不支持或用户未通过认证")
|
||||
if Site.get_by_domain(db, domain):
|
||||
return schemas.Response(success=False, message=f"{domain} 站点己存在")
|
||||
# 保存站点信息
|
||||
@@ -61,6 +54,10 @@ def update_site(
|
||||
site_in.id = None
|
||||
site = Site(**site_in.dict())
|
||||
site.create(db)
|
||||
# 通知缓存站点图标
|
||||
EventManager().send_event(EventType.CacheSiteIcon, {
|
||||
"domain": domain
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -78,6 +75,10 @@ def update_site(
|
||||
if not site:
|
||||
return schemas.Response(success=False, message="站点不存在")
|
||||
site.update(db, site_in.dict())
|
||||
# 通知缓存站点图标
|
||||
EventManager().send_event(EventType.CacheSiteIcon, {
|
||||
"domain": site_in.domain
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -101,25 +102,25 @@ def delete_site(
|
||||
|
||||
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
|
||||
def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
运行CookieCloud同步站点信息
|
||||
"""
|
||||
background_tasks.add_task(start_cookiecloud_sync, db)
|
||||
background_tasks.add_task(Scheduler().start, job_id="cookiecloud")
|
||||
return schemas.Response(success=True, message="CookieCloud同步任务已启动!")
|
||||
|
||||
|
||||
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
|
||||
def cookie_cloud_sync(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def reset(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
清空所有站点数据并重新同步CookieCloud站点信息
|
||||
"""
|
||||
Site.reset(db)
|
||||
SystemConfigOper().set(SystemConfigKey.IndexerSites, [])
|
||||
SystemConfigOper().set(SystemConfigKey.RssSites, [])
|
||||
CookieCloudChain().process(manual=True)
|
||||
# 启动定时服务
|
||||
Scheduler().start("cookiecloud", manual=True)
|
||||
# 插件站点删除
|
||||
EventManager().send_event(EventType.SiteDeleted,
|
||||
{
|
||||
@@ -133,6 +134,7 @@ def update_cookie(
|
||||
site_id: int,
|
||||
username: str,
|
||||
password: str,
|
||||
code: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -146,9 +148,10 @@ def update_cookie(
|
||||
detail=f"站点 {site_id} 不存在!",
|
||||
)
|
||||
# 更新Cookie
|
||||
state, message = SiteChain(db).update_cookie(site_info=site_info,
|
||||
username=username,
|
||||
password=password)
|
||||
state, message = SiteChain().update_cookie(site_info=site_info,
|
||||
username=username,
|
||||
password=password,
|
||||
two_step_code=code)
|
||||
return schemas.Response(success=state, message=message)
|
||||
|
||||
|
||||
@@ -165,7 +168,7 @@ def test_site(site_id: int,
|
||||
status_code=404,
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
status, message = SiteChain(db).test(site.domain)
|
||||
status, message = SiteChain().test(site.domain)
|
||||
return schemas.Response(success=status, message=message)
|
||||
|
||||
|
||||
@@ -235,10 +238,11 @@ def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
|
||||
"""
|
||||
# 选中的rss站点
|
||||
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
||||
|
||||
# 所有站点
|
||||
all_site = Site.list_order_by_pri(db)
|
||||
if not selected_sites or not all_site:
|
||||
return []
|
||||
if not selected_sites:
|
||||
return all_site
|
||||
|
||||
# 选中的rss站点
|
||||
rss_sites = [site for site in all_site if site and site.id in selected_sites]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from typing import List, Any, Optional
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -7,33 +7,28 @@ from sqlalchemy.orm import Session
|
||||
from app import schemas
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings
|
||||
from app.core.security import verify_token
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token, verify_uri_token
|
||||
from app.db import get_db
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.models.user import User
|
||||
from app.db.userauth import get_current_active_user
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def start_subscribe_add(db: Session, title: str, year: str,
|
||||
def start_subscribe_add(title: str, year: str,
|
||||
mtype: MediaType, tmdbid: int, season: int, username: str):
|
||||
"""
|
||||
启动订阅任务
|
||||
"""
|
||||
SubscribeChain(db).add(title=title, year=year,
|
||||
mtype=mtype, tmdbid=tmdbid, season=season, username=username)
|
||||
SubscribeChain().add(title=title, year=year,
|
||||
mtype=mtype, tmdbid=tmdbid, season=season, username=username)
|
||||
|
||||
|
||||
def start_subscribe_search(db: Session, sid: Optional[int], state: Optional[str]):
|
||||
"""
|
||||
启动订阅搜索任务
|
||||
"""
|
||||
SubscribeChain(db).search(sid=sid, state=state, manual=True)
|
||||
|
||||
|
||||
@router.get("/", summary="所有订阅", response_model=List[schemas.Subscribe])
|
||||
@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe])
|
||||
def read_subscribes(
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
@@ -47,11 +42,18 @@ def read_subscribes(
|
||||
return subscribes
|
||||
|
||||
|
||||
@router.get("/list", summary="查询所有订阅(API_TOKEN)", response_model=List[schemas.Subscribe])
|
||||
def list_subscribes(_: str = Depends(verify_uri_token)) -> Any:
|
||||
"""
|
||||
查询所有订阅 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return read_subscribes()
|
||||
|
||||
|
||||
@router.post("/", summary="新增订阅", response_model=schemas.Response)
|
||||
def create_subscribe(
|
||||
*,
|
||||
subscribe_in: schemas.Subscribe,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
@@ -62,23 +64,30 @@ def create_subscribe(
|
||||
mtype = MediaType(subscribe_in.type)
|
||||
else:
|
||||
mtype = None
|
||||
# 豆瓣标理
|
||||
if subscribe_in.doubanid:
|
||||
meta = MetaInfo(subscribe_in.name)
|
||||
subscribe_in.name = meta.name
|
||||
subscribe_in.season = meta.begin_season
|
||||
# 标题转换
|
||||
if subscribe_in.name:
|
||||
title = subscribe_in.name
|
||||
else:
|
||||
title = None
|
||||
sid, message = SubscribeChain(db).add(mtype=mtype,
|
||||
title=title,
|
||||
year=subscribe_in.year,
|
||||
tmdbid=subscribe_in.tmdbid,
|
||||
season=subscribe_in.season,
|
||||
doubanid=subscribe_in.doubanid,
|
||||
username=current_user.name,
|
||||
best_version=subscribe_in.best_version,
|
||||
exist_ok=True)
|
||||
return schemas.Response(success=True if sid else False, message=message, data={
|
||||
"id": sid
|
||||
})
|
||||
sid, message = SubscribeChain().add(mtype=mtype,
|
||||
title=title,
|
||||
year=subscribe_in.year,
|
||||
tmdbid=subscribe_in.tmdbid,
|
||||
season=subscribe_in.season,
|
||||
doubanid=subscribe_in.doubanid,
|
||||
username=current_user.name,
|
||||
best_version=subscribe_in.best_version,
|
||||
save_path=subscribe_in.save_path,
|
||||
search_imdbid=subscribe_in.search_imdbid,
|
||||
exist_ok=True)
|
||||
return schemas.Response(
|
||||
success=bool(sid), message=message, data={"id": sid}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/", summary="更新订阅", response_model=schemas.Response)
|
||||
@@ -94,7 +103,7 @@ def update_subscribe(
|
||||
subscribe = Subscribe.get(db, subscribe_in.id)
|
||||
if not subscribe:
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
if subscribe_in.sites:
|
||||
if subscribe_in.sites is not None:
|
||||
subscribe_in.sites = json.dumps(subscribe_in.sites)
|
||||
# 避免更新缺失集数
|
||||
subscribe_dict = subscribe_in.dict()
|
||||
@@ -107,6 +116,9 @@ def update_subscribe(
|
||||
subscribe_dict["lack_episode"] = (subscribe.lack_episode
|
||||
+ (subscribe_in.total_episode
|
||||
- (subscribe.total_episode or 0)))
|
||||
# 是否手动修改过总集数
|
||||
if subscribe_in.total_episode != subscribe.total_episode:
|
||||
subscribe_dict["manual_total_episode"] = 1
|
||||
subscribe.update(db, subscribe_dict)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -115,23 +127,31 @@ def update_subscribe(
|
||||
def subscribe_mediaid(
|
||||
mediaid: str,
|
||||
season: int = None,
|
||||
title: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID或豆瓣ID查询订阅 tmdb:/douban:
|
||||
"""
|
||||
result = None
|
||||
if mediaid.startswith("tmdb:"):
|
||||
tmdbid = mediaid[5:]
|
||||
if not tmdbid or not str(tmdbid).isdigit():
|
||||
return Subscribe()
|
||||
result = Subscribe.exists(db, int(tmdbid), season)
|
||||
result = Subscribe.exists(db, tmdbid=int(tmdbid), season=season)
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid[7:]
|
||||
if not doubanid:
|
||||
return Subscribe()
|
||||
result = Subscribe.get_by_doubanid(db, doubanid)
|
||||
else:
|
||||
result = None
|
||||
# 豆瓣已订阅如果 id 搜索无结果使用标题搜索
|
||||
# 会造成同名结果也会被返回
|
||||
if not result and title:
|
||||
meta = MetaInfo(title)
|
||||
if season:
|
||||
meta.begin_season = season
|
||||
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
|
||||
|
||||
if result and result.sites:
|
||||
result.sites = json.loads(result.sites)
|
||||
|
||||
@@ -140,35 +160,38 @@ def subscribe_mediaid(
|
||||
|
||||
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response)
|
||||
def refresh_subscribes(
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
刷新所有订阅
|
||||
"""
|
||||
SubscribeChain(db).refresh()
|
||||
Scheduler().start("subscribe_refresh")
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
|
||||
def check_subscribes(
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
刷新所有订阅
|
||||
刷新订阅 TMDB 信息
|
||||
"""
|
||||
SubscribeChain(db).check()
|
||||
Scheduler().start("subscribe_tmdb")
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
|
||||
def search_subscribes(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
搜索所有订阅
|
||||
"""
|
||||
background_tasks.add_task(start_subscribe_search, db=db, sid=None, state='R')
|
||||
background_tasks.add_task(
|
||||
Scheduler().start,
|
||||
job_id="subscribe_search",
|
||||
sid=None,
|
||||
state='R',
|
||||
manual=True
|
||||
)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -176,12 +199,17 @@ def search_subscribes(
|
||||
def search_subscribe(
|
||||
subscribe_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据订阅编号搜索订阅
|
||||
"""
|
||||
background_tasks.add_task(start_subscribe_search, db=db, sid=subscribe_id, state=None)
|
||||
background_tasks.add_task(
|
||||
Scheduler().start,
|
||||
job_id="subscribe_search",
|
||||
sid=subscribe_id,
|
||||
state=None,
|
||||
manual=True
|
||||
)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -193,8 +221,10 @@ def read_subscribe(
|
||||
"""
|
||||
根据订阅编号查询订阅信息
|
||||
"""
|
||||
if not subscribe_id:
|
||||
return Subscribe()
|
||||
subscribe = Subscribe.get(db, subscribe_id)
|
||||
if subscribe.sites:
|
||||
if subscribe and subscribe.sites:
|
||||
subscribe.sites = json.loads(subscribe.sites)
|
||||
return subscribe
|
||||
|
||||
@@ -238,10 +268,9 @@ def delete_subscribe(
|
||||
|
||||
@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response)
|
||||
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
authorization: str = Header(None)) -> Any:
|
||||
"""
|
||||
Jellyseerr/Overseerr订阅
|
||||
Jellyseerr/Overseerr网络勾子通知订阅
|
||||
"""
|
||||
if not authorization or authorization != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
@@ -266,7 +295,6 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||
# 添加订阅
|
||||
if media_type == MediaType.MOVIE:
|
||||
background_tasks.add_task(start_subscribe_add,
|
||||
db=db,
|
||||
mtype=media_type,
|
||||
tmdbid=tmdbId,
|
||||
title=subject,
|
||||
@@ -281,7 +309,6 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||
break
|
||||
for season in seasons:
|
||||
background_tasks.add_task(start_subscribe_add,
|
||||
db=db,
|
||||
mtype=media_type,
|
||||
tmdbid=tmdbId,
|
||||
title=subject,
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import json
|
||||
import time
|
||||
import tailer
|
||||
from datetime import datetime
|
||||
from typing import Union
|
||||
from typing import Union, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
import tailer
|
||||
from dotenv import set_key
|
||||
from fastapi import APIRouter, HTTPException, Depends, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.search import SearchChain
|
||||
from app.core.config import settings
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
@@ -24,21 +26,60 @@ from version import APP_VERSION
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/img/{imgurl:path}/{proxy}", summary="图片代理")
|
||||
def get_img(imgurl: str, proxy: bool = False) -> Any:
|
||||
"""
|
||||
通过图片代理(使用代理服务器)
|
||||
"""
|
||||
if not imgurl:
|
||||
return None
|
||||
if proxy:
|
||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=settings.PROXY).get_res(url=imgurl)
|
||||
else:
|
||||
response = RequestUtils(ua=settings.USER_AGENT).get_res(url=imgurl)
|
||||
if response:
|
||||
return Response(content=response.content, media_type="image/jpeg")
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/env", summary="查询系统环境变量", response_model=schemas.Response)
|
||||
def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询系统环境变量,包括当前版本号
|
||||
"""
|
||||
info = settings.dict(
|
||||
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD", "API_TOKEN"}
|
||||
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD"}
|
||||
)
|
||||
info.update({
|
||||
"VERSION": APP_VERSION
|
||||
"VERSION": APP_VERSION,
|
||||
"AUTH_VERSION": SitesHelper().auth_version,
|
||||
"INDEXER_VERSION": SitesHelper().indexer_version,
|
||||
})
|
||||
return schemas.Response(success=True,
|
||||
data=info)
|
||||
|
||||
|
||||
@router.post("/env", summary="更新系统环境变量", response_model=schemas.Response)
|
||||
def set_env_setting(env: dict,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
更新系统环境变量
|
||||
"""
|
||||
for k, v in env.items():
|
||||
if k == "undefined":
|
||||
continue
|
||||
if hasattr(settings, k):
|
||||
if v == "None":
|
||||
v = None
|
||||
setattr(settings, k, v)
|
||||
if v is None:
|
||||
v = ''
|
||||
else:
|
||||
v = str(v)
|
||||
set_key(settings.CONFIG_PATH / "app.env", k, v)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/progress/{process_type}", summary="实时进度")
|
||||
def get_progress(process_type: str, token: str):
|
||||
"""
|
||||
@@ -67,18 +108,32 @@ def get_setting(key: str,
|
||||
"""
|
||||
查询系统设置
|
||||
"""
|
||||
if hasattr(settings, key):
|
||||
value = getattr(settings, key)
|
||||
else:
|
||||
value = SystemConfigOper().get(key)
|
||||
return schemas.Response(success=True, data={
|
||||
"value": SystemConfigOper().get(key)
|
||||
"value": value
|
||||
})
|
||||
|
||||
|
||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
||||
def set_setting(key: str, value: Union[list, dict, str, int] = None,
|
||||
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
更新系统设置
|
||||
"""
|
||||
SystemConfigOper().set(key, value)
|
||||
if hasattr(settings, key):
|
||||
if value == "None":
|
||||
value = None
|
||||
setattr(settings, key, value)
|
||||
if value is None:
|
||||
value = ''
|
||||
else:
|
||||
value = str(value)
|
||||
set_key(settings.CONFIG_PATH / "app.env", key, value)
|
||||
else:
|
||||
SystemConfigOper().set(key, value)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -105,9 +160,11 @@ def get_message(token: str):
|
||||
|
||||
|
||||
@router.get("/logging", summary="实时日志")
|
||||
def get_logging(token: str):
|
||||
def get_logging(token: str, length: int = 50, logfile: str = "moviepilot.log"):
|
||||
"""
|
||||
实时获取系统日志,返回格式为SSE
|
||||
实时获取系统日志
|
||||
length = -1 时, 返回text/plain
|
||||
否则 返回格式SSE
|
||||
"""
|
||||
if not token or not verify_token(token):
|
||||
raise HTTPException(
|
||||
@@ -115,18 +172,74 @@ def get_logging(token: str):
|
||||
detail="认证失败!",
|
||||
)
|
||||
|
||||
log_path = settings.LOG_PATH / logfile
|
||||
|
||||
def log_generator():
|
||||
log_path = settings.LOG_PATH / 'moviepilot.log'
|
||||
# 读取文件末尾50行,不使用tailer模块
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
for line in f.readlines()[-50:]:
|
||||
for line in f.readlines()[-max(length, 50):]:
|
||||
yield 'data: %s\n\n' % line
|
||||
while True:
|
||||
for text in tailer.follow(open(log_path, 'r', encoding='utf-8')):
|
||||
yield 'data: %s\n\n' % (text or '')
|
||||
for t in tailer.follow(open(log_path, 'r', encoding='utf-8')):
|
||||
yield 'data: %s\n\n' % (t or '')
|
||||
time.sleep(1)
|
||||
|
||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||
# 根据length参数返回不同的响应
|
||||
if length == -1:
|
||||
# 返回全部日志作为文本响应
|
||||
if not log_path.exists():
|
||||
return Response(content="日志文件不存在!", media_type="text/plain")
|
||||
with open(log_path, 'r', encoding='utf-8') as file:
|
||||
text = file.read()
|
||||
return Response(content=text, media_type="text/plain")
|
||||
else:
|
||||
# 返回SSE流响应
|
||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
|
||||
def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询Github所有Release版本
|
||||
"""
|
||||
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||
f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
|
||||
if version_res:
|
||||
ver_json = version_res.json()
|
||||
if ver_json:
|
||||
return schemas.Response(success=True, data=ver_json)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@router.get("/ruletest", summary="优先级规则测试", response_model=schemas.Response)
|
||||
def ruletest(title: str,
|
||||
subtitle: str = None,
|
||||
ruletype: str = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索
|
||||
"""
|
||||
torrent = schemas.TorrentInfo(
|
||||
title=title,
|
||||
description=subtitle,
|
||||
)
|
||||
if ruletype == "2":
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.BestVersionFilterRules)
|
||||
elif ruletype == "3":
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.SearchFilterRules)
|
||||
else:
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.SubscribeFilterRules)
|
||||
if not rule_string:
|
||||
return schemas.Response(success=False, message="优先级规则未设置!")
|
||||
|
||||
# 过滤
|
||||
result = SearchChain().filter_torrents(rule_string=rule_string,
|
||||
torrent_list=[torrent])
|
||||
if not result:
|
||||
return schemas.Response(success=False, message="不符合优先级规则!")
|
||||
return schemas.Response(success=True, data={
|
||||
"priority": 100 - result[0].pri_order + 1
|
||||
})
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
@@ -156,49 +269,26 @@ def nettest(url: str,
|
||||
return schemas.Response(success=False, message="网络连接失败!")
|
||||
|
||||
|
||||
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
|
||||
def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
|
||||
def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询Github所有Release版本
|
||||
查询已加载的模块ID列表
|
||||
"""
|
||||
version_res = RequestUtils().get_res(f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
|
||||
if version_res:
|
||||
ver_json = version_res.json()
|
||||
if ver_json:
|
||||
return schemas.Response(success=True, data=ver_json)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@router.get("/ruletest", summary="过滤规则测试", response_model=schemas.Response)
|
||||
def ruletest(title: str,
|
||||
subtitle: str = None,
|
||||
ruletype: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
过滤规则测试,规则类型 1-订阅,2-洗版
|
||||
"""
|
||||
torrent = schemas.TorrentInfo(
|
||||
title=title,
|
||||
description=subtitle,
|
||||
)
|
||||
if ruletype == "2":
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.FilterRules2)
|
||||
else:
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.FilterRules)
|
||||
if not rule_string:
|
||||
return schemas.Response(success=False, message="过滤规则未设置!")
|
||||
|
||||
# 过滤
|
||||
result = SearchChain(db).filter_torrents(rule_string=rule_string,
|
||||
torrent_list=[torrent])
|
||||
if not result:
|
||||
return schemas.Response(success=False, message="不符合过滤规则!")
|
||||
module_ids = [module.__name__ for module in ModuleManager().get_modules("test")]
|
||||
return schemas.Response(success=True, data={
|
||||
"priority": 100 - result[0].pri_order + 1
|
||||
"ids": module_ids
|
||||
})
|
||||
|
||||
|
||||
@router.get("/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response)
|
||||
def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
模块可用性测试接口
|
||||
"""
|
||||
state, errmsg = ModuleManager().test(moduleid)
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
|
||||
def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
@@ -209,3 +299,25 @@ def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
# 执行重启
|
||||
ret, msg = SystemUtils.restart()
|
||||
return schemas.Response(success=ret, message=msg)
|
||||
|
||||
|
||||
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
|
||||
def reload_module(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
重新加载模块
|
||||
"""
|
||||
ModuleManager().reload()
|
||||
Scheduler().init()
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
||||
def execute_command(jobid: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
执行命令
|
||||
"""
|
||||
if not jobid:
|
||||
return schemas.Response(success=False, message="命令不能为空!")
|
||||
Scheduler().start(jobid)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason])
|
||||
def tmdb_seasons(tmdbid: int, db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询themoviedb所有季信息
|
||||
"""
|
||||
seasons_info = TmdbChain(db).tmdb_seasons(tmdbid=tmdbid)
|
||||
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
|
||||
if not seasons_info:
|
||||
return []
|
||||
else:
|
||||
@@ -29,16 +26,15 @@ def tmdb_seasons(tmdbid: int, db: Session = Depends(get_db),
|
||||
@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_similar(tmdbid: int,
|
||||
type_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询类似电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain(db).movie_similar(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain().movie_similar(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain(db).tv_similar(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain().tv_similar(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
@@ -50,16 +46,15 @@ def tmdb_similar(tmdbid: int,
|
||||
@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_recommend(tmdbid: int,
|
||||
type_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询推荐电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain(db).movie_recommend(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain().movie_recommend(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain(db).tv_recommend(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain().tv_recommend(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
@@ -72,16 +67,15 @@ def tmdb_recommend(tmdbid: int,
|
||||
def tmdb_credits(tmdbid: int,
|
||||
type_name: str,
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询演员阵容,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain(db).movie_credits(tmdbid=tmdbid, page=page)
|
||||
tmdbinfos = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain(db).tv_credits(tmdbid=tmdbid, page=page)
|
||||
tmdbinfos = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
@@ -92,12 +86,11 @@ def tmdb_credits(tmdbid: int,
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.TmdbPerson)
|
||||
def tmdb_person(person_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
tmdbinfo = TmdbChain(db).person_detail(person_id=person_id)
|
||||
tmdbinfo = TmdbChain().person_detail(person_id=person_id)
|
||||
if not tmdbinfo:
|
||||
return schemas.TmdbPerson()
|
||||
else:
|
||||
@@ -107,12 +100,11 @@ def tmdb_person(person_id: int,
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_person_credits(person_id: int,
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
tmdbinfo = TmdbChain(db).person_credits(person_id=person_id, page=page)
|
||||
tmdbinfo = TmdbChain().person_credits(person_id=person_id, page=page)
|
||||
if not tmdbinfo:
|
||||
return []
|
||||
else:
|
||||
@@ -124,16 +116,15 @@ def tmdb_movies(sort_by: str = "popularity.desc",
|
||||
with_genres: str = "",
|
||||
with_original_language: str = "",
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB电影信息
|
||||
"""
|
||||
movies = TmdbChain(db).tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
if not movies:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=movie).to_dict() for movie in movies]
|
||||
@@ -144,16 +135,15 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
|
||||
with_genres: str = "",
|
||||
with_original_language: str = "",
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
tvs = TmdbChain(db).tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
if not tvs:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=tv).to_dict() for tv in tvs]
|
||||
@@ -161,12 +151,11 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
|
||||
|
||||
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_trending(page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
infos = TmdbChain(db).tmdb_trending(page=page)
|
||||
infos = TmdbChain().tmdb_trending(page=page)
|
||||
if not infos:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=info).to_dict() for info in infos]
|
||||
@@ -174,12 +163,11 @@ def tmdb_trending(page: int = 1,
|
||||
|
||||
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
|
||||
def tmdb_season_episodes(tmdbid: int, season: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询某季的所有信信息
|
||||
"""
|
||||
episodes_info = TmdbChain(db).tmdb_episodes(tmdbid=tmdbid, season=season)
|
||||
episodes_info = TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)
|
||||
if not episodes_info:
|
||||
return []
|
||||
else:
|
||||
|
||||
@@ -8,13 +8,15 @@ from app import schemas
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
||||
def manual_transfer(path: str,
|
||||
def manual_transfer(path: str = None,
|
||||
logid: int = None,
|
||||
target: str = None,
|
||||
tmdbid: int = None,
|
||||
type_name: str = None,
|
||||
@@ -28,13 +30,14 @@ def manual_transfer(path: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
手动转移,支持自定义剧集识别格式
|
||||
手动转移,文件或历史记录,支持自定义剧集识别格式
|
||||
:param path: 转移路径或文件
|
||||
:param logid: 转移历史记录ID
|
||||
:param target: 目标路径
|
||||
:param type_name: 媒体类型、电影/电视剧
|
||||
:param tmdbid: tmdbid
|
||||
:param season: 剧集季号
|
||||
:param transfer_type: 转移类型,move/copy
|
||||
:param transfer_type: 转移类型,move/copy 等
|
||||
:param episode_format: 剧集识别格式
|
||||
:param episode_detail: 剧集识别详细信息
|
||||
:param episode_part: 剧集识别分集信息
|
||||
@@ -43,11 +46,35 @@ def manual_transfer(path: str,
|
||||
:param db: 数据库
|
||||
:param _: Token校验
|
||||
"""
|
||||
in_path = Path(path)
|
||||
if target:
|
||||
target = Path(target)
|
||||
if not target.exists():
|
||||
return schemas.Response(success=False, message=f"目标路径不存在")
|
||||
force = False
|
||||
target = Path(target) if target else None
|
||||
transfer = TransferChain()
|
||||
if logid:
|
||||
# 查询历史记录
|
||||
history: TransferHistory = TransferHistory.get(db, logid)
|
||||
if not history:
|
||||
return schemas.Response(success=False, message=f"历史记录不存在,ID:{logid}")
|
||||
# 强制转移
|
||||
force = True
|
||||
if history.status and ("move" in history.mode):
|
||||
# 重新整理成功的转移,则使用成功的 dest 做 in_path
|
||||
in_path = Path(history.dest)
|
||||
else:
|
||||
# 源路径
|
||||
in_path = Path(history.src)
|
||||
# 目的路径
|
||||
if history.dest and str(history.dest) != "None":
|
||||
# 删除旧的已整理文件
|
||||
transfer.delete_files(Path(history.dest))
|
||||
if not target:
|
||||
target = transfer.get_root_path(path=history.dest,
|
||||
type_name=history.type,
|
||||
category=history.category)
|
||||
elif path:
|
||||
in_path = Path(path)
|
||||
else:
|
||||
return schemas.Response(success=False, message=f"缺少参数:path/logid")
|
||||
|
||||
# 类型
|
||||
mtype = MediaType(type_name) if type_name else None
|
||||
# 自定义格式
|
||||
@@ -60,7 +87,7 @@ def manual_transfer(path: str,
|
||||
offset=episode_offset,
|
||||
)
|
||||
# 开始转移
|
||||
state, errormsg = TransferChain(db).manual_transfer(
|
||||
state, errormsg = transfer.manual_transfer(
|
||||
in_path=in_path,
|
||||
target=target,
|
||||
tmdbid=tmdbid,
|
||||
@@ -68,7 +95,8 @@ def manual_transfer(path: str,
|
||||
season=season,
|
||||
transfer_type=transfer_type,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize
|
||||
min_filesize=min_filesize,
|
||||
force=force
|
||||
)
|
||||
# 失败
|
||||
if not state:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import re
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
@@ -59,6 +60,10 @@ def update_user(
|
||||
"""
|
||||
user_info = user_in.dict()
|
||||
if user_info.get("password"):
|
||||
# 正则表达式匹配密码包含字母、数字、特殊字符中的至少两项
|
||||
pattern = r'^(?![a-zA-Z]+$)(?!\d+$)(?![^\da-zA-Z\s]+$).{6,50}$'
|
||||
if not re.match(pattern, user_info.get("password")):
|
||||
return schemas.Response(success=False, message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位")
|
||||
user_info["hashed_password"] = get_password_hash(user_info["password"])
|
||||
user_info.pop("password")
|
||||
user = User.get_by_name(db, name=user_info["name"])
|
||||
|
||||
@@ -1,48 +1,42 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Request, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.webhook import WebhookChain
|
||||
from app.core.config import settings
|
||||
from app.db import get_db
|
||||
from app.core.security import verify_uri_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def start_webhook_chain(db: Session, body: Any, form: Any, args: Any):
|
||||
def start_webhook_chain(body: Any, form: Any, args: Any):
|
||||
"""
|
||||
启动链式任务
|
||||
"""
|
||||
WebhookChain(db).message(body=body, form=form, args=args)
|
||||
WebhookChain().message(body=body, form=form, args=args)
|
||||
|
||||
|
||||
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||
async def webhook_message(background_tasks: BackgroundTasks,
|
||||
token: str, request: Request,
|
||||
db: Session = Depends(get_db),) -> Any:
|
||||
request: Request,
|
||||
_: str = Depends(verify_uri_token)
|
||||
) -> Any:
|
||||
"""
|
||||
Webhook响应
|
||||
"""
|
||||
if token != settings.API_TOKEN:
|
||||
return schemas.Response(success=False, message="token认证不通过")
|
||||
body = await request.body()
|
||||
form = await request.form()
|
||||
args = request.query_params
|
||||
background_tasks.add_task(start_webhook_chain, db, body, form, args)
|
||||
background_tasks.add_task(start_webhook_chain, body, form, args)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||
async def webhook_message(background_tasks: BackgroundTasks,
|
||||
token: str, request: Request,
|
||||
db: Session = Depends(get_db)) -> Any:
|
||||
request: Request, _: str = Depends(verify_uri_token)) -> Any:
|
||||
"""
|
||||
Webhook响应
|
||||
"""
|
||||
if token != settings.API_TOKEN:
|
||||
return schemas.Response(success=False, message="token认证不通过")
|
||||
args = request.query_params
|
||||
background_tasks.add_task(start_webhook_chain, db, None, None, args)
|
||||
background_tasks.add_task(start_webhook_chain, None, None, args)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from requests import Session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_uri_apikey
|
||||
from app.db import get_db
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.schemas import RadarrMovie, SonarrSeries
|
||||
@@ -18,15 +19,10 @@ arr_router = APIRouter(tags=['servarr'])
|
||||
|
||||
|
||||
@arr_router.get("/system/status", summary="系统状态")
|
||||
def arr_system_status(apikey: str) -> Any:
|
||||
def arr_system_status(_: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr系统状态
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
return {
|
||||
"appName": "MoviePilot",
|
||||
"instanceName": "moviepilot",
|
||||
@@ -77,15 +73,10 @@ def arr_system_status(apikey: str) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/qualityProfile", summary="质量配置")
|
||||
def arr_qualityProfile(apikey: str) -> Any:
|
||||
def arr_qualityProfile(_: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr质量配置
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -123,15 +114,10 @@ def arr_qualityProfile(apikey: str) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/rootfolder", summary="根目录")
|
||||
def arr_rootfolder(apikey: str) -> Any:
|
||||
def arr_rootfolder(_: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr根目录
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -144,15 +130,10 @@ def arr_rootfolder(apikey: str) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/tag", summary="标签")
|
||||
def arr_tag(apikey: str) -> Any:
|
||||
def arr_tag(_: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr标签
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -162,15 +143,10 @@ def arr_tag(apikey: str) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/languageprofile", summary="语言")
|
||||
def arr_languageprofile(apikey: str) -> Any:
|
||||
def arr_languageprofile(_: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr语言
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
return [{
|
||||
"id": 1,
|
||||
"name": "默认",
|
||||
@@ -193,7 +169,7 @@ def arr_languageprofile(apikey: str) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie])
|
||||
def arr_movies(apikey: str, db: Session = Depends(get_db)) -> Any:
|
||||
def arr_movies(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
查询Rardar电影
|
||||
"""
|
||||
@@ -262,11 +238,6 @@ def arr_movies(apikey: str, db: Session = Depends(get_db)) -> Any:
|
||||
}
|
||||
]
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
# 查询所有电影订阅
|
||||
result = []
|
||||
subscribes = Subscribe.list(db)
|
||||
@@ -289,23 +260,18 @@ def arr_movies(apikey: str, db: Session = Depends(get_db)) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie])
|
||||
def arr_movie_lookup(apikey: str, term: str, db: Session = Depends(get_db)) -> Any:
|
||||
def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
查询Rardar电影 term: `tmdb:${id}`
|
||||
存在和不存在均不能返回错误
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
tmdbid = term.replace("tmdb:", "")
|
||||
# 查询媒体信息
|
||||
mediainfo = MediaChain(db).recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid))
|
||||
mediainfo = MediaChain().recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid))
|
||||
if not mediainfo:
|
||||
return [RadarrMovie()]
|
||||
# 查询是否已存在
|
||||
exists = MediaChain(db).media_exists(mediainfo=mediainfo)
|
||||
exists = MediaChain().media_exists(mediainfo=mediainfo)
|
||||
if not exists:
|
||||
# 文件不存在
|
||||
hasfile = False
|
||||
@@ -340,15 +306,10 @@ def arr_movie_lookup(apikey: str, term: str, db: Session = Depends(get_db)) -> A
|
||||
|
||||
|
||||
@arr_router.get("/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie)
|
||||
def arr_movie(apikey: str, mid: int, db: Session = Depends(get_db)) -> Any:
|
||||
def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
查询Rardar电影订阅
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
subscribe = Subscribe.get(db, mid)
|
||||
if subscribe:
|
||||
return RadarrMovie(
|
||||
@@ -371,18 +332,13 @@ def arr_movie(apikey: str, mid: int, db: Session = Depends(get_db)) -> Any:
|
||||
|
||||
|
||||
@arr_router.post("/movie", summary="新增电影订阅")
|
||||
def arr_add_movie(apikey: str,
|
||||
movie: RadarrMovie,
|
||||
def arr_add_movie(movie: RadarrMovie,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(verify_uri_apikey)
|
||||
) -> Any:
|
||||
"""
|
||||
新增Rardar电影订阅
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
# 检查订阅是否已存在
|
||||
subscribe = Subscribe.get_by_tmdbid(db, movie.tmdbId)
|
||||
if subscribe:
|
||||
@@ -390,11 +346,11 @@ def arr_add_movie(apikey: str,
|
||||
"id": subscribe.id
|
||||
}
|
||||
# 添加订阅
|
||||
sid, message = SubscribeChain(db).add(title=movie.title,
|
||||
year=movie.year,
|
||||
mtype=MediaType.MOVIE,
|
||||
tmdbid=movie.tmdbId,
|
||||
userid="Seerr")
|
||||
sid, message = SubscribeChain().add(title=movie.title,
|
||||
year=movie.year,
|
||||
mtype=MediaType.MOVIE,
|
||||
tmdbid=movie.tmdbId,
|
||||
username="Seerr")
|
||||
if sid:
|
||||
return {
|
||||
"id": sid
|
||||
@@ -407,15 +363,10 @@ def arr_add_movie(apikey: str,
|
||||
|
||||
|
||||
@arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response)
|
||||
def arr_remove_movie(apikey: str, mid: int, db: Session = Depends(get_db)) -> Any:
|
||||
def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
删除Rardar电影订阅
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
subscribe = Subscribe.get(db, mid)
|
||||
if subscribe:
|
||||
subscribe.delete(db, mid)
|
||||
@@ -428,7 +379,7 @@ def arr_remove_movie(apikey: str, mid: int, db: Session = Depends(get_db)) -> An
|
||||
|
||||
|
||||
@arr_router.get("/series", summary="所有剧集", response_model=List[schemas.SonarrSeries])
|
||||
def arr_series(apikey: str, db: Session = Depends(get_db)) -> Any:
|
||||
def arr_series(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
查询Sonarr剧集
|
||||
"""
|
||||
@@ -534,11 +485,6 @@ def arr_series(apikey: str, db: Session = Depends(get_db)) -> Any:
|
||||
}
|
||||
]
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
# 查询所有电视剧订阅
|
||||
result = []
|
||||
subscribes = Subscribe.list(db)
|
||||
@@ -569,20 +515,14 @@ def arr_series(apikey: str, db: Session = Depends(get_db)) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/series/lookup", summary="查询剧集")
|
||||
def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) -> Any:
|
||||
def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
查询Sonarr剧集 term: `tvdb:${id}` title
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
|
||||
# 获取TVDBID
|
||||
if not term.startswith("tvdb:"):
|
||||
mediainfo = MediaChain(db).recognize_media(meta=MetaInfo(term),
|
||||
mtype=MediaType.TV)
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
|
||||
mtype=MediaType.TV)
|
||||
if not mediainfo:
|
||||
return [SonarrSeries()]
|
||||
tvdbid = mediainfo.tvdb_id
|
||||
@@ -593,7 +533,7 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
|
||||
tvdbid = int(term.replace("tvdb:", ""))
|
||||
|
||||
# 查询TVDB信息
|
||||
tvdbinfo = MediaChain(db).tvdb_info(tvdbid=tvdbid)
|
||||
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
|
||||
if not tvdbinfo:
|
||||
return [SonarrSeries()]
|
||||
|
||||
@@ -605,11 +545,11 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
|
||||
|
||||
# 根据TVDB查询媒体信息
|
||||
if not mediainfo:
|
||||
mediainfo = MediaChain(db).recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
|
||||
mtype=MediaType.TV)
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
|
||||
mtype=MediaType.TV)
|
||||
|
||||
# 查询是否存在
|
||||
exists = MediaChain(db).media_exists(mediainfo)
|
||||
exists = MediaChain().media_exists(mediainfo)
|
||||
if exists:
|
||||
hasfile = True
|
||||
else:
|
||||
@@ -664,15 +604,10 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
|
||||
|
||||
|
||||
@arr_router.get("/series/{tid}", summary="剧集详情")
|
||||
def arr_serie(apikey: str, tid: int, db: Session = Depends(get_db)) -> Any:
|
||||
def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
查询Sonarr剧集
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
subscribe = Subscribe.get(db, tid)
|
||||
if subscribe:
|
||||
return SonarrSeries(
|
||||
@@ -684,7 +619,7 @@ def arr_serie(apikey: str, tid: int, db: Session = Depends(get_db)) -> Any:
|
||||
"monitored": True,
|
||||
}],
|
||||
year=subscribe.year,
|
||||
remotePoster=subscribe.image,
|
||||
remotePoster=subscribe.poster,
|
||||
tmdbId=subscribe.tmdbid,
|
||||
tvdbId=subscribe.tvdbid,
|
||||
imdbId=subscribe.imdbid,
|
||||
@@ -703,16 +638,12 @@ def arr_serie(apikey: str, tid: int, db: Session = Depends(get_db)) -> Any:
|
||||
|
||||
|
||||
@arr_router.post("/series", summary="新增剧集订阅")
|
||||
def arr_add_series(apikey: str, tv: schemas.SonarrSeries,
|
||||
db: Session = Depends(get_db)) -> Any:
|
||||
def arr_add_series(tv: schemas.SonarrSeries,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
新增Sonarr剧集订阅
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
# 检查订阅是否存在
|
||||
left_seasons = []
|
||||
for season in tv.seasons:
|
||||
@@ -732,12 +663,12 @@ def arr_add_series(apikey: str, tv: schemas.SonarrSeries,
|
||||
for season in left_seasons:
|
||||
if not season.get("monitored"):
|
||||
continue
|
||||
sid, message = SubscribeChain(db).add(title=tv.title,
|
||||
year=tv.year,
|
||||
season=season.get("seasonNumber"),
|
||||
tmdbid=tv.tmdbId,
|
||||
mtype=MediaType.TV,
|
||||
userid="Seerr")
|
||||
sid, message = SubscribeChain().add(title=tv.title,
|
||||
year=tv.year,
|
||||
season=season.get("seasonNumber"),
|
||||
tmdbid=tv.tmdbId,
|
||||
mtype=MediaType.TV,
|
||||
username="Seerr")
|
||||
|
||||
if sid:
|
||||
return {
|
||||
@@ -751,15 +682,10 @@ def arr_add_series(apikey: str, tv: schemas.SonarrSeries,
|
||||
|
||||
|
||||
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
|
||||
def arr_remove_series(apikey: str, tid: int, db: Session = Depends(get_db)) -> Any:
|
||||
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
||||
"""
|
||||
删除Sonarr剧集订阅
|
||||
"""
|
||||
if not apikey or apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
subscribe = Subscribe.get(db, tid)
|
||||
if subscribe:
|
||||
subscribe.delete(db, tid)
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Optional, Any, Tuple, List, Set, Union, Dict
|
||||
|
||||
from qbittorrentapi import TorrentFilesList
|
||||
from ruamel.yaml import CommentedMap
|
||||
from sqlalchemy.orm import Session
|
||||
from transmission_rpc import File
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -18,7 +17,7 @@ from app.core.meta import MetaBase
|
||||
from app.core.module import ModuleManager
|
||||
from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||
WebhookEventInfo
|
||||
WebhookEventInfo, TmdbEpisode
|
||||
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
|
||||
from app.utils.object import ObjectUtils
|
||||
|
||||
@@ -28,11 +27,10 @@ class ChainBase(metaclass=ABCMeta):
|
||||
处理链基类
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
def __init__(self):
|
||||
"""
|
||||
公共初始化
|
||||
"""
|
||||
self._db = db
|
||||
self.modulemanager = ModuleManager()
|
||||
self.eventmanager = EventManager()
|
||||
|
||||
@@ -47,7 +45,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
with open(cache_path, 'rb') as f:
|
||||
return pickle.load(f)
|
||||
except Exception as err:
|
||||
logger.error(f"加载缓存 {filename} 出错:{err}")
|
||||
logger.error(f"加载缓存 {filename} 出错:{str(err)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -59,12 +57,21 @@ class ChainBase(metaclass=ABCMeta):
|
||||
with open(settings.TEMP_PATH / filename, 'wb') as f:
|
||||
pickle.dump(cache, f)
|
||||
except Exception as err:
|
||||
logger.error(f"保存缓存 {filename} 出错:{err}")
|
||||
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
|
||||
finally:
|
||||
# 主动资源回收
|
||||
del cache
|
||||
gc.collect()
|
||||
|
||||
@staticmethod
|
||||
def remove_cache(filename: str) -> None:
|
||||
"""
|
||||
删除本地缓存
|
||||
"""
|
||||
cache_path = settings.TEMP_PATH / filename
|
||||
if cache_path.exists():
|
||||
Path(cache_path).unlink()
|
||||
|
||||
def run_module(self, method: str, *args, **kwargs) -> Any:
|
||||
"""
|
||||
运行包含该方法的所有模块,然后返回结果
|
||||
@@ -100,20 +107,58 @@ class ChainBase(metaclass=ABCMeta):
|
||||
# 中止继续执行
|
||||
break
|
||||
except Exception as err:
|
||||
logger.error(f"运行模块 {method} 出错:{module.__class__.__name__} - {err}\n{traceback.print_exc()}")
|
||||
logger.error(
|
||||
f"运行模块 {method} 出错:{module.__class__.__name__} - {str(err)}\n{traceback.format_exc()}")
|
||||
return result
|
||||
|
||||
def recognize_media(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
tmdbid: int = None) -> Optional[MediaInfo]:
|
||||
tmdbid: int = None,
|
||||
doubanid: str = None,
|
||||
cache: bool = True) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与tmdbid配套
|
||||
:param tmdbid: tmdbid
|
||||
:param doubanid: 豆瓣ID
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
return self.run_module("recognize_media", meta=meta, mtype=mtype, tmdbid=tmdbid)
|
||||
# 识别用名中含指定信息情形
|
||||
if not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
|
||||
mtype = meta.type
|
||||
if not tmdbid and hasattr(meta, "tmdbid"):
|
||||
tmdbid = meta.tmdbid
|
||||
if not doubanid and hasattr(meta, "doubanid"):
|
||||
doubanid = meta.doubanid
|
||||
return self.run_module("recognize_media", meta=meta, mtype=mtype,
|
||||
tmdbid=tmdbid, doubanid=doubanid, cache=cache)
|
||||
|
||||
def match_doubaninfo(self, name: str, imdbid: str = None,
|
||||
mtype: MediaType = None, year: str = None, season: int = None) -> Optional[dict]:
|
||||
"""
|
||||
搜索和匹配豆瓣信息
|
||||
:param name: 标题
|
||||
:param imdbid: imdbid
|
||||
:param mtype: 类型
|
||||
:param year: 年份
|
||||
:param season: 季
|
||||
"""
|
||||
return self.run_module("match_doubaninfo", name=name, imdbid=imdbid,
|
||||
mtype=mtype, year=year, season=season)
|
||||
|
||||
def match_tmdbinfo(self, name: str, mtype: MediaType = None,
|
||||
year: str = None, season: int = None) -> Optional[dict]:
|
||||
"""
|
||||
搜索和匹配TMDB信息
|
||||
:param name: 标题
|
||||
:param mtype: 类型
|
||||
:param year: 年份
|
||||
:param season: 季
|
||||
"""
|
||||
return self.run_module("match_tmdbinfo", name=name,
|
||||
mtype=mtype, year=year, season=season)
|
||||
|
||||
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
"""
|
||||
@@ -139,13 +184,14 @@ class ChainBase(metaclass=ABCMeta):
|
||||
image_prefix=image_prefix, image_type=image_type,
|
||||
season=season, episode=episode)
|
||||
|
||||
def douban_info(self, doubanid: str) -> Optional[dict]:
|
||||
def douban_info(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
|
||||
"""
|
||||
获取豆瓣信息
|
||||
:param doubanid: 豆瓣ID
|
||||
:param mtype: 媒体类型
|
||||
:return: 豆瓣信息
|
||||
"""
|
||||
return self.run_module("douban_info", doubanid=doubanid)
|
||||
return self.run_module("douban_info", doubanid=doubanid, mtype=mtype)
|
||||
|
||||
def tvdb_info(self, tvdbid: int) -> Optional[dict]:
|
||||
"""
|
||||
@@ -197,21 +243,19 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return self.run_module("search_medias", meta=meta)
|
||||
|
||||
def search_torrents(self, site: CommentedMap,
|
||||
mediainfo: MediaInfo,
|
||||
keyword: str = None,
|
||||
page: int = 0,
|
||||
area: str = "title") -> List[TorrentInfo]:
|
||||
keywords: List[str],
|
||||
mtype: MediaType = None,
|
||||
page: int = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
搜索一个站点的种子资源
|
||||
:param site: 站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param keyword: 搜索关键词,如有按关键词搜索,否则按媒体信息名称搜索
|
||||
:param keywords: 搜索关键词列表
|
||||
:param mtype: 媒体类型
|
||||
:param page: 页码
|
||||
:param area: 搜索区域
|
||||
:reutrn: 资源列表
|
||||
"""
|
||||
return self.run_module("search_torrents", mediainfo=mediainfo, site=site,
|
||||
keyword=keyword, page=page, area=area)
|
||||
return self.run_module("search_torrents", site=site, keywords=keywords,
|
||||
mtype=mtype, page=page)
|
||||
|
||||
def refresh_torrents(self, site: CommentedMap) -> List[TorrentInfo]:
|
||||
"""
|
||||
@@ -223,19 +267,23 @@ class ChainBase(metaclass=ABCMeta):
|
||||
|
||||
def filter_torrents(self, rule_string: str,
|
||||
torrent_list: List[TorrentInfo],
|
||||
season_episodes: Dict[int, list] = None) -> List[TorrentInfo]:
|
||||
season_episodes: Dict[int, list] = None,
|
||||
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
||||
"""
|
||||
过滤种子资源
|
||||
:param rule_string: 过滤规则
|
||||
:param torrent_list: 资源列表
|
||||
:param season_episodes: 季集数过滤 {season:[episodes]}
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 过滤后的资源列表,添加资源优先级
|
||||
"""
|
||||
return self.run_module("filter_torrents", rule_string=rule_string,
|
||||
torrent_list=torrent_list, season_episodes=season_episodes)
|
||||
torrent_list=torrent_list, season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None
|
||||
episodes: Set[int] = None, category: str = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER
|
||||
) -> Optional[Tuple[Optional[str], str]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
@@ -244,10 +292,12 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param cookie: cookie
|
||||
:param episodes: 需要下载的集数
|
||||
:param category: 种子分类
|
||||
:param downloader: 下载器
|
||||
:return: 种子Hash,错误信息
|
||||
"""
|
||||
return self.run_module("download", content=content, download_dir=download_dir,
|
||||
cookie=cookie, episodes=episodes, category=category)
|
||||
cookie=cookie, episodes=episodes, category=category,
|
||||
downloader=downloader)
|
||||
|
||||
def download_added(self, context: Context, download_dir: Path, torrent_path: Path = None) -> None:
|
||||
"""
|
||||
@@ -261,17 +311,21 @@ class ChainBase(metaclass=ABCMeta):
|
||||
download_dir=download_dir)
|
||||
|
||||
def list_torrents(self, status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
hashs: Union[list, str] = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER
|
||||
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
"""
|
||||
获取下载器种子列表
|
||||
:param status: 种子状态
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: 下载器中符合状态的种子列表
|
||||
"""
|
||||
return self.run_module("list_torrents", status=status, hashs=hashs)
|
||||
return self.run_module("list_torrents", status=status, hashs=hashs, downloader=downloader)
|
||||
|
||||
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transfer_type: str, target: Path = None) -> Optional[TransferInfo]:
|
||||
transfer_type: str, target: Path = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
||||
"""
|
||||
文件转移
|
||||
:param path: 文件路径
|
||||
@@ -279,50 +333,60 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移模式
|
||||
:param target: 转移目标路径
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
|
||||
transfer_type=transfer_type, target=target)
|
||||
transfer_type=transfer_type, target=target, episodes_info=episodes_info)
|
||||
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None) -> None:
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
|
||||
"""
|
||||
转移完成后的处理
|
||||
:param hashs: 种子Hash
|
||||
:param path: 源目录
|
||||
:param downloader: 下载器
|
||||
"""
|
||||
return self.run_module("transfer_completed", hashs=hashs, path=path)
|
||||
return self.run_module("transfer_completed", hashs=hashs, path=path, downloader=downloader)
|
||||
|
||||
def remove_torrents(self, hashs: Union[str, list]) -> bool:
|
||||
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
|
||||
"""
|
||||
删除下载器种子
|
||||
:param hashs: 种子Hash
|
||||
:param delete_file: 是否删除文件
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
return self.run_module("remove_torrents", hashs=hashs)
|
||||
return self.run_module("remove_torrents", hashs=hashs, delete_file=delete_file, downloader=downloader)
|
||||
|
||||
def start_torrents(self, hashs: Union[list, str]) -> bool:
|
||||
def start_torrents(self, hashs: Union[list, str], downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
|
||||
"""
|
||||
开始下载
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
return self.run_module("start_torrents", hashs=hashs)
|
||||
return self.run_module("start_torrents", hashs=hashs, downloader=downloader)
|
||||
|
||||
def stop_torrents(self, hashs: Union[list, str]) -> bool:
|
||||
def stop_torrents(self, hashs: Union[list, str], downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
|
||||
"""
|
||||
停止下载
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
return self.run_module("stop_torrents", hashs=hashs)
|
||||
return self.run_module("stop_torrents", hashs=hashs, downloader=downloader)
|
||||
|
||||
def torrent_files(self, tid: str) -> Optional[Union[TorrentFilesList, List[File]]]:
|
||||
def torrent_files(self, tid: str,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[Union[TorrentFilesList, List[File]]]:
|
||||
"""
|
||||
获取种子文件
|
||||
:param tid: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: 种子文件
|
||||
"""
|
||||
return self.run_module("torrent_files", tid=tid)
|
||||
return self.run_module("torrent_files", tid=tid, downloader=downloader)
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||
"""
|
||||
@@ -333,15 +397,6 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("media_exists", mediainfo=mediainfo, itemid=itemid)
|
||||
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> Optional[bool]:
|
||||
"""
|
||||
刷新媒体库
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param file_path: 文件路径
|
||||
:return: 成功或失败
|
||||
"""
|
||||
return self.run_module("refresh_mediaserver", mediainfo=mediainfo, file_path=file_path)
|
||||
|
||||
def post_message(self, message: Notification) -> None:
|
||||
"""
|
||||
发送消息
|
||||
@@ -352,6 +407,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage,
|
||||
data={
|
||||
"channel": message.channel,
|
||||
"type": message.mtype,
|
||||
"title": message.title,
|
||||
"text": message.text,
|
||||
"image": message.image,
|
||||
@@ -381,29 +437,34 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("post_torrents_message", message=message, torrents=torrents)
|
||||
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo) -> None:
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
|
||||
force_nfo: bool = False, force_img: bool = False) -> None:
|
||||
"""
|
||||
刮削元数据
|
||||
:param path: 媒体文件路径
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移模式
|
||||
:param force_nfo: 强制刮削nfo
|
||||
:param force_img: 强制刮削图片
|
||||
:return: 成功或失败
|
||||
"""
|
||||
return self.run_module("scrape_metadata", path=path, mediainfo=mediainfo)
|
||||
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo,
|
||||
transfer_type=transfer_type, force_nfo=force_nfo, force_img=force_img)
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]) -> None:
|
||||
"""
|
||||
注册菜单命令
|
||||
"""
|
||||
return self.run_module("register_commands", commands=commands)
|
||||
self.run_module("register_commands", commands=commands)
|
||||
|
||||
def scheduler_job(self) -> None:
|
||||
"""
|
||||
定时任务,每10分钟调用一次,模块实现该接口以实现定时服务
|
||||
"""
|
||||
return self.run_module("scheduler_job")
|
||||
self.run_module("scheduler_job")
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""
|
||||
清理缓存,模块实现该接口响应清理缓存事件
|
||||
"""
|
||||
return self.run_module("clear_cache")
|
||||
self.run_module("clear_cache")
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import base64
|
||||
from typing import Tuple, Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from lxml import etree
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.site import SiteChain
|
||||
from app.core.config import settings
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.siteicon_oper import SiteIconOper
|
||||
from app.helper.cloudflare import under_challenge
|
||||
from app.helper.cookiecloud import CookieCloudHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, NotificationType, MessageChannel
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.site import SiteUtils
|
||||
|
||||
|
||||
class CookieCloudChain(ChainBase):
|
||||
"""
|
||||
CookieCloud处理链
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
self.siteoper = SiteOper(self._db)
|
||||
self.siteiconoper = SiteIconOper(self._db)
|
||||
self.siteshelper = SitesHelper()
|
||||
self.rsshelper = RssHelper()
|
||||
self.sitechain = SiteChain(self._db)
|
||||
self.message = MessageHelper()
|
||||
self.cookiecloud = CookieCloudHelper(
|
||||
server=settings.COOKIECLOUD_HOST,
|
||||
key=settings.COOKIECLOUD_KEY,
|
||||
password=settings.COOKIECLOUD_PASSWORD
|
||||
)
|
||||
|
||||
def remote_sync(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
远程触发同步站点,发送消息
|
||||
"""
|
||||
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
|
||||
title="开始同步CookieCloud站点 ...", userid=userid))
|
||||
# 开始同步
|
||||
success, msg = self.process()
|
||||
if success:
|
||||
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
|
||||
title=f"同步站点成功,{msg}", userid=userid))
|
||||
else:
|
||||
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
|
||||
title=f"同步站点失败:{msg}", userid=userid))
|
||||
|
||||
def process(self, manual=False) -> Tuple[bool, str]:
|
||||
"""
|
||||
通过CookieCloud同步站点Cookie
|
||||
"""
|
||||
logger.info("开始同步CookieCloud站点 ...")
|
||||
cookies, msg = self.cookiecloud.download()
|
||||
if not cookies:
|
||||
logger.error(f"CookieCloud同步失败:{msg}")
|
||||
if manual:
|
||||
self.message.put(f"CookieCloud同步失败: {msg}")
|
||||
return False, msg
|
||||
# 保存Cookie或新增站点
|
||||
_update_count = 0
|
||||
_add_count = 0
|
||||
_fail_count = 0
|
||||
for domain, cookie in cookies.items():
|
||||
# 获取站点信息
|
||||
indexer = self.siteshelper.get_indexer(domain)
|
||||
site_info = self.siteoper.get_by_domain(domain)
|
||||
if site_info:
|
||||
# 检查站点连通性
|
||||
status, msg = self.sitechain.test(domain)
|
||||
# 更新站点Cookie
|
||||
if status:
|
||||
logger.info(f"站点【{site_info.name}】连通性正常,不同步CookieCloud数据")
|
||||
# 更新站点rss地址
|
||||
if not site_info.public and not site_info.rss:
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(
|
||||
url=site_info.url,
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT,
|
||||
proxy=True if site_info.proxy else False
|
||||
)
|
||||
if rss_url:
|
||||
logger.info(f"更新站点 {domain} RSS地址 ...")
|
||||
self.siteoper.update_rss(domain=domain, rss=rss_url)
|
||||
else:
|
||||
logger.warn(errmsg)
|
||||
continue
|
||||
# 更新站点Cookie
|
||||
logger.info(f"更新站点 {domain} Cookie ...")
|
||||
self.siteoper.update_cookie(domain=domain, cookies=cookie)
|
||||
_update_count += 1
|
||||
elif indexer:
|
||||
# 新增站点
|
||||
res = RequestUtils(cookies=cookie,
|
||||
ua=settings.USER_AGENT
|
||||
).get_res(url=indexer.get("domain"))
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
if not indexer.get("public") and not SiteUtils.is_logged_in(res.text):
|
||||
_fail_count += 1
|
||||
if under_challenge(res.text):
|
||||
logger.warn(f"站点 {indexer.get('name')} 被Cloudflare防护,无法登录,无法添加站点")
|
||||
continue
|
||||
logger.warn(
|
||||
f"站点 {indexer.get('name')} 登录失败,没有该站点账号或Cookie已失效,无法添加站点")
|
||||
continue
|
||||
elif res is not None:
|
||||
_fail_count += 1
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接状态码:{res.status_code},无法添加站点")
|
||||
continue
|
||||
else:
|
||||
_fail_count += 1
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
|
||||
continue
|
||||
# 获取rss地址
|
||||
rss_url = None
|
||||
if not indexer.get("public") and indexer.get("domain"):
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(url=indexer.get("domain"),
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT)
|
||||
if errmsg:
|
||||
logger.warn(errmsg)
|
||||
# 插入数据库
|
||||
logger.info(f"新增站点 {indexer.get('name')} ...")
|
||||
self.siteoper.add(name=indexer.get("name"),
|
||||
url=indexer.get("domain"),
|
||||
domain=domain,
|
||||
cookie=cookie,
|
||||
rss=rss_url,
|
||||
public=1 if indexer.get("public") else 0)
|
||||
_add_count += 1
|
||||
|
||||
# 保存站点图标
|
||||
if indexer:
|
||||
site_icon = self.siteiconoper.get_by_domain(domain)
|
||||
if not site_icon or not site_icon.base64:
|
||||
logger.info(f"开始缓存站点 {indexer.get('name')} 图标 ...")
|
||||
icon_url, icon_base64 = self.__parse_favicon(url=indexer.get("domain"),
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT)
|
||||
if icon_url:
|
||||
self.siteiconoper.update_icon(name=indexer.get("name"),
|
||||
domain=domain,
|
||||
icon_url=icon_url,
|
||||
icon_base64=icon_base64)
|
||||
logger.info(f"缓存站点 {indexer.get('name')} 图标成功")
|
||||
else:
|
||||
logger.warn(f"缓存站点 {indexer.get('name')} 图标失败")
|
||||
# 处理完成
|
||||
ret_msg = f"更新了{_update_count}个站点,新增了{_add_count}个站点"
|
||||
if _fail_count > 0:
|
||||
ret_msg += f",{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
|
||||
if manual:
|
||||
self.message.put(f"CookieCloud同步成功, {ret_msg}")
|
||||
logger.info(f"CookieCloud同步成功:{ret_msg}")
|
||||
return True, ret_msg
|
||||
|
||||
@staticmethod
|
||||
def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
解析站点favicon,返回base64 fav图标
|
||||
:param url: 站点地址
|
||||
:param cookie: Cookie
|
||||
:param ua: User-Agent
|
||||
:return:
|
||||
"""
|
||||
favicon_url = urljoin(url, "favicon.ico")
|
||||
res = RequestUtils(cookies=cookie, timeout=60, ua=ua).get_res(url=url)
|
||||
if res:
|
||||
html_text = res.text
|
||||
else:
|
||||
logger.error(f"获取站点页面失败:{url}")
|
||||
return favicon_url, None
|
||||
html = etree.HTML(html_text)
|
||||
if html:
|
||||
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
|
||||
if fav_link:
|
||||
favicon_url = urljoin(url, fav_link[0])
|
||||
|
||||
res = RequestUtils(cookies=cookie, timeout=20, ua=ua).get_res(url=favicon_url)
|
||||
if res:
|
||||
return favicon_url, base64.b64encode(res.content).decode()
|
||||
else:
|
||||
logger.error(f"获取站点图标失败:{favicon_url}")
|
||||
return favicon_url, None
|
||||
@@ -1,18 +1,21 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class DashboardChain(ChainBase):
|
||||
class DashboardChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
各类仪表板统计处理链
|
||||
"""
|
||||
def media_statistic(self) -> schemas.Statistic:
|
||||
def media_statistic(self) -> Optional[List[schemas.Statistic]]:
|
||||
"""
|
||||
媒体数量统计
|
||||
"""
|
||||
return self.run_module("media_statistic")
|
||||
|
||||
def downloader_info(self) -> schemas.DownloaderInfo:
|
||||
def downloader_info(self) -> Optional[List[schemas.DownloaderInfo]]:
|
||||
"""
|
||||
下载器信息
|
||||
"""
|
||||
|
||||
@@ -1,51 +1,17 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import Context
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.core.config import settings
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class DoubanChain(ChainBase):
|
||||
class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
豆瓣处理链
|
||||
豆瓣处理链,单例运行
|
||||
"""
|
||||
|
||||
def recognize_by_doubanid(self, doubanid: str) -> Optional[Context]:
|
||||
"""
|
||||
根据豆瓣ID识别媒体信息
|
||||
"""
|
||||
logger.info(f'开始识别媒体信息,豆瓣ID:{doubanid} ...')
|
||||
# 查询豆瓣信息
|
||||
doubaninfo = self.douban_info(doubanid=doubanid)
|
||||
if not doubaninfo:
|
||||
logger.warn(f'未查询到豆瓣信息,豆瓣ID:{doubanid}')
|
||||
return None
|
||||
return self.recognize_by_doubaninfo(doubaninfo)
|
||||
|
||||
def recognize_by_doubaninfo(self, doubaninfo: dict) -> Optional[Context]:
|
||||
"""
|
||||
根据豆瓣信息识别媒体信息
|
||||
"""
|
||||
# 使用原标题匹配
|
||||
meta = MetaInfo(title=doubaninfo.get("original_title") or doubaninfo.get("title"))
|
||||
# 处理类型
|
||||
if isinstance(doubaninfo.get('media_type'), MediaType):
|
||||
meta.type = doubaninfo.get('media_type')
|
||||
else:
|
||||
meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{meta.name} 未识别到TMDB媒体信息')
|
||||
return Context(meta_info=meta, media_info=MediaInfo(douban_info=doubaninfo))
|
||||
logger.info(f'识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year} {meta.season}')
|
||||
mediainfo.set_douban_info(doubaninfo)
|
||||
return Context(meta_info=meta, media_info=mediainfo)
|
||||
|
||||
def movie_top250(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
def movie_top250(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
"""
|
||||
获取豆瓣电影TOP250
|
||||
:param page: 页码
|
||||
@@ -53,19 +19,19 @@ class DoubanChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("movie_top250", page=page, count=count)
|
||||
|
||||
def movie_showing(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
def movie_showing(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
"""
|
||||
获取正在上映的电影
|
||||
"""
|
||||
return self.run_module("movie_showing", page=page, count=count)
|
||||
|
||||
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
"""
|
||||
获取本周中国剧集榜
|
||||
"""
|
||||
return self.run_module("tv_weekly_chinese", page=page, count=count)
|
||||
|
||||
def tv_weekly_global(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
def tv_weekly_global(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
"""
|
||||
获取本周全球剧集榜
|
||||
"""
|
||||
@@ -84,3 +50,55 @@ class DoubanChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags,
|
||||
page=page, count=count)
|
||||
|
||||
def tv_animation(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
"""
|
||||
获取动画剧集
|
||||
"""
|
||||
return self.run_module("tv_animation", page=page, count=count)
|
||||
|
||||
def movie_hot(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
"""
|
||||
获取热门电影
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "douban":
|
||||
return None
|
||||
return self.run_module("movie_hot", page=page, count=count)
|
||||
|
||||
def tv_hot(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
"""
|
||||
获取热门剧集
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "douban":
|
||||
return None
|
||||
return self.run_module("tv_hot", page=page, count=count)
|
||||
|
||||
def movie_credits(self, doubanid: str, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询电影演职人员
|
||||
:param doubanid: 豆瓣ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("douban_movie_credits", doubanid=doubanid, page=page)
|
||||
|
||||
def tv_credits(self, doubanid: str, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职人员
|
||||
:param doubanid: 豆瓣ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("douban_tv_credits", doubanid=doubanid, page=page)
|
||||
|
||||
def movie_recommend(self, doubanid: str) -> List[dict]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.run_module("douban_movie_recommend", doubanid=doubanid)
|
||||
|
||||
def tv_recommend(self, doubanid: str) -> List[dict]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电视剧
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.run_module("douban_tv_recommend", doubanid=doubanid)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import base64
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Set, Dict, Union
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo, TorrentInfo, Context
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
@@ -15,6 +18,7 @@ from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification
|
||||
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@@ -23,11 +27,11 @@ class DownloadChain(ChainBase):
|
||||
下载处理链
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.torrent = TorrentHelper()
|
||||
self.downloadhis = DownloadHistoryOper(self._db)
|
||||
self.mediaserver = MediaServerOper(self._db)
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.mediaserver = MediaServerOper()
|
||||
|
||||
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
|
||||
channel: MessageChannel = None,
|
||||
@@ -36,8 +40,10 @@ class DownloadChain(ChainBase):
|
||||
发送添加下载的消息
|
||||
"""
|
||||
msg_text = ""
|
||||
if userid:
|
||||
msg_text = f"用户:{userid}"
|
||||
if torrent.site_name:
|
||||
msg_text = f"站点:{torrent.site_name}"
|
||||
msg_text = f"{msg_text}\n站点:{torrent.site_name}"
|
||||
if meta.resource_term:
|
||||
msg_text = f"{msg_text}\n质量:{meta.resource_term}"
|
||||
if torrent.size:
|
||||
@@ -50,6 +56,8 @@ class DownloadChain(ChainBase):
|
||||
msg_text = f"{msg_text}\n种子:{torrent.title}"
|
||||
if torrent.pubdate:
|
||||
msg_text = f"{msg_text}\n发布时间:{torrent.pubdate}"
|
||||
if torrent.freedate:
|
||||
msg_text = f"{msg_text}\n免费时间:{StringUtils.diff_time_str(torrent.freedate)}"
|
||||
if torrent.seeders:
|
||||
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
|
||||
if torrent.uploadvolumefactor and torrent.downloadvolumefactor:
|
||||
@@ -68,8 +76,7 @@ class DownloadChain(ChainBase):
|
||||
title=f"{mediainfo.title_year} "
|
||||
f"{meta.season_episode} 开始下载",
|
||||
text=msg_text,
|
||||
image=mediainfo.get_message_image(),
|
||||
userid=userid))
|
||||
image=mediainfo.get_message_image()))
|
||||
|
||||
def download_torrent(self, torrent: TorrentInfo,
|
||||
channel: MessageChannel = None,
|
||||
@@ -79,8 +86,68 @@ class DownloadChain(ChainBase):
|
||||
下载种子文件,如果是磁力链,会返回磁力链接本身
|
||||
:return: 种子路径,种子目录名,种子文件清单
|
||||
"""
|
||||
|
||||
def __get_redict_url(url: str, ua: str = None, cookie: str = None) -> Optional[str]:
|
||||
"""
|
||||
获取下载链接, url格式:[base64]url
|
||||
"""
|
||||
# 获取[]中的内容
|
||||
m = re.search(r"\[(.*)](.*)", url)
|
||||
if m:
|
||||
# 参数
|
||||
base64_str = m.group(1)
|
||||
# URL
|
||||
url = m.group(2)
|
||||
if not base64_str:
|
||||
return url
|
||||
# 解码参数
|
||||
req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8')
|
||||
req_params: Dict[str, dict] = json.loads(req_str)
|
||||
if req_params.get('method') == 'get':
|
||||
# GET请求
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie
|
||||
).get_res(url, params=req_params.get('params'))
|
||||
else:
|
||||
# POST请求
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie
|
||||
).post_res(url, params=req_params.get('params'))
|
||||
if not res:
|
||||
return None
|
||||
if not req_params.get('result'):
|
||||
return res.text
|
||||
else:
|
||||
data = res.json()
|
||||
for key in str(req_params.get('result')).split("."):
|
||||
data = data.get(key)
|
||||
if not data:
|
||||
return None
|
||||
logger.info(f"获取到下载地址:{data}")
|
||||
return data
|
||||
return None
|
||||
|
||||
# 获取下载链接
|
||||
if not torrent.enclosure:
|
||||
return None, "", []
|
||||
if torrent.enclosure.startswith("magnet:"):
|
||||
return torrent.enclosure, "", []
|
||||
|
||||
if torrent.enclosure.startswith("["):
|
||||
# 需要解码获取下载地址
|
||||
torrent_url = __get_redict_url(url=torrent.enclosure,
|
||||
ua=torrent.site_ua,
|
||||
cookie=torrent.site_cookie)
|
||||
else:
|
||||
torrent_url = torrent.enclosure
|
||||
if not torrent_url:
|
||||
logger.error(f"{torrent.title} 无法获取下载地址:{torrent.enclosure}!")
|
||||
return None, "", []
|
||||
# 下载种子文件
|
||||
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
|
||||
url=torrent.enclosure,
|
||||
url=torrent_url,
|
||||
cookie=torrent.site_cookie,
|
||||
ua=torrent.site_ua,
|
||||
proxy=torrent.site_proxy)
|
||||
@@ -90,7 +157,7 @@ class DownloadChain(ChainBase):
|
||||
return content, "", []
|
||||
|
||||
if not torrent_file:
|
||||
logger.error(f"下载种子文件失败:{torrent.title} - {torrent.enclosure}")
|
||||
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
mtype=NotificationType.Manual,
|
||||
@@ -106,7 +173,8 @@ class DownloadChain(ChainBase):
|
||||
episodes: Set[int] = None,
|
||||
channel: MessageChannel = None,
|
||||
save_path: str = None,
|
||||
userid: Union[str, int] = None) -> Optional[str]:
|
||||
userid: Union[str, int] = None,
|
||||
username: str = None) -> Optional[str]:
|
||||
"""
|
||||
下载及发送通知
|
||||
:param context: 资源上下文
|
||||
@@ -115,6 +183,7 @@ class DownloadChain(ChainBase):
|
||||
:param channel: 通知渠道
|
||||
:param save_path: 保存路径
|
||||
:param userid: 用户ID
|
||||
:param username: 调用下载的用户名/插件名
|
||||
"""
|
||||
_torrent = context.torrent_info
|
||||
_media = context.media_info
|
||||
@@ -122,9 +191,11 @@ class DownloadChain(ChainBase):
|
||||
_folder_name = ""
|
||||
if not torrent_file:
|
||||
# 下载种子文件,得到的可能是文件也可能是磁力链
|
||||
content, _folder_name, _file_list = self.download_torrent(_torrent, userid=userid)
|
||||
content, _folder_name, _file_list = self.download_torrent(_torrent,
|
||||
channel=channel,
|
||||
userid=userid)
|
||||
if not content:
|
||||
return
|
||||
return None
|
||||
else:
|
||||
content = torrent_file
|
||||
# 获取种子文件的文件夹名和文件清单
|
||||
@@ -136,33 +207,31 @@ class DownloadChain(ChainBase):
|
||||
# 开启下载二级目录
|
||||
if _media.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
download_dir = Path(settings.DOWNLOAD_MOVIE_PATH or settings.DOWNLOAD_PATH) / _media.category
|
||||
download_dir = settings.SAVE_MOVIE_PATH / _media.category
|
||||
else:
|
||||
if settings.DOWNLOAD_ANIME_PATH \
|
||||
and _media.genre_ids \
|
||||
if _media.genre_ids \
|
||||
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
# 动漫
|
||||
download_dir = Path(settings.DOWNLOAD_ANIME_PATH)
|
||||
download_dir = settings.SAVE_ANIME_PATH / _media.category
|
||||
else:
|
||||
# 电视剧
|
||||
download_dir = Path(settings.DOWNLOAD_TV_PATH or settings.DOWNLOAD_PATH) / _media.category
|
||||
download_dir = settings.SAVE_TV_PATH / _media.category
|
||||
elif _media:
|
||||
# 未开启下载二级目录
|
||||
if _media.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
download_dir = Path(settings.DOWNLOAD_MOVIE_PATH or settings.DOWNLOAD_PATH)
|
||||
download_dir = settings.SAVE_MOVIE_PATH
|
||||
else:
|
||||
if settings.DOWNLOAD_ANIME_PATH \
|
||||
and _media.genre_ids \
|
||||
if _media.genre_ids \
|
||||
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
# 动漫
|
||||
download_dir = Path(settings.DOWNLOAD_ANIME_PATH)
|
||||
download_dir = settings.SAVE_ANIME_PATH
|
||||
else:
|
||||
# 电视剧
|
||||
download_dir = Path(settings.DOWNLOAD_TV_PATH or settings.DOWNLOAD_PATH)
|
||||
download_dir = settings.SAVE_TV_PATH
|
||||
else:
|
||||
# 未识别
|
||||
download_dir = Path(settings.DOWNLOAD_PATH)
|
||||
download_dir = settings.SAVE_PATH
|
||||
else:
|
||||
# 自定义下载目录
|
||||
download_dir = Path(save_path)
|
||||
@@ -201,7 +270,11 @@ class DownloadChain(ChainBase):
|
||||
download_hash=_hash,
|
||||
torrent_name=_torrent.title,
|
||||
torrent_description=_torrent.description,
|
||||
torrent_site=_torrent.site_name
|
||||
torrent_site=_torrent.site_name,
|
||||
userid=userid,
|
||||
username=username,
|
||||
channel=channel.value if channel else None,
|
||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
)
|
||||
|
||||
# 登记下载文件
|
||||
@@ -213,9 +286,13 @@ class DownloadChain(ChainBase):
|
||||
if not file_meta.begin_episode \
|
||||
or file_meta.begin_episode not in episodes:
|
||||
continue
|
||||
# 只处理视频格式
|
||||
if not Path(file).suffix \
|
||||
or Path(file).suffix not in settings.RMT_MEDIAEXT:
|
||||
continue
|
||||
files_to_add.append({
|
||||
"download_hash": _hash,
|
||||
"downloader": settings.DOWNLOADER,
|
||||
"downloader": settings.DEFAULT_DOWNLOADER,
|
||||
"fullpath": str(download_dir / _folder_name / file),
|
||||
"savepath": str(download_dir / _folder_name),
|
||||
"filepath": file,
|
||||
@@ -224,8 +301,8 @@ class DownloadChain(ChainBase):
|
||||
if files_to_add:
|
||||
self.downloadhis.add_files(files_to_add)
|
||||
|
||||
# 发送消息
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, channel=channel)
|
||||
# 发送消息(群发,不带channel和userid)
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent)
|
||||
# 下载成功后处理
|
||||
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
||||
# 广播事件
|
||||
@@ -251,41 +328,49 @@ class DownloadChain(ChainBase):
|
||||
|
||||
def batch_download(self,
|
||||
contexts: List[Context],
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]] = None,
|
||||
save_path: str = None,
|
||||
userid: str = None) -> Tuple[List[Context], Dict[int, Dict[int, NotExistMediaInfo]]]:
|
||||
channel: MessageChannel = None,
|
||||
userid: str = None,
|
||||
username: str = None
|
||||
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
根据缺失数据,自动种子列表中组合择优下载
|
||||
:param contexts: 资源上下文列表
|
||||
:param no_exists: 缺失的剧集信息
|
||||
:param save_path: 保存路径
|
||||
:param channel: 通知渠道
|
||||
:param userid: 用户ID
|
||||
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id] = {season: NotExistMediaInfo}
|
||||
:param username: 调用下载的用户名/插件名
|
||||
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}
|
||||
"""
|
||||
# 已下载的项目
|
||||
downloaded_list: List[Context] = []
|
||||
|
||||
def __update_seasons(_tmdbid: int, _need: list, _current: list) -> list:
|
||||
def __update_seasons(_mid: Union[int, str], _need: list, _current: list) -> list:
|
||||
"""
|
||||
更新need_tvs季数,返回剩余季数
|
||||
:param _tmdbid: TMDBID
|
||||
:param _mid: TMDBID
|
||||
:param _need: 需要下载的季数
|
||||
:param _current: 已经下载的季数
|
||||
"""
|
||||
# 剩余季数
|
||||
need = list(set(_need).difference(set(_current)))
|
||||
# 清除已下载的季信息
|
||||
for _sea in list(no_exists.get(_tmdbid)):
|
||||
if _sea not in need:
|
||||
no_exists[_tmdbid].pop(_sea)
|
||||
if not no_exists.get(_tmdbid) and no_exists.get(_tmdbid) is not None:
|
||||
no_exists.pop(_tmdbid)
|
||||
seas = copy.deepcopy(no_exists.get(_mid))
|
||||
if seas:
|
||||
for _sea in list(seas):
|
||||
if _sea not in need:
|
||||
no_exists[_mid].pop(_sea)
|
||||
if not no_exists.get(_mid) and no_exists.get(_mid) is not None:
|
||||
no_exists.pop(_mid)
|
||||
break
|
||||
return need
|
||||
|
||||
def __update_episodes(_tmdbid: int, _sea: int, _need: list, _current: set) -> list:
|
||||
def __update_episodes(_mid: Union[int, str], _sea: int, _need: list, _current: set) -> list:
|
||||
"""
|
||||
更新need_tvs集数,返回剩余集数
|
||||
:param _tmdbid: TMDBID
|
||||
:param _mid: TMDBID
|
||||
:param _sea: 季数
|
||||
:param _need: 需要下载的集数
|
||||
:param _current: 已经下载的集数
|
||||
@@ -293,26 +378,26 @@ class DownloadChain(ChainBase):
|
||||
# 剩余集数
|
||||
need = list(set(_need).difference(set(_current)))
|
||||
if need:
|
||||
not_exist = no_exists[_tmdbid][_sea]
|
||||
no_exists[_tmdbid][_sea] = NotExistMediaInfo(
|
||||
not_exist = no_exists[_mid][_sea]
|
||||
no_exists[_mid][_sea] = NotExistMediaInfo(
|
||||
season=not_exist.season,
|
||||
episodes=need,
|
||||
total_episode=not_exist.total_episode,
|
||||
start_episode=not_exist.start_episode
|
||||
)
|
||||
else:
|
||||
no_exists[_tmdbid].pop(_sea)
|
||||
if not no_exists.get(_tmdbid) and no_exists.get(_tmdbid) is not None:
|
||||
no_exists.pop(_tmdbid)
|
||||
no_exists[_mid].pop(_sea)
|
||||
if not no_exists.get(_mid) and no_exists.get(_mid) is not None:
|
||||
no_exists.pop(_mid)
|
||||
return need
|
||||
|
||||
def __get_season_episodes(tmdbid: int, season: int) -> int:
|
||||
def __get_season_episodes(_mid: Union[int, str], season: int) -> int:
|
||||
"""
|
||||
获取需要的季的集数
|
||||
"""
|
||||
if not no_exists.get(tmdbid):
|
||||
if not no_exists.get(_mid):
|
||||
return 9999
|
||||
no_exist = no_exists.get(tmdbid)
|
||||
no_exist = no_exists.get(_mid)
|
||||
if not no_exist.get(season):
|
||||
return 9999
|
||||
return no_exist[season].total_episode
|
||||
@@ -323,7 +408,8 @@ class DownloadChain(ChainBase):
|
||||
# 如果是电影,直接下载
|
||||
for context in contexts:
|
||||
if context.media_info.type == MediaType.MOVIE:
|
||||
if self.download_single(context, save_path=save_path, userid=userid):
|
||||
if self.download_single(context, save_path=save_path, channel=channel,
|
||||
userid=userid, username=username):
|
||||
# 下载成功
|
||||
downloaded_list.append(context)
|
||||
|
||||
@@ -331,17 +417,17 @@ class DownloadChain(ChainBase):
|
||||
if no_exists:
|
||||
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
|
||||
need_seasons: Dict[int, list] = {}
|
||||
for need_tmdbid, need_tv in no_exists.items():
|
||||
for need_mid, need_tv in no_exists.items():
|
||||
for tv in need_tv.values():
|
||||
if not tv:
|
||||
continue
|
||||
# 季列表为空的,代表全季缺失
|
||||
if not tv.episodes:
|
||||
if not need_seasons.get(need_tmdbid):
|
||||
need_seasons[need_tmdbid] = []
|
||||
need_seasons[need_tmdbid].append(tv.season or 1)
|
||||
if not need_seasons.get(need_mid):
|
||||
need_seasons[need_mid] = []
|
||||
need_seasons[need_mid].append(tv.season or 1)
|
||||
# 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子
|
||||
for need_tmdbid, need_season in need_seasons.items():
|
||||
for need_mid, need_season in need_seasons.items():
|
||||
# 循环种子
|
||||
for context in contexts:
|
||||
# 媒体信息
|
||||
@@ -359,7 +445,7 @@ class DownloadChain(ChainBase):
|
||||
if meta.episode_list:
|
||||
continue
|
||||
# 匹配TMDBID
|
||||
if need_tmdbid == media.tmdb_id:
|
||||
if need_mid == media.tmdb_id or need_mid == media.douban_id:
|
||||
# 种子季是需要季或者子集
|
||||
if set(torrent_season).issubset(set(need_season)):
|
||||
if len(torrent_season) == 1:
|
||||
@@ -374,13 +460,13 @@ class DownloadChain(ChainBase):
|
||||
logger.info(f"{meta.org_string} 解析文件集数为 {torrent_episodes}")
|
||||
if not torrent_episodes:
|
||||
continue
|
||||
# 总集数
|
||||
need_total = __get_season_episodes(need_tmdbid, torrent_season[0])
|
||||
# 更新集数范围
|
||||
begin_ep = min(torrent_episodes)
|
||||
end_ep = max(torrent_episodes)
|
||||
meta.set_episodes(begin=begin_ep, end=end_ep)
|
||||
# 需要总集数
|
||||
need_total = __get_season_episodes(need_mid, torrent_season[0])
|
||||
if len(torrent_episodes) < need_total:
|
||||
# 更新集数范围
|
||||
begin_ep = min(torrent_episodes)
|
||||
end_ep = max(torrent_episodes)
|
||||
meta.set_episodes(begin=begin_ep, end=end_ep)
|
||||
logger.info(
|
||||
f"{meta.org_string} 解析文件集数发现不是完整合集")
|
||||
continue
|
||||
@@ -390,30 +476,38 @@ class DownloadChain(ChainBase):
|
||||
context=context,
|
||||
torrent_file=content if isinstance(content, Path) else None,
|
||||
save_path=save_path,
|
||||
userid=userid
|
||||
channel=channel,
|
||||
userid=userid,
|
||||
username=username
|
||||
)
|
||||
else:
|
||||
# 下载
|
||||
download_id = self.download_single(context, save_path=save_path, userid=userid)
|
||||
download_id = self.download_single(context,
|
||||
save_path=save_path, channel=channel,
|
||||
userid=userid, username=username)
|
||||
|
||||
if download_id:
|
||||
# 下载成功
|
||||
downloaded_list.append(context)
|
||||
# 更新仍需季集
|
||||
need_season = __update_seasons(_tmdbid=need_tmdbid,
|
||||
need_season = __update_seasons(_mid=need_mid,
|
||||
_need=need_season,
|
||||
_current=torrent_season)
|
||||
if not need_season:
|
||||
# 全部下载完成
|
||||
break
|
||||
# 电视剧季内的集匹配
|
||||
if no_exists:
|
||||
# TMDBID列表
|
||||
need_tv_list = list(no_exists)
|
||||
for need_tmdbid in need_tv_list:
|
||||
for need_mid in need_tv_list:
|
||||
# dict[season, [NotExistMediaInfo]]
|
||||
need_tv = no_exists.get(need_tmdbid)
|
||||
need_tv = no_exists.get(need_mid)
|
||||
if not need_tv:
|
||||
continue
|
||||
need_tv_copy = copy.deepcopy(no_exists.get(need_mid))
|
||||
# 循环每一季
|
||||
for sea, tv in need_tv.items():
|
||||
for sea, tv in need_tv_copy.items():
|
||||
# 当前需要季
|
||||
need_season = sea
|
||||
# 当前需要集
|
||||
@@ -424,7 +518,7 @@ class DownloadChain(ChainBase):
|
||||
start_episode = tv.start_episode or 1
|
||||
# 缺失整季的转化为缺失集进行比较
|
||||
if not need_episodes:
|
||||
need_episodes = list(range(start_episode, total_episode))
|
||||
need_episodes = list(range(start_episode, total_episode + 1))
|
||||
# 循环种子
|
||||
for context in contexts:
|
||||
# 媒体信息
|
||||
@@ -435,7 +529,7 @@ class DownloadChain(ChainBase):
|
||||
if media.type != MediaType.TV:
|
||||
continue
|
||||
# 匹配TMDB
|
||||
if media.tmdb_id == need_tmdbid:
|
||||
if media.tmdb_id == need_mid or media.douban_id == need_mid:
|
||||
# 不重复添加
|
||||
if context in downloaded_list:
|
||||
continue
|
||||
@@ -452,12 +546,14 @@ class DownloadChain(ChainBase):
|
||||
# 为需要集的子集则下载
|
||||
if torrent_episodes.issubset(set(need_episodes)):
|
||||
# 下载
|
||||
download_id = self.download_single(context, save_path=save_path, userid=userid)
|
||||
download_id = self.download_single(context,
|
||||
save_path=save_path, channel=channel,
|
||||
userid=userid, username=username)
|
||||
if download_id:
|
||||
# 下载成功
|
||||
downloaded_list.append(context)
|
||||
# 更新仍需集数
|
||||
need_episodes = __update_episodes(_tmdbid=need_tmdbid,
|
||||
need_episodes = __update_episodes(_mid=need_mid,
|
||||
_need=need_episodes,
|
||||
_sea=need_season,
|
||||
_current=torrent_episodes)
|
||||
@@ -466,9 +562,9 @@ class DownloadChain(ChainBase):
|
||||
if no_exists:
|
||||
# TMDBID列表
|
||||
no_exists_list = list(no_exists)
|
||||
for need_tmdbid in no_exists_list:
|
||||
for need_mid in no_exists_list:
|
||||
# dict[season, [NotExistMediaInfo]]
|
||||
need_tv = no_exists.get(need_tmdbid)
|
||||
need_tv = no_exists.get(need_mid)
|
||||
if not need_tv:
|
||||
continue
|
||||
# 需要季列表
|
||||
@@ -502,13 +598,13 @@ class DownloadChain(ChainBase):
|
||||
if not need_episodes:
|
||||
break
|
||||
# 选中一个单季整季的或单季包括需要的所有集的
|
||||
if media.tmdb_id == need_tmdbid \
|
||||
if (media.tmdb_id == need_mid or media.douban_id == need_mid) \
|
||||
and (not meta.episode_list
|
||||
or set(meta.episode_list).intersection(set(need_episodes))) \
|
||||
and len(meta.season_list) == 1 \
|
||||
and meta.season_list[0] == need_season:
|
||||
# 检查种子看是否有需要的集
|
||||
content, _, torrent_files = self.download_torrent(torrent, userid=userid)
|
||||
content, _, torrent_files = self.download_torrent(torrent)
|
||||
if not content:
|
||||
continue
|
||||
if isinstance(content, str):
|
||||
@@ -529,17 +625,20 @@ class DownloadChain(ChainBase):
|
||||
torrent_file=content if isinstance(content, Path) else None,
|
||||
episodes=selected_episodes,
|
||||
save_path=save_path,
|
||||
userid=userid
|
||||
channel=channel,
|
||||
userid=userid,
|
||||
username=username
|
||||
)
|
||||
if not download_id:
|
||||
continue
|
||||
# 把识别的集更新到上下文
|
||||
context.meta_info.begin_episode = min(selected_episodes)
|
||||
context.meta_info.end_episode = max(selected_episodes)
|
||||
# 下载成功
|
||||
downloaded_list.append(context)
|
||||
# 更新种子集数范围
|
||||
begin_ep = min(torrent_episodes)
|
||||
end_ep = max(torrent_episodes)
|
||||
meta.set_episodes(begin=begin_ep, end=end_ep)
|
||||
# 更新仍需集数
|
||||
need_episodes = __update_episodes(_tmdbid=need_tmdbid,
|
||||
need_episodes = __update_episodes(_mid=need_mid,
|
||||
_need=need_episodes,
|
||||
_sea=need_season,
|
||||
_current=selected_episodes)
|
||||
@@ -551,7 +650,7 @@ class DownloadChain(ChainBase):
|
||||
mediainfo: MediaInfo,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
totals: Dict[int, int] = None
|
||||
) -> Tuple[bool, Dict[int, Dict[int, NotExistMediaInfo]]]:
|
||||
) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
检查媒体库,查询是否存在,对于剧集同时返回不存在的季集信息
|
||||
:param meta: 元数据
|
||||
@@ -571,8 +670,9 @@ class DownloadChain(ChainBase):
|
||||
"start_episode": int
|
||||
]}
|
||||
"""
|
||||
if not no_exists.get(mediainfo.tmdb_id):
|
||||
no_exists[mediainfo.tmdb_id] = {
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
if not no_exists.get(mediakey):
|
||||
no_exists[mediakey] = {
|
||||
_season: NotExistMediaInfo(
|
||||
season=_season,
|
||||
episodes=_episodes,
|
||||
@@ -581,7 +681,7 @@ class DownloadChain(ChainBase):
|
||||
)
|
||||
}
|
||||
else:
|
||||
no_exists[mediainfo.tmdb_id][_season] = NotExistMediaInfo(
|
||||
no_exists[mediakey][_season] = NotExistMediaInfo(
|
||||
season=_season,
|
||||
episodes=_episodes,
|
||||
total_episode=_total,
|
||||
@@ -597,6 +697,7 @@ class DownloadChain(ChainBase):
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
title=mediainfo.title,
|
||||
tmdbid=mediainfo.tmdb_id)
|
||||
exists_movies: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid)
|
||||
if exists_movies:
|
||||
@@ -607,7 +708,8 @@ class DownloadChain(ChainBase):
|
||||
if not mediainfo.seasons:
|
||||
# 补充媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id)
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
if not mediainfo:
|
||||
logger.error(f"媒体信息识别失败!")
|
||||
return False, {}
|
||||
@@ -616,6 +718,7 @@ class DownloadChain(ChainBase):
|
||||
return False, {}
|
||||
# 电视剧
|
||||
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
title=mediainfo.title,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=mediainfo.season)
|
||||
# 媒体库已存在的剧集
|
||||
@@ -626,7 +729,7 @@ class DownloadChain(ChainBase):
|
||||
if not episodes:
|
||||
continue
|
||||
# 全季不存在
|
||||
if meta.season_list \
|
||||
if meta.sea \
|
||||
and season not in meta.season_list:
|
||||
continue
|
||||
# 总集数
|
||||
@@ -637,7 +740,7 @@ class DownloadChain(ChainBase):
|
||||
else:
|
||||
# 存在一些,检查每季缺失的季集
|
||||
for season, episodes in mediainfo.seasons.items():
|
||||
if meta.begin_season \
|
||||
if meta.sea \
|
||||
and season not in meta.season_list:
|
||||
continue
|
||||
if not episodes:
|
||||
@@ -709,6 +812,7 @@ class DownloadChain(ChainBase):
|
||||
for torrent in torrents:
|
||||
history = self.downloadhis.get_by_hash(torrent.hash)
|
||||
if history:
|
||||
# 媒体信息
|
||||
torrent.media = {
|
||||
"tmdbid": history.tmdbid,
|
||||
"type": history.type,
|
||||
@@ -717,6 +821,9 @@ class DownloadChain(ChainBase):
|
||||
"episode": history.episodes,
|
||||
"image": history.image,
|
||||
}
|
||||
# 下载用户
|
||||
torrent.userid = history.userid
|
||||
torrent.username = history.username
|
||||
ret_torrents.append(torrent)
|
||||
return ret_torrents
|
||||
|
||||
@@ -735,3 +842,16 @@ class DownloadChain(ChainBase):
|
||||
删除下载任务
|
||||
"""
|
||||
return self.remove_torrents(hashs=[hash_str])
|
||||
|
||||
@eventmanager.register(EventType.DownloadFileDeleted)
|
||||
def download_file_deleted(self, event: Event):
|
||||
"""
|
||||
下载文件删除时,同步删除下载任务
|
||||
"""
|
||||
if not event:
|
||||
return
|
||||
hash_str = event.event_data.get("hash")
|
||||
if not hash_str:
|
||||
return
|
||||
logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}")
|
||||
self.remove_torrents(hashs=[hash_str], delete_file=False)
|
||||
|
||||
@@ -1,36 +1,136 @@
|
||||
import copy
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import Context, MediaInfo
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.log import logger
|
||||
from app.schemas.types import EventType, MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
recognize_lock = Lock()
|
||||
|
||||
class MediaChain(ChainBase):
|
||||
"""
|
||||
媒体信息处理链
|
||||
"""
|
||||
|
||||
def recognize_by_title(self, title: str, subtitle: str = None) -> Optional[Context]:
|
||||
class MediaChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
媒体信息处理链,单例运行
|
||||
"""
|
||||
# 临时识别标题
|
||||
recognize_title: Optional[str] = None
|
||||
# 临时识别结果 {title, name, year, season, episode}
|
||||
recognize_temp: Optional[dict] = None
|
||||
|
||||
def recognize_by_meta(self, metainfo: MetaBase) -> Optional[MediaInfo]:
|
||||
"""
|
||||
根据主副标题识别媒体信息
|
||||
"""
|
||||
logger.info(f'开始识别媒体信息,标题:{title},副标题:{subtitle} ...')
|
||||
# 识别元数据
|
||||
metainfo = MetaInfo(title, subtitle)
|
||||
title = metainfo.title
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=metainfo)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{title} 未识别到媒体信息')
|
||||
return Context(meta_info=metainfo)
|
||||
# 偿试使用辅助识别,如果有注册响应事件的话
|
||||
if eventmanager.check(EventType.NameRecognize):
|
||||
logger.info(f'请求辅助识别,标题:{title} ...')
|
||||
mediainfo = self.recognize_help(title=title, org_meta=metainfo)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{title} 未识别到媒体信息')
|
||||
return None
|
||||
# 识别成功
|
||||
logger.info(f'{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}')
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
# 返回上下文
|
||||
return Context(meta_info=metainfo, media_info=mediainfo)
|
||||
return mediainfo
|
||||
|
||||
def recognize_help(self, title: str, org_meta: MetaBase) -> Optional[MediaInfo]:
|
||||
"""
|
||||
请求辅助识别,返回媒体信息
|
||||
:param title: 标题
|
||||
:param org_meta: 原始元数据
|
||||
"""
|
||||
with recognize_lock:
|
||||
self.recognize_temp = None
|
||||
self.recognize_title = title
|
||||
|
||||
# 发送请求事件
|
||||
eventmanager.send_event(
|
||||
EventType.NameRecognize,
|
||||
{
|
||||
'title': title,
|
||||
}
|
||||
)
|
||||
# 每0.5秒循环一次,等待结果,直到10秒后超时
|
||||
for i in range(20):
|
||||
if self.recognize_temp is not None:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
# 加锁
|
||||
with recognize_lock:
|
||||
mediainfo = None
|
||||
if not self.recognize_temp or self.recognize_title != title:
|
||||
# 没有识别结果或者识别标题已改变
|
||||
return None
|
||||
# 有识别结果
|
||||
meta_dict = copy.deepcopy(self.recognize_temp)
|
||||
logger.info(f'获取到辅助识别结果:{meta_dict}')
|
||||
if meta_dict.get("name") == org_meta.name and meta_dict.get("year") == org_meta.year:
|
||||
logger.info(f'辅助识别结果与原始识别结果一致')
|
||||
else:
|
||||
logger.info(f'辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...')
|
||||
org_meta.name = meta_dict.get("name")
|
||||
org_meta.year = meta_dict.get("year")
|
||||
org_meta.begin_season = meta_dict.get("season")
|
||||
org_meta.begin_episode = meta_dict.get("episode")
|
||||
if org_meta.begin_season or org_meta.begin_episode:
|
||||
org_meta.type = MediaType.TV
|
||||
# 重新识别
|
||||
mediainfo = self.recognize_media(meta=org_meta)
|
||||
return mediainfo
|
||||
|
||||
@eventmanager.register(EventType.NameRecognizeResult)
|
||||
def recognize_result(self, event: Event):
|
||||
"""
|
||||
监控识别结果事件,获取辅助识别结果,结果格式:{title, name, year, season, episode}
|
||||
"""
|
||||
if not event:
|
||||
return
|
||||
event_data = event.event_data or {}
|
||||
# 加锁
|
||||
with recognize_lock:
|
||||
# 不是原标题的结果不要
|
||||
if event_data.get("title") != self.recognize_title:
|
||||
return
|
||||
# 标志收到返回
|
||||
self.recognize_temp = {}
|
||||
# 处理数据格式
|
||||
file_title, file_year, season_number, episode_number = None, None, None, None
|
||||
if event_data.get("name"):
|
||||
file_title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
|
||||
if event_data.get("year"):
|
||||
file_year = str(event_data["year"]).split("/")[0].strip()
|
||||
if event_data.get("season") and str(event_data["season"]).isdigit():
|
||||
season_number = int(event_data["season"])
|
||||
if event_data.get("episode") and str(event_data["episode"]).isdigit():
|
||||
episode_number = int(event_data["episode"])
|
||||
if not file_title:
|
||||
return
|
||||
if file_title == 'Unknown':
|
||||
return
|
||||
if not str(file_year).isdigit():
|
||||
file_year = None
|
||||
# 结果赋值
|
||||
self.recognize_temp = {
|
||||
"name": file_title,
|
||||
"year": file_year,
|
||||
"season": season_number,
|
||||
"episode": episode_number
|
||||
}
|
||||
|
||||
def recognize_by_path(self, path: str) -> Optional[Context]:
|
||||
"""
|
||||
@@ -38,17 +138,18 @@ class MediaChain(ChainBase):
|
||||
"""
|
||||
logger.info(f'开始识别媒体信息,文件:{path} ...')
|
||||
file_path = Path(path)
|
||||
# 上级目录元数据
|
||||
dir_meta = MetaInfo(title=file_path.parent.name)
|
||||
# 文件元数据,不包含后缀
|
||||
file_meta = MetaInfo(title=file_path.stem)
|
||||
# 合并元数据
|
||||
file_meta.merge(dir_meta)
|
||||
# 元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
# 识别媒体信息
|
||||
mediainfo = self.recognize_media(meta=file_meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{path} 未识别到媒体信息')
|
||||
return Context(meta_info=file_meta)
|
||||
# 偿试使用辅助识别,如果有注册响应事件的话
|
||||
if eventmanager.check(EventType.NameRecognize):
|
||||
logger.info(f'请求辅助识别,标题:{file_path.name} ...')
|
||||
mediainfo = self.recognize_help(title=path, org_meta=file_meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{path} 未识别到媒体信息')
|
||||
return Context(meta_info=file_meta)
|
||||
logger.info(f'{path} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}')
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
@@ -66,8 +167,7 @@ class MediaChain(ChainBase):
|
||||
# 识别
|
||||
meta = MetaInfo(content)
|
||||
if not meta.name:
|
||||
logger.warn(f'{title} 未识别到元数据!')
|
||||
return meta, []
|
||||
meta.cn_name = content
|
||||
# 合并信息
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
@@ -86,3 +186,78 @@ class MediaChain(ChainBase):
|
||||
logger.info(f"{content} 搜索到 {len(medias)} 条相关媒体信息")
|
||||
# 识别的元数据,媒体信息列表
|
||||
return meta, medias
|
||||
|
||||
def get_tmdbinfo_by_doubanid(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
|
||||
"""
|
||||
根据豆瓣ID获取TMDB信息
|
||||
"""
|
||||
tmdbinfo = None
|
||||
doubaninfo = self.douban_info(doubanid=doubanid, mtype=mtype)
|
||||
if doubaninfo:
|
||||
# 优先使用原标题匹配
|
||||
season_meta = None
|
||||
if doubaninfo.get("original_title"):
|
||||
meta = MetaInfo(title=doubaninfo.get("original_title"))
|
||||
season_meta = MetaInfo(title=doubaninfo.get("title"))
|
||||
# 合并季
|
||||
meta.begin_season = season_meta.begin_season
|
||||
else:
|
||||
meta = MetaInfo(title=doubaninfo.get("title"))
|
||||
# 年份
|
||||
if doubaninfo.get("year"):
|
||||
meta.year = doubaninfo.get("year")
|
||||
# 处理类型
|
||||
if isinstance(doubaninfo.get('media_type'), MediaType):
|
||||
meta.type = doubaninfo.get('media_type')
|
||||
else:
|
||||
meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV
|
||||
# 使用原标题识别TMDB媒体信息
|
||||
tmdbinfo = self.match_tmdbinfo(
|
||||
name=meta.name,
|
||||
year=meta.year,
|
||||
mtype=mtype or meta.type,
|
||||
season=meta.begin_season
|
||||
)
|
||||
if not tmdbinfo:
|
||||
if season_meta and season_meta.name != meta.name:
|
||||
# 使用主标题识别媒体信息
|
||||
tmdbinfo = self.match_tmdbinfo(
|
||||
name=season_meta.name,
|
||||
year=meta.year,
|
||||
mtype=mtype or meta.type,
|
||||
season=meta.begin_season
|
||||
)
|
||||
return tmdbinfo
|
||||
|
||||
def get_doubaninfo_by_tmdbid(self, tmdbid: int,
|
||||
mtype: MediaType = None, season: int = None) -> Optional[dict]:
|
||||
"""
|
||||
根据TMDBID获取豆瓣信息
|
||||
"""
|
||||
tmdbinfo = self.tmdb_info(tmdbid=tmdbid, mtype=mtype)
|
||||
if tmdbinfo:
|
||||
# 名称
|
||||
name = tmdbinfo.get("title") or tmdbinfo.get("name")
|
||||
# 年份
|
||||
year = None
|
||||
if tmdbinfo.get('release_date'):
|
||||
year = tmdbinfo['release_date'][:4]
|
||||
elif tmdbinfo.get('seasons') and season:
|
||||
for seainfo in tmdbinfo['seasons']:
|
||||
# 季
|
||||
season_number = seainfo.get("season_number")
|
||||
if not season_number:
|
||||
continue
|
||||
air_date = seainfo.get("air_date")
|
||||
if air_date and season_number == season:
|
||||
year = air_date[:4]
|
||||
break
|
||||
# IMDBID
|
||||
imdbid = tmdbinfo.get("external_ids", {}).get("imdb_id")
|
||||
return self.match_doubaninfo(
|
||||
name=name,
|
||||
year=year,
|
||||
mtype=mtype,
|
||||
imdbid=imdbid
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import json
|
||||
import threading
|
||||
from typing import List, Union, Generator
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Union, Optional
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.db import SessionFactory
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.log import logger
|
||||
from app.schemas import MessageChannel, Notification
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
@@ -20,77 +16,99 @@ class MediaServerChain(ChainBase):
|
||||
媒体服务器处理链
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.dboper = MediaServerOper()
|
||||
|
||||
def librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
def librarys(self, server: str = None, username: str = None) -> List[schemas.MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库
|
||||
"""
|
||||
return self.run_module("mediaserver_librarys")
|
||||
return self.run_module("mediaserver_librarys", server=server, username=username)
|
||||
|
||||
def items(self, library_id: Union[str, int]) -> Generator:
|
||||
def items(self, server: str, library_id: Union[str, int]) -> List[schemas.MediaServerItem]:
|
||||
"""
|
||||
获取媒体服务器所有项目
|
||||
"""
|
||||
return self.run_module("mediaserver_items", library_id=library_id)
|
||||
return self.run_module("mediaserver_items", server=server, library_id=library_id)
|
||||
|
||||
def episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
||||
def iteminfo(self, server: str, item_id: Union[str, int]) -> schemas.MediaServerItem:
|
||||
"""
|
||||
获取媒体服务器项目信息
|
||||
"""
|
||||
return self.run_module("mediaserver_iteminfo", server=server, item_id=item_id)
|
||||
|
||||
def episodes(self, server: str, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
||||
"""
|
||||
获取媒体服务器剧集信息
|
||||
"""
|
||||
return self.run_module("mediaserver_tv_episodes", item_id=item_id)
|
||||
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
|
||||
|
||||
def remote_sync(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
def playing(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
同步豆瓣想看数据,发送消息
|
||||
获取媒体服务器正在播放信息
|
||||
"""
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="开始媒体服务器 ...", userid=userid))
|
||||
self.sync()
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="同步媒体服务器完成!", userid=userid))
|
||||
return self.run_module("mediaserver_playing", count=count, server=server, username=username)
|
||||
|
||||
def latest(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
|
||||
|
||||
def get_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
|
||||
"""
|
||||
获取播放地址
|
||||
"""
|
||||
return self.run_module("mediaserver_play_url", server=server, item_id=item_id)
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
同步媒体库所有数据到本地数据库
|
||||
"""
|
||||
# 设置的媒体服务器
|
||||
if not settings.MEDIASERVER:
|
||||
return
|
||||
# 同步黑名单
|
||||
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
|
||||
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
|
||||
mediaservers = settings.MEDIASERVER.split(",")
|
||||
with lock:
|
||||
# 媒体服务器同步使用独立的会话
|
||||
_db = SessionFactory()
|
||||
_dbOper = MediaServerOper(_db)
|
||||
logger.info("开始同步媒体库数据 ...")
|
||||
# 汇总统计
|
||||
total_count = 0
|
||||
# 清空登记薄
|
||||
_dbOper.empty(server=settings.MEDIASERVER)
|
||||
for library in self.librarys():
|
||||
logger.info(f"正在同步媒体库 {library.name} ...")
|
||||
library_count = 0
|
||||
for item in self.items(library.id):
|
||||
if not item:
|
||||
self.dboper.empty()
|
||||
# 遍历媒体服务器
|
||||
for mediaserver in mediaservers:
|
||||
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")
|
||||
for library in self.librarys(mediaserver):
|
||||
# 同步黑名单 跳过
|
||||
if library.name in sync_blacklist:
|
||||
continue
|
||||
if not item.item_id:
|
||||
continue
|
||||
# 计数
|
||||
library_count += 1
|
||||
seasoninfo = {}
|
||||
# 类型
|
||||
item_type = "电视剧" if item.item_type in ['Series', 'show'] else "电影"
|
||||
if item_type == "电视剧":
|
||||
# 查询剧集信息
|
||||
espisodes_info = self.episodes(item.item_id) or []
|
||||
for episode in espisodes_info:
|
||||
seasoninfo[episode.season] = episode.episodes
|
||||
# 插入数据
|
||||
item_dict = item.dict()
|
||||
item_dict['seasoninfo'] = json.dumps(seasoninfo)
|
||||
item_dict['item_type'] = item_type
|
||||
_dbOper.add(**item_dict)
|
||||
logger.info(f"媒体库 {library.name} 同步完成,共同步数量:{library_count}")
|
||||
# 总数累加
|
||||
total_count += library_count
|
||||
# 关闭数据库连接
|
||||
if _db:
|
||||
_db.close()
|
||||
logger.info(f"正在同步 {mediaserver} 媒体库 {library.name} ...")
|
||||
library_count = 0
|
||||
for item in self.items(mediaserver, library.id):
|
||||
if not item:
|
||||
continue
|
||||
if not item.item_id:
|
||||
continue
|
||||
logger.debug(f"正在同步 {item.title} ...")
|
||||
# 计数
|
||||
library_count += 1
|
||||
seasoninfo = {}
|
||||
# 类型
|
||||
item_type = "电视剧" if item.item_type in ['Series', 'show'] else "电影"
|
||||
if item_type == "电视剧":
|
||||
# 查询剧集信息
|
||||
espisodes_info = self.episodes(mediaserver, item.item_id) or []
|
||||
for episode in espisodes_info:
|
||||
seasoninfo[episode.season] = episode.episodes
|
||||
# 插入数据
|
||||
item_dict = item.dict()
|
||||
item_dict['seasoninfo'] = json.dumps(seasoninfo)
|
||||
item_dict['item_type'] = item_type
|
||||
self.dboper.add(**item_dict)
|
||||
logger.info(f"{mediaserver} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
|
||||
# 总数累加
|
||||
total_count += library_count
|
||||
logger.info("【MediaServer】媒体库数据同步完成,同步数量:%s" % total_count)
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
from typing import Any
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Optional, Dict, Union
|
||||
|
||||
from app.chain.download import *
|
||||
from app.chain import ChainBase
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo, Context
|
||||
from app.core.event import EventManager
|
||||
from app.core.meta import MetaBase
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
from app.schemas import Notification, NotExistMediaInfo
|
||||
from app.schemas.types import EventType, MessageChannel, MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
# 当前页面
|
||||
_current_page: int = 0
|
||||
@@ -27,16 +35,69 @@ class MessageChain(ChainBase):
|
||||
# 每页数据量
|
||||
_page_size: int = 8
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
self.downloadchain = DownloadChain(self._db)
|
||||
self.subscribechain = SubscribeChain(self._db)
|
||||
self.searchchain = SearchChain(self._db)
|
||||
self.medtachain = MediaChain(self._db)
|
||||
self.torrent = TorrentHelper()
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.downloadchain = DownloadChain()
|
||||
self.subscribechain = SubscribeChain()
|
||||
self.searchchain = SearchChain()
|
||||
self.mediachain = MediaChain()
|
||||
self.eventmanager = EventManager()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
|
||||
def __get_noexits_info(
|
||||
self,
|
||||
_meta: MetaBase,
|
||||
_mediainfo: MediaInfo) -> Dict[Union[int, str], Dict[int, NotExistMediaInfo]]:
|
||||
"""
|
||||
获取缺失的媒体信息
|
||||
"""
|
||||
if _mediainfo.type == MediaType.TV:
|
||||
if not _mediainfo.seasons:
|
||||
# 补充媒体信息
|
||||
_mediainfo = self.mediachain.recognize_media(mtype=_mediainfo.type,
|
||||
tmdbid=_mediainfo.tmdb_id,
|
||||
doubanid=_mediainfo.douban_id,
|
||||
cache=False)
|
||||
if not _mediainfo:
|
||||
logger.warn(f"{_mediainfo.tmdb_id or _mediainfo.douban_id} 媒体信息识别失败!")
|
||||
return {}
|
||||
if not _mediainfo.seasons:
|
||||
logger.warn(f"媒体信息中没有季集信息,"
|
||||
f"标题:{_mediainfo.title},"
|
||||
f"tmdbid:{_mediainfo.tmdb_id},doubanid:{_mediainfo.douban_id}")
|
||||
return {}
|
||||
# KEY
|
||||
_mediakey = _mediainfo.tmdb_id or _mediainfo.douban_id
|
||||
_no_exists = {
|
||||
_mediakey: {}
|
||||
}
|
||||
if _meta.begin_season:
|
||||
# 指定季
|
||||
episodes = _mediainfo.seasons.get(_meta.begin_season)
|
||||
if not episodes:
|
||||
return {}
|
||||
_no_exists[_mediakey][_meta.begin_season] = NotExistMediaInfo(
|
||||
season=_meta.begin_season,
|
||||
episodes=[],
|
||||
total_episode=len(episodes),
|
||||
start_episode=episodes[0]
|
||||
)
|
||||
else:
|
||||
# 所有季
|
||||
for sea, eps in _mediainfo.seasons.items():
|
||||
if not eps:
|
||||
continue
|
||||
_no_exists[_mediakey][sea] = NotExistMediaInfo(
|
||||
season=sea,
|
||||
episodes=[],
|
||||
total_episode=len(eps),
|
||||
start_episode=eps[0]
|
||||
)
|
||||
else:
|
||||
_no_exists = {}
|
||||
|
||||
return _no_exists
|
||||
|
||||
def process(self, body: Any, form: Any, args: Any) -> None:
|
||||
"""
|
||||
识别消息内容,执行操作
|
||||
@@ -77,6 +138,7 @@ class MessageChain(ChainBase):
|
||||
)
|
||||
|
||||
elif text.isdigit():
|
||||
# 用户选择了具体的条目
|
||||
# 缓存
|
||||
cache_data: dict = user_cache.get(userid)
|
||||
# 选择项目
|
||||
@@ -86,34 +148,51 @@ class MessageChain(ChainBase):
|
||||
# 发送消息
|
||||
self.post_message(Notification(channel=channel, title="输入有误!", userid=userid))
|
||||
return
|
||||
# 选择的序号
|
||||
_choice = int(text) + _current_page * self._page_size - 1
|
||||
# 缓存类型
|
||||
cache_type: str = cache_data.get('type')
|
||||
# 缓存列表
|
||||
cache_list: list = cache_data.get('items')
|
||||
cache_list: list = copy.deepcopy(cache_data.get('items'))
|
||||
# 选择
|
||||
if cache_type == "Search":
|
||||
mediainfo: MediaInfo = cache_list[int(text) + _current_page * self._page_size - 1]
|
||||
if cache_type in ["Search", "ReSearch"]:
|
||||
# 当前媒体信息
|
||||
mediainfo: MediaInfo = cache_list[_choice]
|
||||
_current_media = mediainfo
|
||||
# 查询缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
||||
mediainfo=_current_media)
|
||||
if exist_flag:
|
||||
if exist_flag and cache_type == "Search":
|
||||
# 媒体库中已存在
|
||||
self.post_message(
|
||||
Notification(channel=channel,
|
||||
title=f"{_current_media.title_year}"
|
||||
f"{_current_meta.sea} 媒体库中已存在",
|
||||
f"{_current_meta.sea} 媒体库中已存在,如需重新下载请发送:搜索 XXX 或 下载 XXX",
|
||||
userid=userid))
|
||||
return
|
||||
elif exist_flag:
|
||||
# 没有缺失,但要全量重新搜索和下载
|
||||
no_exists = self.__get_noexits_info(_current_meta, _current_media)
|
||||
# 发送缺失的媒体信息
|
||||
if no_exists:
|
||||
# 发送消息
|
||||
messages = []
|
||||
if no_exists and cache_type == "Search":
|
||||
# 发送缺失消息
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
messages = [
|
||||
f"第 {sea} 季缺失 {StringUtils.str_series(no_exist.episodes) if no_exist.episodes else no_exist.total_episode} 集"
|
||||
for sea, no_exist in no_exists.get(mediainfo.tmdb_id).items()]
|
||||
for sea, no_exist in no_exists.get(mediakey).items()]
|
||||
elif no_exists:
|
||||
# 发送总集数的消息
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
messages = [
|
||||
f"第 {sea} 季总 {no_exist.total_episode} 集"
|
||||
for sea, no_exist in no_exists.get(mediakey).items()]
|
||||
if messages:
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"{mediainfo.title_year}:\n" + "\n".join(messages)))
|
||||
title=f"{mediainfo.title_year}:\n" + "\n".join(messages),
|
||||
userid=userid))
|
||||
# 搜索种子,过滤掉不需要的剧集,以便选择
|
||||
logger.info(f"{mediainfo.title_year} 媒体库中不存在,开始搜索 ...")
|
||||
logger.info(f"开始搜索 {mediainfo.title_year} ...")
|
||||
self.post_message(
|
||||
Notification(channel=channel,
|
||||
title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...",
|
||||
@@ -133,13 +212,16 @@ class MessageChain(ChainBase):
|
||||
# 判断是否设置自动下载
|
||||
auto_download_user = settings.AUTO_DOWNLOAD_USER
|
||||
# 匹配到自动下载用户
|
||||
if auto_download_user and any(userid == user for user in auto_download_user.split(",")):
|
||||
logger.info(f"用户 {userid} 在自动下载用户中,开始自动择优下载")
|
||||
if auto_download_user \
|
||||
and (auto_download_user == "all"
|
||||
or any(userid == user for user in auto_download_user.split(","))):
|
||||
logger.info(f"用户 {userid} 在自动下载用户中,开始自动择优下载 ...")
|
||||
# 自动选择下载
|
||||
self.__auto_download(channel=channel,
|
||||
cache_list=contexts,
|
||||
userid=userid,
|
||||
username=username)
|
||||
username=username,
|
||||
no_exists=no_exists)
|
||||
else:
|
||||
# 更新缓存
|
||||
user_cache[userid] = {
|
||||
@@ -154,19 +236,24 @@ class MessageChain(ChainBase):
|
||||
userid=userid,
|
||||
total=len(contexts))
|
||||
|
||||
elif cache_type == "Subscribe":
|
||||
# 订阅媒体
|
||||
mediainfo: MediaInfo = cache_list[int(text) - 1]
|
||||
elif cache_type in ["Subscribe", "ReSubscribe"]:
|
||||
# 订阅或洗版媒体
|
||||
mediainfo: MediaInfo = cache_list[_choice]
|
||||
# 洗版标识
|
||||
best_version = False
|
||||
# 查询缺失的媒体信息
|
||||
exist_flag, _ = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
||||
mediainfo=mediainfo)
|
||||
if exist_flag:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
title=f"{mediainfo.title_year}"
|
||||
f"{_current_meta.sea} 媒体库中已存在",
|
||||
userid=userid))
|
||||
return
|
||||
if cache_type == "Subscribe":
|
||||
exist_flag, _ = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
||||
mediainfo=mediainfo)
|
||||
if exist_flag:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
title=f"{mediainfo.title_year}"
|
||||
f"{_current_meta.sea} 媒体库中已存在,如需洗版请发送:洗版 XXX",
|
||||
userid=userid))
|
||||
return
|
||||
else:
|
||||
best_version = True
|
||||
# 添加订阅,状态为N
|
||||
self.subscribechain.add(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
@@ -175,19 +262,21 @@ class MessageChain(ChainBase):
|
||||
season=_current_meta.begin_season,
|
||||
channel=channel,
|
||||
userid=userid,
|
||||
username=username)
|
||||
username=username,
|
||||
best_version=best_version)
|
||||
elif cache_type == "Torrent":
|
||||
if int(text) == 0:
|
||||
# 自动选择下载
|
||||
# 自动选择下载,强制下载模式
|
||||
self.__auto_download(channel=channel,
|
||||
cache_list=cache_list,
|
||||
userid=userid,
|
||||
username=username)
|
||||
else:
|
||||
# 下载种子
|
||||
context: Context = cache_list[int(text) - 1]
|
||||
context: Context = cache_list[_choice]
|
||||
# 下载
|
||||
self.downloadchain.download_single(context, userid=userid)
|
||||
self.downloadchain.download_single(context, channel=channel,
|
||||
userid=userid, username=username)
|
||||
|
||||
elif text.lower() == "p":
|
||||
# 上一页
|
||||
@@ -203,10 +292,11 @@ class MessageChain(ChainBase):
|
||||
self.post_message(Notification(
|
||||
channel=channel, title="已经是第一页了!", userid=userid))
|
||||
return
|
||||
cache_type: str = cache_data.get('type')
|
||||
cache_list: list = cache_data.get('items')
|
||||
# 减一页
|
||||
_current_page -= 1
|
||||
cache_type: str = cache_data.get('type')
|
||||
# 产生副本,避免修改原值
|
||||
cache_list: list = copy.deepcopy(cache_data.get('items'))
|
||||
if _current_page == 0:
|
||||
start = 0
|
||||
end = self._page_size
|
||||
@@ -214,11 +304,6 @@ class MessageChain(ChainBase):
|
||||
start = _current_page * self._page_size
|
||||
end = start + self._page_size
|
||||
if cache_type == "Torrent":
|
||||
# 更新缓存
|
||||
user_cache[userid] = {
|
||||
"type": "Torrent",
|
||||
"items": cache_list[start:end]
|
||||
}
|
||||
# 发送种子数据
|
||||
self.__post_torrents_message(channel=channel,
|
||||
title=_current_media.title,
|
||||
@@ -242,7 +327,8 @@ class MessageChain(ChainBase):
|
||||
channel=channel, title="输入有误!", userid=userid))
|
||||
return
|
||||
cache_type: str = cache_data.get('type')
|
||||
cache_list: list = cache_data.get('items')
|
||||
# 产生副本,避免修改原值
|
||||
cache_list: list = copy.deepcopy(cache_data.get('items'))
|
||||
total = len(cache_list)
|
||||
# 加一页
|
||||
cache_list = cache_list[
|
||||
@@ -256,11 +342,6 @@ class MessageChain(ChainBase):
|
||||
# 加一页
|
||||
_current_page += 1
|
||||
if cache_type == "Torrent":
|
||||
# 更新缓存
|
||||
user_cache[userid] = {
|
||||
"type": "Torrent",
|
||||
"items": cache_list
|
||||
}
|
||||
# 发送种子数据
|
||||
self.__post_torrents_message(channel=channel,
|
||||
title=_current_media.title,
|
||||
@@ -277,6 +358,14 @@ class MessageChain(ChainBase):
|
||||
# 订阅
|
||||
content = re.sub(r"订阅[::\s]*", "", text)
|
||||
action = "Subscribe"
|
||||
elif text.startswith("洗版"):
|
||||
# 洗版
|
||||
content = re.sub(r"洗版[::\s]*", "", text)
|
||||
action = "ReSubscribe"
|
||||
elif text.startswith("搜索") or text.startswith("下载"):
|
||||
# 重新搜索/下载
|
||||
content = re.sub(r"(搜索|下载)[::\s]*", "", text)
|
||||
action = "ReSearch"
|
||||
elif text.startswith("#") \
|
||||
or re.search(r"^请[问帮你]", text) \
|
||||
or re.search(r"[??]$", text) \
|
||||
@@ -287,12 +376,12 @@ class MessageChain(ChainBase):
|
||||
action = "chat"
|
||||
else:
|
||||
# 搜索
|
||||
content = re.sub(r"(搜索|下载)[::\s]*", "", text)
|
||||
content = text
|
||||
action = "Search"
|
||||
|
||||
if action in ["Subscribe", "Search"]:
|
||||
if action != "chat":
|
||||
# 搜索
|
||||
meta, medias = self.medtachain.search(content)
|
||||
meta, medias = self.mediachain.search(content)
|
||||
# 识别
|
||||
if not meta.name:
|
||||
self.post_message(Notification(
|
||||
@@ -331,30 +420,41 @@ class MessageChain(ChainBase):
|
||||
# 保存缓存
|
||||
self.save_cache(user_cache, self._cache_file)
|
||||
|
||||
def __auto_download(self, channel, cache_list, userid, username):
|
||||
def __auto_download(self, channel: MessageChannel, cache_list: list[Context],
|
||||
userid: Union[str, int], username: str,
|
||||
no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None):
|
||||
"""
|
||||
自动择优下载
|
||||
"""
|
||||
# 查询缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
||||
mediainfo=_current_media)
|
||||
if exist_flag:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
title=f"{_current_media.title_year}"
|
||||
f"{_current_meta.sea} 媒体库中已存在",
|
||||
userid=userid))
|
||||
return
|
||||
if no_exists is None:
|
||||
# 查询缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
|
||||
meta=_current_meta,
|
||||
mediainfo=_current_media
|
||||
)
|
||||
if exist_flag:
|
||||
# 媒体库中已存在,查询全量
|
||||
no_exists = self.__get_noexits_info(_current_meta, _current_media)
|
||||
|
||||
# 批量下载
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
|
||||
no_exists=no_exists,
|
||||
userid=userid)
|
||||
channel=channel,
|
||||
userid=userid,
|
||||
username=username)
|
||||
if downloads and not lefts:
|
||||
# 全部下载完成
|
||||
logger.info(f'{_current_media.title_year} 下载完成')
|
||||
else:
|
||||
# 未完成下载
|
||||
logger.info(f'{_current_media.title_year} 未下载未完整,添加订阅 ...')
|
||||
if downloads and _current_media.type == MediaType.TV:
|
||||
# 获取已下载剧集
|
||||
downloaded = [download.meta_info.begin_episode for download in downloads
|
||||
if download.meta_info.begin_episode]
|
||||
note = json.dumps(downloaded)
|
||||
else:
|
||||
note = None
|
||||
# 添加订阅,状态为R
|
||||
self.subscribechain.add(title=_current_media.title,
|
||||
year=_current_media.year,
|
||||
@@ -364,7 +464,8 @@ class MessageChain(ChainBase):
|
||||
channel=channel,
|
||||
userid=userid,
|
||||
username=username,
|
||||
state="R")
|
||||
state="R",
|
||||
note=note)
|
||||
|
||||
def __post_medias_message(self, channel: MessageChannel,
|
||||
title: str, items: list, userid: str, total: int):
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import pickle
|
||||
import re
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import Context
|
||||
from app.core.context import MediaInfo, TorrentInfo
|
||||
@@ -25,21 +25,23 @@ class SearchChain(ChainBase):
|
||||
站点资源搜索处理链
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.siteshelper = SitesHelper()
|
||||
self.progress = ProgressHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
|
||||
def search_by_tmdbid(self, tmdbid: int, mtype: MediaType = None, area: str = "title") -> List[Context]:
|
||||
def search_by_id(self, tmdbid: int = None, doubanid: str = None,
|
||||
mtype: MediaType = None, area: str = "title") -> List[Context]:
|
||||
"""
|
||||
根据TMDB ID搜索资源,精确匹配,但不不过滤本地存在的资源
|
||||
根据TMDBID/豆瓣ID搜索资源,精确匹配,但不不过滤本地存在的资源
|
||||
:param tmdbid: TMDB ID
|
||||
:param doubanid: 豆瓣 ID
|
||||
:param mtype: 媒体,电影 or 电视剧
|
||||
:param area: 搜索范围,title or imdbid
|
||||
"""
|
||||
mediainfo = self.recognize_media(tmdbid=tmdbid, mtype=mtype)
|
||||
mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
if not mediainfo:
|
||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||
return []
|
||||
@@ -61,7 +63,7 @@ class SearchChain(ChainBase):
|
||||
else:
|
||||
logger.info(f'开始浏览资源,站点:{site} ...')
|
||||
# 搜索
|
||||
return self.__search_all_sites(keyword=title, sites=[site] if site else None, page=page) or []
|
||||
return self.__search_all_sites(keywords=[title], sites=[site] if site else None, page=page) or []
|
||||
|
||||
def last_search_results(self) -> List[Context]:
|
||||
"""
|
||||
@@ -73,14 +75,15 @@ class SearchChain(ChainBase):
|
||||
try:
|
||||
return pickle.loads(results)
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
|
||||
return []
|
||||
|
||||
def process(self, mediainfo: MediaInfo,
|
||||
keyword: str = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
sites: List[int] = None,
|
||||
filter_rule: str = None,
|
||||
priority_rule: str = None,
|
||||
filter_rule: Dict[str, str] = None,
|
||||
area: str = "title") -> List[Context]:
|
||||
"""
|
||||
根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
||||
@@ -88,59 +91,57 @@ class SearchChain(ChainBase):
|
||||
:param keyword: 搜索关键词
|
||||
:param no_exists: 缺失的媒体信息
|
||||
:param sites: 站点ID列表,为空时搜索所有站点
|
||||
:param priority_rule: 优先级规则,为空时使用搜索优先级规则
|
||||
:param filter_rule: 过滤规则,为空是使用默认过滤规则
|
||||
:param area: 搜索范围,title or imdbid
|
||||
"""
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||
# 补充媒体信息
|
||||
if not mediainfo.names:
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id)
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
if not mediainfo:
|
||||
logger.error(f'媒体信息识别失败!')
|
||||
return []
|
||||
# 缺失的季集
|
||||
if no_exists and no_exists.get(mediainfo.tmdb_id):
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
if no_exists and no_exists.get(mediakey):
|
||||
# 过滤剧集
|
||||
season_episodes = {sea: info.episodes
|
||||
for sea, info in no_exists[mediainfo.tmdb_id].items()}
|
||||
elif mediainfo.season:
|
||||
# 豆瓣只搜索当前季
|
||||
season_episodes = {mediainfo.season: []}
|
||||
else:
|
||||
season_episodes = None
|
||||
# 搜索关键词
|
||||
if keyword:
|
||||
keywords = [keyword]
|
||||
elif mediainfo.original_title and mediainfo.title != mediainfo.original_title:
|
||||
keywords = [mediainfo.title, mediainfo.original_title]
|
||||
else:
|
||||
keywords = [mediainfo.title]
|
||||
# 去重去空,但要保持顺序
|
||||
keywords = list(dict.fromkeys([k for k in [mediainfo.title,
|
||||
mediainfo.original_title,
|
||||
mediainfo.en_title,
|
||||
mediainfo.sg_title] if k]))
|
||||
|
||||
# 执行搜索
|
||||
torrents: List[TorrentInfo] = []
|
||||
for keyword in keywords:
|
||||
torrents = self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
sites=sites,
|
||||
area=area
|
||||
)
|
||||
if torrents:
|
||||
break
|
||||
torrents: List[TorrentInfo] = self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keywords=keywords,
|
||||
sites=sites,
|
||||
area=area
|
||||
)
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||
return []
|
||||
# 过滤种子
|
||||
if filter_rule is None:
|
||||
# 取默认过滤规则
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
|
||||
if filter_rule:
|
||||
logger.info(f'开始过滤资源,当前规则:{filter_rule} ...')
|
||||
result: List[TorrentInfo] = self.filter_torrents(rule_string=filter_rule,
|
||||
torrent_list=torrents,
|
||||
season_episodes=season_episodes)
|
||||
if result is not None:
|
||||
torrents = result
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤条件的资源')
|
||||
return []
|
||||
# 开始新进度
|
||||
self.progress.start(ProgressKey.Search)
|
||||
# 匹配的资源
|
||||
_match_torrents = []
|
||||
# 总数
|
||||
@@ -148,27 +149,32 @@ class SearchChain(ChainBase):
|
||||
# 已处理数
|
||||
_count = 0
|
||||
if mediainfo:
|
||||
self.progress.start(ProgressKey.Search)
|
||||
logger.info(f'开始匹配,总 {_total} 个资源 ...')
|
||||
# 英文标题应该在别名/原标题中,不需要再匹配
|
||||
logger.info(f"标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
|
||||
self.progress.update(value=0, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
|
||||
for torrent in torrents:
|
||||
_count += 1
|
||||
self.progress.update(value=(_count / _total) * 100,
|
||||
self.progress.update(value=(_count / _total) * 96,
|
||||
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
|
||||
key=ProgressKey.Search)
|
||||
# 比对IMDBID
|
||||
if torrent.imdbid \
|
||||
and mediainfo.imdb_id \
|
||||
and torrent.imdbid == mediainfo.imdb_id:
|
||||
logger.info(f'{mediainfo.title} 匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
logger.info(f'{mediainfo.title} 通过IMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
_match_torrents.append(torrent)
|
||||
continue
|
||||
# 识别
|
||||
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
# 比对类型
|
||||
if (torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV) \
|
||||
or (torrent_meta.type != MediaType.TV and mediainfo.type == MediaType.TV):
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 类型不匹配')
|
||||
# 比对种子识别类型
|
||||
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value},'
|
||||
f'需要是 {mediainfo.type.value},不匹配')
|
||||
continue
|
||||
# 比对种子在站点中的类型
|
||||
if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category},'
|
||||
f'需要是 {mediainfo.type.value},不匹配')
|
||||
continue
|
||||
# 比对年份
|
||||
if mediainfo.year:
|
||||
@@ -196,7 +202,7 @@ class SearchChain(ChainBase):
|
||||
continue
|
||||
# 在副标题中判断是否存在标题与原语种标题
|
||||
if torrent.description:
|
||||
subtitle = torrent.description.split()
|
||||
subtitle = re.split(r'[\s/|]+', torrent.description)
|
||||
if (StringUtils.is_chinese(mediainfo.title)
|
||||
and str(mediainfo.title) in subtitle) \
|
||||
or (StringUtils.is_chinese(mediainfo.original_title)
|
||||
@@ -213,33 +219,67 @@ class SearchChain(ChainBase):
|
||||
break
|
||||
else:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 标题不匹配')
|
||||
self.progress.update(value=100,
|
||||
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
||||
self.progress.update(value=97,
|
||||
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
|
||||
key=ProgressKey.Search)
|
||||
self.progress.end(ProgressKey.Search)
|
||||
else:
|
||||
_match_torrents = torrents
|
||||
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
||||
# 开始过滤
|
||||
self.progress.update(value=98, text=f'开始过滤,总 {len(_match_torrents)} 个资源,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
# 过滤种子
|
||||
if priority_rule is None:
|
||||
# 取搜索优先级规则
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
|
||||
if priority_rule:
|
||||
logger.info(f'开始优先级规则过滤,当前规则:{priority_rule} ...')
|
||||
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
|
||||
torrent_list=_match_torrents,
|
||||
season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
if result is not None:
|
||||
_match_torrents = result
|
||||
if not _match_torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
||||
return []
|
||||
# 使用过滤规则再次过滤
|
||||
if filter_rule:
|
||||
logger.info(f'开始过滤规则过滤,当前规则:{filter_rule} ...')
|
||||
_match_torrents = self.filter_torrents_by_rule(torrents=_match_torrents,
|
||||
mediainfo=mediainfo,
|
||||
filter_rule=filter_rule)
|
||||
if not _match_torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
||||
return []
|
||||
# 去掉mediainfo中多余的数据
|
||||
mediainfo.clear()
|
||||
# 组装上下文
|
||||
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrent) for torrent in _match_torrents]
|
||||
|
||||
logger.info(f"过滤完成,剩余 {_total} 个资源")
|
||||
self.progress.update(value=99, text=f'过滤完成,剩余 {_total} 个资源', key=ProgressKey.Search)
|
||||
# 排序
|
||||
self.progress.update(value=100,
|
||||
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
contexts = self.torrenthelper.sort_torrents(contexts)
|
||||
# 结束进度
|
||||
self.progress.end(ProgressKey.Search)
|
||||
# 返回
|
||||
return contexts
|
||||
|
||||
def __search_all_sites(self, mediainfo: Optional[MediaInfo] = None,
|
||||
keyword: str = None,
|
||||
def __search_all_sites(self, keywords: List[str],
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
sites: List[int] = None,
|
||||
page: int = 0,
|
||||
area: str = "title") -> Optional[List[TorrentInfo]]:
|
||||
"""
|
||||
多线程搜索多个站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param keyword: 搜索关键词,如有按关键词搜索,否则按媒体信息名称搜索
|
||||
:param keywords: 搜索关键词列表
|
||||
:param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点
|
||||
:param page: 搜索页码
|
||||
:param area: 搜索区域 title or imdbid
|
||||
@@ -281,8 +321,18 @@ class SearchChain(ChainBase):
|
||||
executor = ThreadPoolExecutor(max_workers=len(indexer_sites))
|
||||
all_task = []
|
||||
for site in indexer_sites:
|
||||
task = executor.submit(self.search_torrents, mediainfo=mediainfo,
|
||||
site=site, keyword=keyword, page=page, area=area)
|
||||
if area == "imdbid":
|
||||
# 搜索IMDBID
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=[mediainfo.imdb_id] if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
else:
|
||||
# 搜索标题
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=keywords,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
all_task.append(task)
|
||||
# 结果集
|
||||
results = []
|
||||
@@ -293,7 +343,7 @@ class SearchChain(ChainBase):
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
self.progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 计算耗时
|
||||
end_time = datetime.now()
|
||||
@@ -306,3 +356,31 @@ class SearchChain(ChainBase):
|
||||
self.progress.end(ProgressKey.Search)
|
||||
# 返回
|
||||
return results
|
||||
|
||||
def filter_torrents_by_rule(self,
|
||||
torrents: List[TorrentInfo],
|
||||
mediainfo: MediaInfo,
|
||||
filter_rule: Dict[str, str] = None,
|
||||
) -> List[TorrentInfo]:
|
||||
"""
|
||||
使用过滤规则过滤种子
|
||||
:param torrents: 种子列表
|
||||
:param filter_rule: 过滤规则
|
||||
:param mediainfo: 媒体信息
|
||||
"""
|
||||
|
||||
if not filter_rule:
|
||||
# 没有则取搜索默认过滤规则
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules)
|
||||
if not filter_rule:
|
||||
return torrents
|
||||
|
||||
# 使用默认过滤规则再次过滤
|
||||
return list(filter(
|
||||
lambda t: self.torrenthelper.filter_torrent(
|
||||
torrent_info=t,
|
||||
filter_rule=filter_rule,
|
||||
mediainfo=mediainfo
|
||||
),
|
||||
torrents
|
||||
))
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
from typing import Union, Tuple
|
||||
import base64
|
||||
import re
|
||||
from typing import Tuple, Optional
|
||||
from typing import Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from lxml import etree
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event, EventManager
|
||||
from app.db.models.site import Site
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.siteicon_oper import SiteIconOper
|
||||
from app.helper.browser import PlaywrightHelper
|
||||
from app.helper.cloudflare import under_challenge
|
||||
from app.helper.cookie import CookieHelper
|
||||
from app.helper.cookiecloud import CookieCloudHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.schemas import MessageChannel, Notification
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.site import SiteUtils
|
||||
from app.utils.string import StringUtils
|
||||
@@ -22,11 +32,263 @@ class SiteChain(ChainBase):
|
||||
站点管理处理链
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
self.siteoper = SiteOper(self._db)
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.siteoper = SiteOper()
|
||||
self.siteiconoper = SiteIconOper()
|
||||
self.siteshelper = SitesHelper()
|
||||
self.rsshelper = RssHelper()
|
||||
self.cookiehelper = CookieHelper()
|
||||
self.message = MessageHelper()
|
||||
self.cookiecloud = CookieCloudHelper(
|
||||
server=settings.COOKIECLOUD_HOST,
|
||||
key=settings.COOKIECLOUD_KEY,
|
||||
password=settings.COOKIECLOUD_PASSWORD
|
||||
)
|
||||
|
||||
# 特殊站点登录验证
|
||||
self.special_site_test = {
|
||||
"zhuque.in": self.__zhuque_test,
|
||||
# "m-team.io": self.__mteam_test,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def __zhuque_test(site: Site) -> Tuple[bool, str]:
|
||||
"""
|
||||
判断站点是否已经登陆:zhuique
|
||||
"""
|
||||
# 获取token
|
||||
token = None
|
||||
res = RequestUtils(
|
||||
ua=site.ua,
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
).get_res(url=site.url)
|
||||
if res and res.status_code == 200:
|
||||
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
|
||||
if csrf_token:
|
||||
token = csrf_token.group(1)
|
||||
if not token:
|
||||
return False, "无法获取Token"
|
||||
# 调用查询用户信息接口
|
||||
user_res = RequestUtils(
|
||||
headers={
|
||||
'X-CSRF-TOKEN': token,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"User-Agent": f"{site.ua}"
|
||||
},
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
).get_res(url=f"{site.url}api/user/getInfo")
|
||||
if user_res and user_res.status_code == 200:
|
||||
user_info = user_res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
return True, "连接成功"
|
||||
return False, "Cookie已失效"
|
||||
|
||||
@staticmethod
|
||||
def __mteam_test(site: Site) -> Tuple[bool, str]:
|
||||
"""
|
||||
判断站点是否已经登陆:m-team
|
||||
"""
|
||||
url = f"{site.url}api/member/profile"
|
||||
res = RequestUtils(
|
||||
ua=site.ua,
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
).post_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
user_info = res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
return True, "连接成功"
|
||||
return False, "Cookie已失效"
|
||||
|
||||
@staticmethod
|
||||
def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
解析站点favicon,返回base64 fav图标
|
||||
:param url: 站点地址
|
||||
:param cookie: Cookie
|
||||
:param ua: User-Agent
|
||||
:return:
|
||||
"""
|
||||
favicon_url = urljoin(url, "favicon.ico")
|
||||
res = RequestUtils(cookies=cookie, timeout=60, ua=ua).get_res(url=url)
|
||||
if res:
|
||||
html_text = res.text
|
||||
else:
|
||||
logger.error(f"获取站点页面失败:{url}")
|
||||
return favicon_url, None
|
||||
html = etree.HTML(html_text)
|
||||
if html:
|
||||
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
|
||||
if fav_link:
|
||||
favicon_url = urljoin(url, fav_link[0])
|
||||
|
||||
res = RequestUtils(cookies=cookie, timeout=20, ua=ua).get_res(url=favicon_url)
|
||||
if res:
|
||||
return favicon_url, base64.b64encode(res.content).decode()
|
||||
else:
|
||||
logger.error(f"获取站点图标失败:{favicon_url}")
|
||||
return favicon_url, None
|
||||
|
||||
def sync_cookies(self, manual=False) -> Tuple[bool, str]:
|
||||
"""
|
||||
通过CookieCloud同步站点Cookie
|
||||
"""
|
||||
|
||||
def __indexer_domain(inx: dict, sub_domain: str) -> str:
|
||||
"""
|
||||
根据主域名获取索引器地址
|
||||
"""
|
||||
if StringUtils.get_url_domain(inx.get("domain")) == sub_domain:
|
||||
return inx.get("domain")
|
||||
for ext_d in inx.get("ext_domains"):
|
||||
if StringUtils.get_url_domain(ext_d) == sub_domain:
|
||||
return ext_d
|
||||
return sub_domain
|
||||
|
||||
logger.info("开始同步CookieCloud站点 ...")
|
||||
cookies, msg = self.cookiecloud.download()
|
||||
if not cookies:
|
||||
logger.error(f"CookieCloud同步失败:{msg}")
|
||||
if manual:
|
||||
self.message.put(f"CookieCloud同步失败: {msg}")
|
||||
return False, msg
|
||||
# 保存Cookie或新增站点
|
||||
_update_count = 0
|
||||
_add_count = 0
|
||||
_fail_count = 0
|
||||
for domain, cookie in cookies.items():
|
||||
# 索引器信息
|
||||
indexer = self.siteshelper.get_indexer(domain)
|
||||
# 数据库的站点信息
|
||||
site_info = self.siteoper.get_by_domain(domain)
|
||||
if site_info:
|
||||
# 站点已存在,检查站点连通性
|
||||
status, msg = self.test(domain)
|
||||
# 更新站点Cookie
|
||||
if status:
|
||||
logger.info(f"站点【{site_info.name}】连通性正常,不同步CookieCloud数据")
|
||||
# 更新站点rss地址
|
||||
if not site_info.public and not site_info.rss:
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(
|
||||
url=site_info.url,
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT,
|
||||
proxy=True if site_info.proxy else False
|
||||
)
|
||||
if rss_url:
|
||||
logger.info(f"更新站点 {domain} RSS地址 ...")
|
||||
self.siteoper.update_rss(domain=domain, rss=rss_url)
|
||||
else:
|
||||
logger.warn(errmsg)
|
||||
continue
|
||||
# 更新站点Cookie
|
||||
logger.info(f"更新站点 {domain} Cookie ...")
|
||||
self.siteoper.update_cookie(domain=domain, cookies=cookie)
|
||||
_update_count += 1
|
||||
elif indexer:
|
||||
# 新增站点
|
||||
domain_url = __indexer_domain(inx=indexer, sub_domain=domain)
|
||||
res = RequestUtils(cookies=cookie,
|
||||
ua=settings.USER_AGENT
|
||||
).get_res(url=domain_url)
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
if not indexer.get("public") and not SiteUtils.is_logged_in(res.text):
|
||||
_fail_count += 1
|
||||
if under_challenge(res.text):
|
||||
logger.warn(f"站点 {indexer.get('name')} 被Cloudflare防护,无法登录,无法添加站点")
|
||||
continue
|
||||
logger.warn(
|
||||
f"站点 {indexer.get('name')} 登录失败,没有该站点账号或Cookie已失效,无法添加站点")
|
||||
continue
|
||||
elif res is not None:
|
||||
_fail_count += 1
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接状态码:{res.status_code},无法添加站点")
|
||||
continue
|
||||
else:
|
||||
_fail_count += 1
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
|
||||
continue
|
||||
# 获取rss地址
|
||||
rss_url = None
|
||||
if not indexer.get("public") and domain_url:
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(url=domain_url,
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT)
|
||||
if errmsg:
|
||||
logger.warn(errmsg)
|
||||
# 插入数据库
|
||||
logger.info(f"新增站点 {indexer.get('name')} ...")
|
||||
self.siteoper.add(name=indexer.get("name"),
|
||||
url=domain_url,
|
||||
domain=domain,
|
||||
cookie=cookie,
|
||||
rss=rss_url,
|
||||
public=1 if indexer.get("public") else 0)
|
||||
_add_count += 1
|
||||
|
||||
# 通知缓存站点图标
|
||||
if indexer:
|
||||
EventManager().send_event(EventType.CacheSiteIcon, {
|
||||
"domain": domain,
|
||||
})
|
||||
# 处理完成
|
||||
ret_msg = f"更新了{_update_count}个站点,新增了{_add_count}个站点"
|
||||
if _fail_count > 0:
|
||||
ret_msg += f",{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
|
||||
if manual:
|
||||
self.message.put(f"CookieCloud同步成功, {ret_msg}")
|
||||
logger.info(f"CookieCloud同步成功:{ret_msg}")
|
||||
return True, ret_msg
|
||||
|
||||
@eventmanager.register(EventType.CacheSiteIcon)
|
||||
def cache_site_icon(self, event: Event):
|
||||
"""
|
||||
缓存站点图标
|
||||
"""
|
||||
if not event:
|
||||
return
|
||||
event_data = event.event_data or {}
|
||||
# 主域名
|
||||
domain = event_data.get("domain")
|
||||
if not domain:
|
||||
return
|
||||
if str(domain).startswith("http"):
|
||||
domain = StringUtils.get_url_domain(domain)
|
||||
# 站点信息
|
||||
siteinfo = self.siteoper.get_by_domain(domain)
|
||||
if not siteinfo:
|
||||
logger.warn(f"未维护站点 {domain} 信息!")
|
||||
return
|
||||
# Cookie
|
||||
cookie = siteinfo.cookie
|
||||
# 索引器
|
||||
indexer = self.siteshelper.get_indexer(domain)
|
||||
if not indexer:
|
||||
logger.warn(f"站点 {domain} 索引器不存在!")
|
||||
return
|
||||
# 查询站点图标
|
||||
site_icon = self.siteiconoper.get_by_domain(domain)
|
||||
if not site_icon or not site_icon.base64:
|
||||
logger.info(f"开始缓存站点 {indexer.get('name')} 图标 ...")
|
||||
icon_url, icon_base64 = self.__parse_favicon(url=indexer.get("domain"),
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT)
|
||||
if icon_url:
|
||||
self.siteiconoper.update_icon(name=indexer.get("name"),
|
||||
domain=domain,
|
||||
icon_url=icon_url,
|
||||
icon_base64=icon_base64)
|
||||
logger.info(f"缓存站点 {indexer.get('name')} 图标成功")
|
||||
else:
|
||||
logger.warn(f"缓存站点 {indexer.get('name')} 图标失败")
|
||||
|
||||
def test(self, url: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
@@ -39,6 +301,12 @@ class SiteChain(ChainBase):
|
||||
site_info = self.siteoper.get_by_domain(domain)
|
||||
if not site_info:
|
||||
return False, f"站点【{url}】不存在"
|
||||
|
||||
# 特殊站点测试
|
||||
if self.special_site_test.get(domain):
|
||||
return self.special_site_test[domain](site_info)
|
||||
|
||||
# 通用站点测试
|
||||
site_url = site_info.url
|
||||
site_cookie = site_info.cookie
|
||||
ua = site_info.ua
|
||||
@@ -96,7 +364,7 @@ class SiteChain(ChainBase):
|
||||
title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \
|
||||
f"\n- 禁用站点:/site_disable [id]" \
|
||||
f"\n- 启用站点:/site_enable [id]" \
|
||||
f"\n- 更新站点Cookie:/site_cookie [id] [username] [password]"
|
||||
f"\n- 更新站点Cookie:/site_cookie [id] [username] [password] [2fa_code/secret]"
|
||||
messages = []
|
||||
for site in site_list:
|
||||
if site.render:
|
||||
@@ -104,9 +372,9 @@ class SiteChain(ChainBase):
|
||||
else:
|
||||
render_str = ""
|
||||
if site.is_active:
|
||||
messages.append(f"{site.id}. [{site.name}]({site.url}){render_str}")
|
||||
messages.append(f"{site.id}. {site.name} {render_str}")
|
||||
else:
|
||||
messages.append(f"{site.id}. {site.name}")
|
||||
messages.append(f"{site.id}. {site.name} ⚠️")
|
||||
# 发送列表
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
@@ -162,12 +430,13 @@ class SiteChain(ChainBase):
|
||||
self.remote_list(channel, userid)
|
||||
|
||||
def update_cookie(self, site_info: Site,
|
||||
username: str, password: str) -> Tuple[bool, str]:
|
||||
username: str, password: str, two_step_code: str = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
根据用户名密码更新站点Cookie
|
||||
:param site_info: 站点信息
|
||||
:param username: 用户名
|
||||
:param password: 密码
|
||||
:param two_step_code: 二步验证码或密钥
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
# 更新站点Cookie
|
||||
@@ -175,6 +444,7 @@ class SiteChain(ChainBase):
|
||||
url=site_info.url,
|
||||
username=username,
|
||||
password=password,
|
||||
two_step_code=two_step_code,
|
||||
proxies=settings.PROXY_HOST if site_info.proxy else None
|
||||
)
|
||||
if result:
|
||||
@@ -192,8 +462,8 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
使用用户名密码更新站点Cookie
|
||||
"""
|
||||
err_title = "请输入正确的命令格式:/site_cookie [id] [username] [password]," \
|
||||
"[id]为站点编号,[uername]为站点用户名,[password]为站点密码"
|
||||
err_title = "请输入正确的命令格式:/site_cookie [id] [username] [password] [2fa_code/secret]," \
|
||||
"[id]为站点编号,[uername]为站点用户名,[password]为站点密码,[2fa_code/secret]为站点二步验证码或密钥"
|
||||
if not arg_str:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
@@ -201,7 +471,11 @@ class SiteChain(ChainBase):
|
||||
return
|
||||
arg_str = str(arg_str).strip()
|
||||
args = arg_str.split()
|
||||
if len(args) != 3:
|
||||
# 二步验证码
|
||||
two_step_code = None
|
||||
if len(args) == 4:
|
||||
two_step_code = args[3]
|
||||
elif len(args) != 3:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
title=err_title, userid=userid))
|
||||
@@ -231,7 +505,8 @@ class SiteChain(ChainBase):
|
||||
# 更新Cookie
|
||||
status, msg = self.update_cookie(site_info=site_info,
|
||||
username=username,
|
||||
password=password)
|
||||
password=password,
|
||||
two_step_code=two_step_code)
|
||||
if not status:
|
||||
logger.error(msg)
|
||||
self.post_message(Notification(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,23 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Union
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, MessageChannel
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class SystemChain(ChainBase):
|
||||
class SystemChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
系统级处理链
|
||||
"""
|
||||
|
||||
_restart_file = "__system_restart__"
|
||||
|
||||
def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
清理系统缓存
|
||||
@@ -15,3 +25,95 @@ class SystemChain(ChainBase):
|
||||
self.clear_cache()
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"缓存清理完成!", userid=userid))
|
||||
|
||||
def restart(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
重启系统
|
||||
"""
|
||||
if channel and userid:
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="系统正在重启,请耐心等候!", userid=userid))
|
||||
# 保存重启信息
|
||||
self.save_cache({
|
||||
"channel": channel.value,
|
||||
"userid": userid
|
||||
}, self._restart_file)
|
||||
SystemUtils.restart()
|
||||
|
||||
def version(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
查看当前版本、远程版本
|
||||
"""
|
||||
release_version = self.__get_release_version()
|
||||
local_version = self.get_local_version()
|
||||
if release_version == local_version:
|
||||
title = f"当前版本:{local_version},已是最新版本"
|
||||
else:
|
||||
title = f"当前版本:{local_version},远程版本:{release_version}"
|
||||
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=title, userid=userid))
|
||||
|
||||
def restart_finish(self):
|
||||
"""
|
||||
如通过交互命令重启,
|
||||
重启完发送msg
|
||||
"""
|
||||
# 重启消息
|
||||
restart_channel = self.load_cache(self._restart_file)
|
||||
if restart_channel:
|
||||
# 发送重启完成msg
|
||||
if not isinstance(restart_channel, dict):
|
||||
restart_channel = json.loads(restart_channel)
|
||||
channel = next(
|
||||
(channel for channel in MessageChannel.__members__.values() if
|
||||
channel.value == restart_channel.get('channel')), None)
|
||||
userid = restart_channel.get('userid')
|
||||
|
||||
# 版本号
|
||||
release_version = self.__get_release_version()
|
||||
local_version = self.get_local_version()
|
||||
if release_version == local_version:
|
||||
title = f"当前版本:{local_version}"
|
||||
else:
|
||||
title = f"当前版本:{local_version},远程版本:{release_version}"
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"系统已重启完成!{title}",
|
||||
userid=userid))
|
||||
self.remove_cache(self._restart_file)
|
||||
|
||||
@staticmethod
|
||||
def __get_release_version():
|
||||
"""
|
||||
获取最新版本
|
||||
"""
|
||||
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
|
||||
if version_res:
|
||||
ver_json = version_res.json()
|
||||
version = f"{ver_json['tag_name']}"
|
||||
return version
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_local_version():
|
||||
"""
|
||||
查看当前版本
|
||||
"""
|
||||
version_file = settings.ROOT_PATH / "version.py"
|
||||
if version_file.exists():
|
||||
try:
|
||||
with open(version_file, 'rb') as f:
|
||||
version = f.read()
|
||||
pattern = r"'([^']*)'"
|
||||
match = re.search(pattern, str(version))
|
||||
|
||||
if match:
|
||||
version = match.group(1)
|
||||
return version
|
||||
else:
|
||||
logger.warn("未找到版本号")
|
||||
return None
|
||||
except Exception as err:
|
||||
logger.error(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import random
|
||||
from typing import Optional, List
|
||||
|
||||
from cachetools import cached, TTLCache
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class TmdbChain(ChainBase):
|
||||
class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
TheMovieDB处理链
|
||||
TheMovieDB处理链,单例运行
|
||||
"""
|
||||
|
||||
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str,
|
||||
@@ -20,17 +25,21 @@ class TmdbChain(ChainBase):
|
||||
:param page: 页码
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
return None
|
||||
return self.run_module("tmdb_discover", mtype=mtype,
|
||||
sort_by=sort_by, with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
|
||||
def tmdb_trending(self, page: int = 1) -> List[dict]:
|
||||
def tmdb_trending(self, page: int = 1) -> Optional[List[dict]]:
|
||||
"""
|
||||
TMDB流行趋势
|
||||
:param page: 第几页
|
||||
:return: TMDB信息列表
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
return None
|
||||
return self.run_module("tmdb_trending", page=page)
|
||||
|
||||
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
||||
@@ -53,28 +62,28 @@ class TmdbChain(ChainBase):
|
||||
根据TMDBID查询类似电影
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("movie_similar", tmdbid=tmdbid)
|
||||
return self.run_module("tmdb_movie_similar", tmdbid=tmdbid)
|
||||
|
||||
def tv_similar(self, tmdbid: int) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询类似电视剧
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("tv_similar", tmdbid=tmdbid)
|
||||
return self.run_module("tmdb_tv_similar", tmdbid=tmdbid)
|
||||
|
||||
def movie_recommend(self, tmdbid: int) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询推荐电影
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("movie_recommend", tmdbid=tmdbid)
|
||||
return self.run_module("tmdb_movie_recommend", tmdbid=tmdbid)
|
||||
|
||||
def tv_recommend(self, tmdbid: int) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询推荐电视剧
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("tv_recommend", tmdbid=tmdbid)
|
||||
return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid)
|
||||
|
||||
def movie_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
@@ -82,7 +91,7 @@ class TmdbChain(ChainBase):
|
||||
:param tmdbid: TMDBID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("movie_credits", tmdbid=tmdbid, page=page)
|
||||
return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page)
|
||||
|
||||
def tv_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
@@ -90,14 +99,14 @@ class TmdbChain(ChainBase):
|
||||
:param tmdbid: TMDBID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("tv_credits", tmdbid=tmdbid, page=page)
|
||||
return self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page)
|
||||
|
||||
def person_detail(self, person_id: int) -> dict:
|
||||
"""
|
||||
根据TMDBID查询演职员详情
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return self.run_module("person_detail", person_id=person_id)
|
||||
return self.run_module("tmdb_person_detail", person_id=person_id)
|
||||
|
||||
def person_credits(self, person_id: int, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
@@ -105,4 +114,18 @@ class TmdbChain(ChainBase):
|
||||
:param person_id: 人物ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("person_credits", person_id=person_id, page=page)
|
||||
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
||||
def get_random_wallpager(self):
|
||||
"""
|
||||
获取随机壁纸,缓存1个小时
|
||||
"""
|
||||
infos = self.tmdb_trending()
|
||||
if infos:
|
||||
# 随机一个电影
|
||||
while True:
|
||||
info = random.choice(infos)
|
||||
if info and info.get("backdrop_path"):
|
||||
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.get('backdrop_path')}"
|
||||
return None
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import re
|
||||
import traceback
|
||||
from typing import Dict, List, Union
|
||||
|
||||
from cachetools import cached, TTLCache
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import TorrentInfo, Context, MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db import SessionFactory
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType
|
||||
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -28,12 +30,13 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
_rss_file = "__rss_cache__"
|
||||
|
||||
def __init__(self):
|
||||
self._db = SessionFactory()
|
||||
super().__init__(self._db)
|
||||
super().__init__()
|
||||
self.siteshelper = SitesHelper()
|
||||
self.siteoper = SiteOper(self._db)
|
||||
self.siteoper = SiteOper()
|
||||
self.rsshelper = RssHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.mediachain = MediaChain()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
|
||||
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
@@ -60,7 +63,16 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
else:
|
||||
return self.load_cache(self._rss_file) or {}
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=600))
|
||||
def clear_torrents(self):
|
||||
"""
|
||||
清理种子缓存数据
|
||||
"""
|
||||
logger.info(f'开始清理种子缓存数据 ...')
|
||||
self.remove_cache(self._spider_file)
|
||||
self.remove_cache(self._rss_file)
|
||||
logger.info(f'种子缓存数据清理完成')
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=595))
|
||||
def browse(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
浏览站点首页内容,返回种子清单,TTL缓存10分钟
|
||||
@@ -73,7 +85,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
return []
|
||||
return self.refresh_torrents(site=site)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=300))
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=295))
|
||||
def rss(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
获取站点RSS内容,返回种子清单,TTL缓存5分钟
|
||||
@@ -87,7 +99,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
if not site.get("rss"):
|
||||
logger.error(f'站点 {domain} 未配置RSS地址!')
|
||||
return []
|
||||
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False)
|
||||
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False, timeout=int(site.get("timeout") or 30))
|
||||
if rss_items is None:
|
||||
# rss过期,尝试保留原配置生成新的rss
|
||||
self.__renew_rss_url(domain=domain, site=site)
|
||||
@@ -134,6 +146,11 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# 读取缓存
|
||||
torrents_cache = self.get_torrents()
|
||||
|
||||
# 缓存过滤掉无效种子
|
||||
for _domain, _torrents in torrents_cache.items():
|
||||
torrents_cache[_domain] = [_torrent for _torrent in _torrents
|
||||
if not self.torrenthelper.is_invalid(_torrent.torrent_info.enclosure)]
|
||||
|
||||
# 所有站点索引
|
||||
indexers = self.siteshelper.get_indexers()
|
||||
# 遍历站点缓存资源
|
||||
@@ -167,8 +184,12 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
logger.info(f'处理资源:{torrent.title} ...')
|
||||
# 识别
|
||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
# 使用站点种子分类,校正类型识别
|
||||
if meta.type != MediaType.TV \
|
||||
and torrent.category == MediaType.TV.value:
|
||||
meta.type = MediaType.TV
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta)
|
||||
mediainfo: MediaInfo = self.mediachain.recognize_by_meta(meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{torrent.title}')
|
||||
# 存储空的媒体信息
|
||||
@@ -230,5 +251,5 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
self.post_message(
|
||||
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
logger.error(f"站点 {domain} RSS链接自动获取失败:{str(e)} - {traceback.format_exc()}")
|
||||
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
||||
|
||||
@@ -4,14 +4,13 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Union, Dict
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
@@ -34,12 +33,13 @@ class TransferChain(ChainBase):
|
||||
文件转移处理链
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
self.downloadhis = DownloadHistoryOper(self._db)
|
||||
self.transferhis = TransferHistoryOper(self._db)
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.transferhis = TransferHistoryOper()
|
||||
self.progress = ProgressHelper()
|
||||
self.mediachain = MediaChain(self._db)
|
||||
self.mediachain = MediaChain()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
def process(self) -> bool:
|
||||
@@ -66,7 +66,8 @@ class TransferChain(ChainBase):
|
||||
mtype = MediaType(downloadhis.type)
|
||||
# 按TMDBID识别
|
||||
mediainfo = self.recognize_media(mtype=mtype,
|
||||
tmdbid=downloadhis.tmdbid)
|
||||
tmdbid=downloadhis.tmdbid,
|
||||
doubanid=downloadhis.doubanid)
|
||||
else:
|
||||
# 非MoviePilot下载的任务,按文件识别
|
||||
mediainfo = None
|
||||
@@ -109,17 +110,6 @@ class TransferChain(ChainBase):
|
||||
logger.warn(f"{path.name} 没有找到可转移的媒体文件")
|
||||
return False, f"{path.name} 没有找到可转移的媒体文件"
|
||||
|
||||
# 汇总错误信息
|
||||
err_msgs: List[str] = []
|
||||
# 汇总季集清单
|
||||
season_episodes: Dict[Tuple, List[int]] = {}
|
||||
# 汇总元数据
|
||||
metas: Dict[Tuple, MetaBase] = {}
|
||||
# 汇总媒体信息
|
||||
medias: Dict[Tuple, MediaInfo] = {}
|
||||
# 汇总转移信息
|
||||
transfers: Dict[Tuple, TransferInfo] = {}
|
||||
|
||||
# 有集自定义格式
|
||||
formaterHandler = FormatParser(eformat=epformat.format,
|
||||
details=epformat.detail,
|
||||
@@ -128,17 +118,24 @@ class TransferChain(ChainBase):
|
||||
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.FileTransfer)
|
||||
# 总数
|
||||
# 目录所有文件清单
|
||||
transfer_files = SystemUtils.list_files(directory=path,
|
||||
extensions=settings.RMT_MEDIAEXT,
|
||||
min_filesize=min_filesize)
|
||||
if formaterHandler:
|
||||
# 有集自定义格式,过滤文件
|
||||
transfer_files = [f for f in transfer_files if formaterHandler.match(f.name)]
|
||||
# 总数
|
||||
|
||||
# 汇总错误信息
|
||||
err_msgs: List[str] = []
|
||||
# 总文件数
|
||||
total_num = len(transfer_files)
|
||||
# 已处理数量
|
||||
processed_num = 0
|
||||
# 失败数量
|
||||
fail_num = 0
|
||||
# 跳过数量
|
||||
skip_num = 0
|
||||
self.progress.update(value=0,
|
||||
text=f"开始转移 {path},共 {total_num} 个文件 ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
@@ -148,6 +145,15 @@ class TransferChain(ChainBase):
|
||||
|
||||
# 处理所有待转移目录或文件,默认一个转移路径或文件只有一个媒体信息
|
||||
for trans_path in trans_paths:
|
||||
# 汇总季集清单
|
||||
season_episodes: Dict[Tuple, List[int]] = {}
|
||||
# 汇总元数据
|
||||
metas: Dict[Tuple, MetaBase] = {}
|
||||
# 汇总媒体信息
|
||||
medias: Dict[Tuple, MediaInfo] = {}
|
||||
# 汇总转移信息
|
||||
transfers: Dict[Tuple, TransferInfo] = {}
|
||||
|
||||
# 如果是目录且不是⼀蓝光原盘,获取所有文件并转移
|
||||
if (not trans_path.is_file()
|
||||
and not SystemUtils.is_bluray_dir(trans_path)):
|
||||
@@ -164,7 +170,6 @@ class TransferChain(ChainBase):
|
||||
|
||||
# 转移所有文件
|
||||
for file_path in file_paths:
|
||||
|
||||
# 回收站及隐藏的文件不处理
|
||||
file_path_str = str(file_path)
|
||||
if file_path_str.find('/@Recycle/') != -1 \
|
||||
@@ -172,6 +177,9 @@ class TransferChain(ChainBase):
|
||||
or file_path_str.find('/.') != -1 \
|
||||
or file_path_str.find('/@eaDir') != -1:
|
||||
logger.debug(f"{file_path_str} 是回收站或隐藏的文件")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 整理屏蔽词不处理
|
||||
@@ -186,6 +194,9 @@ class TransferChain(ChainBase):
|
||||
break
|
||||
if is_blocked:
|
||||
err_msgs.append(f"{file_path.name} 命中整理屏蔽词")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 转移成功的不再处理
|
||||
@@ -193,6 +204,9 @@ class TransferChain(ChainBase):
|
||||
transferd = self.transferhis.get_by_src(file_path_str)
|
||||
if transferd and transferd.status:
|
||||
logger.info(f"{file_path} 已成功转移过,如需重新处理,请删除历史记录。")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 更新进度
|
||||
@@ -201,12 +215,8 @@ class TransferChain(ChainBase):
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
if not meta:
|
||||
# 上级目录元数据
|
||||
dir_meta = MetaInfo(title=file_path.parent.name)
|
||||
# 文件元数据,不包含后缀
|
||||
file_meta = MetaInfo(title=file_path.stem)
|
||||
# 合并元数据
|
||||
file_meta.merge(dir_meta)
|
||||
# 文件元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
else:
|
||||
file_meta = meta
|
||||
|
||||
@@ -217,12 +227,15 @@ class TransferChain(ChainBase):
|
||||
if not file_meta:
|
||||
logger.error(f"{file_path} 无法识别有效信息")
|
||||
err_msgs.append(f"{file_path} 无法识别有效信息")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 自定义识别
|
||||
if formaterHandler:
|
||||
# 开始集、结束集、PART
|
||||
begin_ep, end_ep, part = formaterHandler.split_episode(file_path.stem)
|
||||
begin_ep, end_ep, part = formaterHandler.split_episode(file_path.name)
|
||||
if begin_ep is not None:
|
||||
file_meta.begin_episode = begin_ep
|
||||
file_meta.part = part
|
||||
@@ -231,7 +244,7 @@ class TransferChain(ChainBase):
|
||||
|
||||
if not mediainfo:
|
||||
# 识别媒体信息
|
||||
file_mediainfo = self.recognize_media(meta=file_meta)
|
||||
file_mediainfo = self.mediachain.recognize_by_meta(file_meta)
|
||||
else:
|
||||
file_mediainfo = mediainfo
|
||||
|
||||
@@ -240,15 +253,18 @@ class TransferChain(ChainBase):
|
||||
# 新增转移失败历史记录
|
||||
his = self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
mode=transfer_type,
|
||||
meta=file_meta,
|
||||
download_hash=download_hash
|
||||
)
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
|
||||
f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
|
||||
title=f"{file_path.name} 未识别到媒体信息,无法入库!",
|
||||
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
|
||||
))
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||
@@ -260,31 +276,14 @@ class TransferChain(ChainBase):
|
||||
|
||||
logger.info(f"{file_path.name} 识别为:{file_mediainfo.type.value} {file_mediainfo.title_year}")
|
||||
|
||||
# 电视剧没有集无法转移
|
||||
if file_mediainfo.type == MediaType.TV and not file_meta.episode:
|
||||
# 转移失败
|
||||
logger.warn(f"{file_path.name} 入库失败:未识别到集数")
|
||||
err_msgs.append(f"{file_path.name} 未识别到集数")
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo
|
||||
)
|
||||
# 发送消息
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{file_path.name} 入库失败!",
|
||||
text=f"原因:未识别到集数",
|
||||
image=file_mediainfo.get_message_image()
|
||||
))
|
||||
continue
|
||||
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=file_mediainfo)
|
||||
# 获取集数据
|
||||
if file_mediainfo.type == MediaType.TV:
|
||||
episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=file_mediainfo.tmdb_id,
|
||||
season=file_meta.begin_season or 1)
|
||||
else:
|
||||
episodes_info = None
|
||||
|
||||
# 获取下载hash
|
||||
if not download_hash:
|
||||
download_file = self.downloadhis.get_file_by_fullpath(file_path_str)
|
||||
if download_file:
|
||||
@@ -295,18 +294,19 @@ class TransferChain(ChainBase):
|
||||
mediainfo=file_mediainfo,
|
||||
path=file_path,
|
||||
transfer_type=transfer_type,
|
||||
target=target)
|
||||
target=target,
|
||||
episodes_info=episodes_info)
|
||||
if not transferinfo:
|
||||
logger.error("文件转移模块运行失败")
|
||||
return False, "文件转移模块运行失败"
|
||||
if not transferinfo.target_path:
|
||||
if not transferinfo.success:
|
||||
# 转移失败
|
||||
logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}")
|
||||
err_msgs.append(f"{file_path.name} {transferinfo.message}")
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo,
|
||||
@@ -319,6 +319,9 @@ class TransferChain(ChainBase):
|
||||
text=f"原因:{transferinfo.message or '未知'}",
|
||||
image=file_mediainfo.get_message_image()
|
||||
))
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 汇总信息
|
||||
@@ -342,7 +345,7 @@ class TransferChain(ChainBase):
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_success(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo,
|
||||
@@ -350,7 +353,9 @@ class TransferChain(ChainBase):
|
||||
)
|
||||
# 刮削单个文件
|
||||
if settings.SCRAP_METADATA:
|
||||
self.scrape_metadata(path=transferinfo.target_path, mediainfo=file_mediainfo)
|
||||
self.scrape_metadata(path=transferinfo.target_path,
|
||||
mediainfo=file_mediainfo,
|
||||
transfer_type=transfer_type)
|
||||
# 更新进度
|
||||
processed_num += 1
|
||||
self.progress.update(value=processed_num / total_num * 100,
|
||||
@@ -358,8 +363,7 @@ class TransferChain(ChainBase):
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
# 目录或文件转移完成
|
||||
self.progress.update(value=100,
|
||||
text=f"所有文件转移完成,正在执行后续处理 ...",
|
||||
self.progress.update(text=f"{trans_path} 转移完成,正在执行后续处理 ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
# 执行后续处理
|
||||
@@ -369,9 +373,6 @@ class TransferChain(ChainBase):
|
||||
# 媒体目录
|
||||
if transfer_info.target_path.is_file():
|
||||
transfer_info.target_path = transfer_info.target_path.parent
|
||||
# 刷新媒体库,根目录或季目录
|
||||
if settings.REFRESH_MEDIASERVER:
|
||||
self.refresh_mediaserver(mediainfo=media, file_path=transfer_info.target_path)
|
||||
# 发送通知
|
||||
se_str = None
|
||||
if media.type == MediaType.TV:
|
||||
@@ -386,10 +387,16 @@ class TransferChain(ChainBase):
|
||||
'mediainfo': media,
|
||||
'transferinfo': transfer_info
|
||||
})
|
||||
# 结束进度
|
||||
logger.info(f"{path} 转移完成,共 {total_num} 个文件,"
|
||||
f"成功 {total_num - len(err_msgs)} 个,失败 {len(err_msgs)} 个")
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
|
||||
# 结束进度
|
||||
logger.info(f"{path} 转移完成,共 {total_num} 个文件,"
|
||||
f"失败 {fail_num} 个,跳过 {skip_num} 个")
|
||||
|
||||
self.progress.update(value=100,
|
||||
text=f"{path} 转移完成,共 {total_num} 个文件,"
|
||||
f"失败 {fail_num} 个,跳过 {skip_num} 个",
|
||||
key=ProgressKey.FileTransfer)
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
|
||||
return True, "\n".join(err_msgs)
|
||||
|
||||
@@ -440,7 +447,7 @@ class TransferChain(ChainBase):
|
||||
|
||||
def args_error():
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="请输入正确的命令格式:/redo [id] [tmdbid]|[类型],"
|
||||
title="请输入正确的命令格式:/redo [id] [tmdbid/豆瓣id]|[类型],"
|
||||
"[id]历史记录编号", userid=userid))
|
||||
|
||||
if not arg_str:
|
||||
@@ -455,48 +462,74 @@ class TransferChain(ChainBase):
|
||||
if not logid.isdigit():
|
||||
args_error()
|
||||
return
|
||||
# TMDB ID
|
||||
tmdb_strs = arg_strs[1].split('|')
|
||||
tmdbid = tmdb_strs[0]
|
||||
# TMDBID/豆瓣ID
|
||||
id_strs = arg_strs[1].split('|')
|
||||
media_id = id_strs[0]
|
||||
if not logid.isdigit():
|
||||
args_error()
|
||||
return
|
||||
# 类型
|
||||
type_str = tmdb_strs[1] if len(tmdb_strs) > 1 else None
|
||||
type_str = id_strs[1] if len(id_strs) > 1 else None
|
||||
if not type_str or type_str not in [MediaType.MOVIE.value, MediaType.TV.value]:
|
||||
args_error()
|
||||
return
|
||||
state, errmsg = self.re_transfer(logid=int(logid),
|
||||
mtype=MediaType(type_str), tmdbid=int(tmdbid))
|
||||
mtype=MediaType(type_str),
|
||||
mediaid=media_id)
|
||||
if not state:
|
||||
self.post_message(Notification(channel=channel, title="手动整理失败",
|
||||
text=errmsg, userid=userid))
|
||||
return
|
||||
|
||||
def re_transfer(self, logid: int, mtype: MediaType, tmdbid: int) -> Tuple[bool, str]:
|
||||
@staticmethod
|
||||
def get_root_path(path: str, type_name: str, category: str) -> Optional[Path]:
|
||||
"""
|
||||
根据历史记录,重新识别转移,只处理对应的src目录
|
||||
计算媒体库目录的根路径
|
||||
"""
|
||||
if not path or path == "None":
|
||||
return None
|
||||
index = -2
|
||||
if type_name != '电影':
|
||||
index = -3
|
||||
if category:
|
||||
index -= 1
|
||||
if '/' in path:
|
||||
retpath = '/'.join(path.split('/')[:index])
|
||||
else:
|
||||
retpath = '\\'.join(path.split('\\')[:index])
|
||||
return Path(retpath)
|
||||
|
||||
def re_transfer(self, logid: int, mtype: MediaType = None,
|
||||
mediaid: str = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
根据历史记录,重新识别转移,只支持简单条件
|
||||
:param logid: 历史记录ID
|
||||
:param mtype: 媒体类型
|
||||
:param tmdbid: TMDB ID
|
||||
:param mediaid: TMDB ID/豆瓣ID
|
||||
"""
|
||||
# 查询历史记录
|
||||
history: TransferHistory = self.transferhis.get(logid)
|
||||
if not history:
|
||||
logger.error(f"历史记录不存在,ID:{logid}")
|
||||
return False, "历史记录不存在"
|
||||
# 没有下载记录,按源目录路径重新转移
|
||||
# 按源目录路径重新转移
|
||||
src_path = Path(history.src)
|
||||
if not src_path.exists():
|
||||
return False, f"源目录不存在:{src_path}"
|
||||
dest_path = self.get_root_path(path=history.dest, type_name=history.type, category=history.category)
|
||||
# 查询媒体信息
|
||||
mediainfo = self.recognize_media(mtype=mtype, tmdbid=tmdbid)
|
||||
if mtype and mediaid:
|
||||
mediainfo = self.recognize_media(mtype=mtype, tmdbid=int(mediaid) if str(mediaid).isdigit() else None,
|
||||
doubanid=mediaid)
|
||||
if mediainfo:
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
else:
|
||||
mediainfo = self.mediachain.recognize_by_path(str(src_path))
|
||||
if not mediainfo:
|
||||
return False, f"未识别到媒体信息,类型:{mtype.value},tmdbid:{tmdbid}"
|
||||
return False, f"未识别到媒体信息,类型:{mtype.value},id:{mediaid}"
|
||||
# 重新执行转移
|
||||
logger.info(f"{mtype.value} {tmdbid} 识别为:{mediainfo.title_year}")
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}")
|
||||
|
||||
# 删除旧的已整理文件
|
||||
if history.dest:
|
||||
@@ -506,6 +539,7 @@ class TransferChain(ChainBase):
|
||||
state, errmsg = self.do_transfer(path=src_path,
|
||||
mediainfo=mediainfo,
|
||||
download_hash=history.download_hash,
|
||||
target=dest_path,
|
||||
force=True)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
@@ -519,9 +553,10 @@ class TransferChain(ChainBase):
|
||||
season: int = None,
|
||||
transfer_type: str = None,
|
||||
epformat: EpisodeFormat = None,
|
||||
min_filesize: int = 0) -> Tuple[bool, Union[str, list]]:
|
||||
min_filesize: int = 0,
|
||||
force: bool = False) -> Tuple[bool, Union[str, list]]:
|
||||
"""
|
||||
手动转移
|
||||
手动转移,支持复杂条件,带进度显示
|
||||
:param in_path: 源文件路径
|
||||
:param target: 目标路径
|
||||
:param tmdbid: TMDB ID
|
||||
@@ -530,6 +565,7 @@ class TransferChain(ChainBase):
|
||||
:param transfer_type: 转移类型
|
||||
:param epformat: 剧集格式
|
||||
:param min_filesize: 最小文件大小(MB)
|
||||
:param force: 是否强制转移
|
||||
"""
|
||||
logger.info(f"手动转移:{in_path} ...")
|
||||
|
||||
@@ -549,9 +585,11 @@ class TransferChain(ChainBase):
|
||||
path=in_path,
|
||||
mediainfo=mediainfo,
|
||||
target=target,
|
||||
transfer_type=transfer_type,
|
||||
season=season,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize
|
||||
min_filesize=min_filesize,
|
||||
force=force
|
||||
)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
@@ -566,7 +604,8 @@ class TransferChain(ChainBase):
|
||||
transfer_type=transfer_type,
|
||||
season=season,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize)
|
||||
min_filesize=min_filesize,
|
||||
force=force)
|
||||
return state, errmsg
|
||||
|
||||
def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||
@@ -593,33 +632,56 @@ class TransferChain(ChainBase):
|
||||
title=msg_title, text=msg_str, image=mediainfo.get_message_image()))
|
||||
|
||||
@staticmethod
|
||||
def delete_files(path: Path):
|
||||
def delete_files(path: Path) -> Tuple[bool, str]:
|
||||
"""
|
||||
删除转移后的文件以及空目录
|
||||
:param path: 文件路径
|
||||
:return: 成功标识,错误信息
|
||||
"""
|
||||
logger.info(f"开始删除文件以及空目录:{path} ...")
|
||||
if not path.exists():
|
||||
return
|
||||
return True, f"文件或目录不存在:{path}"
|
||||
if path.is_file():
|
||||
# 删除文件
|
||||
path.unlink()
|
||||
# 删除文件、nfo、jpg等同名文件
|
||||
pattern = path.stem.replace('[', '?').replace(']', '?')
|
||||
files = path.parent.glob(f"{pattern}.*")
|
||||
for file in files:
|
||||
Path(file).unlink()
|
||||
logger.warn(f"文件 {path} 已删除")
|
||||
# 需要删除父目录
|
||||
elif str(path.parent) == str(path.root):
|
||||
# 根目录,不删除
|
||||
logger.warn(f"根目录 {path} 不能删除!")
|
||||
return
|
||||
return False, f"根目录 {path} 不能删除!"
|
||||
else:
|
||||
# 非根目录,才删除目录
|
||||
shutil.rmtree(path)
|
||||
# 删除目录
|
||||
logger.warn(f"目录 {path} 已删除")
|
||||
# 需要删除父目录
|
||||
# 判断父目录是否为空, 为空则删除
|
||||
for parent_path in path.parents:
|
||||
if str(parent_path.parent) != str(path.root):
|
||||
# 父目录非根目录,才删除父目录
|
||||
files = SystemUtils.list_files(parent_path, settings.RMT_MEDIAEXT)
|
||||
if not files:
|
||||
shutil.rmtree(parent_path)
|
||||
logger.warn(f"目录 {parent_path} 已删除")
|
||||
|
||||
# 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级
|
||||
if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT):
|
||||
# 媒体库二级分类根路径
|
||||
library_root_names = [
|
||||
settings.LIBRARY_MOVIE_NAME or '电影',
|
||||
settings.LIBRARY_TV_NAME or '电视剧',
|
||||
settings.LIBRARY_ANIME_NAME or '动漫',
|
||||
]
|
||||
|
||||
# 判断父目录是否为空, 为空则删除
|
||||
for parent_path in path.parents:
|
||||
# 遍历父目录到媒体库二级分类根路径
|
||||
if str(parent_path.name) in library_root_names:
|
||||
break
|
||||
if str(parent_path.parent) != str(path.root):
|
||||
# 父目录非根目录,才删除父目录
|
||||
if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT):
|
||||
# 当前路径下没有媒体文件则删除
|
||||
try:
|
||||
shutil.rmtree(parent_path)
|
||||
except Exception as e:
|
||||
logger.error(f"删除目录 {parent_path} 失败:{str(e)}")
|
||||
return False, f"删除目录 {parent_path} 失败:{str(e)}"
|
||||
logger.warn(f"目录 {parent_path} 已删除")
|
||||
return True, ""
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import EventType, MediaImageType, MediaType, NotificationType
|
||||
from app.utils.http import WebUtils
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class WebhookChain(ChainBase):
|
||||
class WebhookChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
Webhook处理链
|
||||
"""
|
||||
|
||||
def message(self, body: Any, form: Any, args: Any) -> None:
|
||||
"""
|
||||
处理Webhook报文并发送消息
|
||||
处理Webhook报文并发送事件
|
||||
"""
|
||||
# 获取主体内容
|
||||
event_info = self.webhook_parser(body=body, form=form, args=args)
|
||||
@@ -22,76 +20,3 @@ class WebhookChain(ChainBase):
|
||||
return
|
||||
# 广播事件
|
||||
self.eventmanager.send_event(EventType.WebhookMessage, event_info)
|
||||
# 拼装消息内容
|
||||
_webhook_actions = {
|
||||
"library.new": "新入库",
|
||||
"system.webhooktest": "测试",
|
||||
"playback.start": "开始播放",
|
||||
"playback.stop": "停止播放",
|
||||
"user.authenticated": "登录成功",
|
||||
"user.authenticationfailed": "登录失败",
|
||||
"media.play": "开始播放",
|
||||
"media.stop": "停止播放",
|
||||
"PlaybackStart": "开始播放",
|
||||
"PlaybackStop": "停止播放",
|
||||
"item.rate": "标记了"
|
||||
}
|
||||
_webhook_images = {
|
||||
"emby": "https://emby.media/notificationicon.png",
|
||||
"plex": "https://www.plex.tv/wp-content/uploads/2022/04/new-logo-process-lines-gray.png",
|
||||
"jellyfin": "https://play-lh.googleusercontent.com/SCsUK3hCCRqkJbmLDctNYCfehLxsS4ggD1ZPHIFrrAN1Tn9yhjmGMPep2D9lMaaa9eQi"
|
||||
}
|
||||
|
||||
if not _webhook_actions.get(event_info.event):
|
||||
return
|
||||
|
||||
# 消息标题
|
||||
if event_info.item_type in ["TV", "SHOW"]:
|
||||
message_title = f"{_webhook_actions.get(event_info.event)}剧集 {event_info.item_name}"
|
||||
elif event_info.item_type == "MOV":
|
||||
message_title = f"{_webhook_actions.get(event_info.event)}电影 {event_info.item_name}"
|
||||
elif event_info.item_type == "AUD":
|
||||
message_title = f"{_webhook_actions.get(event_info.event)}有声书 {event_info.item_name}"
|
||||
else:
|
||||
message_title = f"{_webhook_actions.get(event_info.event)}"
|
||||
|
||||
# 消息内容
|
||||
message_texts = []
|
||||
if event_info.user_name:
|
||||
message_texts.append(f"用户:{event_info.user_name}")
|
||||
if event_info.device_name:
|
||||
message_texts.append(f"设备:{event_info.client} {event_info.device_name}")
|
||||
if event_info.ip:
|
||||
message_texts.append(f"IP地址:{event_info.ip} {WebUtils.get_location(event_info.ip)}")
|
||||
if event_info.percentage:
|
||||
percentage = round(float(event_info.percentage), 2)
|
||||
message_texts.append(f"进度:{percentage}%")
|
||||
if event_info.overview:
|
||||
message_texts.append(f"剧情:{event_info.overview}")
|
||||
message_texts.append(f"时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}")
|
||||
|
||||
# 消息内容
|
||||
message_content = "\n".join(message_texts)
|
||||
|
||||
# 消息图片
|
||||
image_url = event_info.image_url
|
||||
# 查询剧集图片
|
||||
if (event_info.tmdb_id
|
||||
and event_info.season_id
|
||||
and event_info.episode_id):
|
||||
specific_image = self.obtain_specific_image(
|
||||
mediaid=event_info.tmdb_id,
|
||||
mtype=MediaType.TV,
|
||||
image_type=MediaImageType.Backdrop,
|
||||
season=event_info.season_id,
|
||||
episode=event_info.episode_id
|
||||
)
|
||||
if specific_image:
|
||||
image_url = specific_image
|
||||
# 使用默认图片
|
||||
if not image_url:
|
||||
image_url = _webhook_images.get(event_info.channel)
|
||||
|
||||
# 发送消息
|
||||
self.post_message(Notification(mtype=NotificationType.MediaServer,
|
||||
title=message_title, text=message_content, image=image_url))
|
||||
|
||||
205
app/command.py
205
app/command.py
@@ -1,11 +1,11 @@
|
||||
import importlib
|
||||
import threading
|
||||
import traceback
|
||||
from threading import Thread, Event
|
||||
from typing import Any, Union
|
||||
from threading import Thread
|
||||
from typing import Any, Union, Dict
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.cookiecloud import CookieCloudChain
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.system import SystemChain
|
||||
@@ -13,12 +13,13 @@ from app.chain.transfer import TransferChain
|
||||
from app.core.event import Event as ManagerEvent
|
||||
from app.core.event import eventmanager, EventManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db import SessionFactory
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class CommandChian(ChainBase):
|
||||
@@ -38,103 +39,116 @@ class Command(metaclass=Singleton):
|
||||
_commands = {}
|
||||
|
||||
# 退出事件
|
||||
_event = Event()
|
||||
_event = threading.Event()
|
||||
|
||||
def __init__(self):
|
||||
# 数据库连接
|
||||
self._db = SessionFactory()
|
||||
# 事件管理器
|
||||
self.eventmanager = EventManager()
|
||||
# 插件管理器
|
||||
self.pluginmanager = PluginManager()
|
||||
# 处理链
|
||||
self.chain = CommandChian(self._db)
|
||||
self.chain = CommandChian()
|
||||
# 定时服务管理
|
||||
self.scheduler = Scheduler()
|
||||
# 线程管理器
|
||||
self.threader = ThreadHelper()
|
||||
# 内置命令
|
||||
self._commands = {
|
||||
"/cookiecloud": {
|
||||
"func": CookieCloudChain(self._db).remote_sync,
|
||||
"id": "cookiecloud",
|
||||
"type": "scheduler",
|
||||
"description": "同步站点",
|
||||
"category": "站点",
|
||||
"data": {}
|
||||
"category": "站点"
|
||||
},
|
||||
"/sites": {
|
||||
"func": SiteChain(self._db).remote_list,
|
||||
"func": SiteChain().remote_list,
|
||||
"description": "查询站点",
|
||||
"category": "站点",
|
||||
"data": {}
|
||||
},
|
||||
"/site_cookie": {
|
||||
"func": SiteChain(self._db).remote_cookie,
|
||||
"func": SiteChain().remote_cookie,
|
||||
"description": "更新站点Cookie",
|
||||
"data": {}
|
||||
},
|
||||
"/site_enable": {
|
||||
"func": SiteChain(self._db).remote_enable,
|
||||
"func": SiteChain().remote_enable,
|
||||
"description": "启用站点",
|
||||
"data": {}
|
||||
},
|
||||
"/site_disable": {
|
||||
"func": SiteChain(self._db).remote_disable,
|
||||
"func": SiteChain().remote_disable,
|
||||
"description": "禁用站点",
|
||||
"data": {}
|
||||
},
|
||||
"/mediaserver_sync": {
|
||||
"func": MediaServerChain(self._db).remote_sync,
|
||||
"id": "mediaserver_sync",
|
||||
"type": "scheduler",
|
||||
"description": "同步媒体服务器",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
"category": "管理"
|
||||
},
|
||||
"/subscribes": {
|
||||
"func": SubscribeChain(self._db).remote_list,
|
||||
"func": SubscribeChain().remote_list,
|
||||
"description": "查询订阅",
|
||||
"category": "订阅",
|
||||
"data": {}
|
||||
},
|
||||
"/subscribe_refresh": {
|
||||
"func": SubscribeChain(self._db).remote_refresh,
|
||||
"id": "subscribe_refresh",
|
||||
"type": "scheduler",
|
||||
"description": "刷新订阅",
|
||||
"category": "订阅",
|
||||
"data": {}
|
||||
"category": "订阅"
|
||||
},
|
||||
"/subscribe_search": {
|
||||
"func": SubscribeChain(self._db).remote_search,
|
||||
"id": "subscribe_search",
|
||||
"type": "scheduler",
|
||||
"description": "搜索订阅",
|
||||
"category": "订阅",
|
||||
"data": {}
|
||||
"category": "订阅"
|
||||
},
|
||||
"/subscribe_delete": {
|
||||
"func": SubscribeChain(self._db).remote_delete,
|
||||
"func": SubscribeChain().remote_delete,
|
||||
"description": "删除订阅",
|
||||
"data": {}
|
||||
},
|
||||
"/subscribe_tmdb": {
|
||||
"id": "subscribe_tmdb",
|
||||
"type": "scheduler",
|
||||
"description": "订阅元数据更新"
|
||||
},
|
||||
"/downloading": {
|
||||
"func": DownloadChain(self._db).remote_downloading,
|
||||
"func": DownloadChain().remote_downloading,
|
||||
"description": "正在下载",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
},
|
||||
"/transfer": {
|
||||
"func": TransferChain(self._db).process,
|
||||
"id": "transfer",
|
||||
"type": "scheduler",
|
||||
"description": "下载文件整理",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
"category": "管理"
|
||||
},
|
||||
"/redo": {
|
||||
"func": TransferChain(self._db).remote_transfer,
|
||||
"func": TransferChain().remote_transfer,
|
||||
"description": "手动整理",
|
||||
"data": {}
|
||||
},
|
||||
"/clear_cache": {
|
||||
"func": SystemChain(self._db).remote_clear_cache,
|
||||
"func": SystemChain().remote_clear_cache,
|
||||
"description": "清理缓存",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
},
|
||||
"/restart": {
|
||||
"func": SystemUtils.restart,
|
||||
"func": SystemChain().restart,
|
||||
"description": "重启系统",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
},
|
||||
"/version": {
|
||||
"func": SystemChain().version,
|
||||
"description": "当前版本",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
# 汇总插件命令
|
||||
@@ -156,6 +170,8 @@ class Command(metaclass=Singleton):
|
||||
self._thread = Thread(target=self.__run)
|
||||
# 启动事件处理线程
|
||||
self._thread.start()
|
||||
# 重启msg
|
||||
SystemChain().restart_finish()
|
||||
|
||||
def __run(self):
|
||||
"""
|
||||
@@ -168,21 +184,93 @@ class Command(metaclass=Singleton):
|
||||
for handler in handlers:
|
||||
try:
|
||||
names = handler.__qualname__.split(".")
|
||||
if names[0] == "Command":
|
||||
self.command_event(event)
|
||||
[class_name, method_name] = names
|
||||
if class_name in self.pluginmanager.get_plugin_ids():
|
||||
# 插件事件
|
||||
self.threader.submit(
|
||||
self.pluginmanager.run_plugin_method,
|
||||
class_name, method_name, event
|
||||
)
|
||||
|
||||
else:
|
||||
self.pluginmanager.run_plugin_method(names[0], names[1], event)
|
||||
# 检查全局变量中是否存在
|
||||
if class_name not in globals():
|
||||
# 导入模块,除了插件和Command本身,只有chain能响应事件
|
||||
module = importlib.import_module(
|
||||
f"app.chain.{class_name[:-5].lower()}"
|
||||
)
|
||||
class_obj = getattr(module, class_name)()
|
||||
else:
|
||||
# 通过类名创建类实例
|
||||
class_obj = globals()[class_name]()
|
||||
# 检查类是否存在并调用方法
|
||||
if hasattr(class_obj, method_name):
|
||||
self.threader.submit(
|
||||
getattr(class_obj, method_name),
|
||||
event
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
def __run_command(self, command: Dict[str, any],
|
||||
data_str: str = "",
|
||||
channel: MessageChannel = None, userid: Union[str, int] = None):
|
||||
"""
|
||||
运行定时服务
|
||||
"""
|
||||
if command.get("type") == "scheduler":
|
||||
# 定时服务
|
||||
if userid:
|
||||
self.chain.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
title=f"开始执行 {command.get('description')} ...",
|
||||
userid=userid
|
||||
)
|
||||
)
|
||||
|
||||
# 执行定时任务
|
||||
self.scheduler.start(job_id=command.get("id"))
|
||||
|
||||
if userid:
|
||||
self.chain.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
title=f"{command.get('description')} 执行完成",
|
||||
userid=userid
|
||||
)
|
||||
)
|
||||
else:
|
||||
# 命令
|
||||
cmd_data = command['data'] if command.get('data') else {}
|
||||
args_num = ObjectUtils.arguments(command['func'])
|
||||
if args_num > 0:
|
||||
if cmd_data:
|
||||
# 有内置参数直接使用内置参数
|
||||
data = cmd_data.get("data") or {}
|
||||
data['channel'] = channel
|
||||
data['user'] = userid
|
||||
cmd_data['data'] = data
|
||||
command['func'](**cmd_data)
|
||||
elif args_num == 2:
|
||||
# 没有输入参数,只输入渠道和用户ID
|
||||
command['func'](channel, userid)
|
||||
elif args_num > 2:
|
||||
# 多个输入参数:用户输入、用户ID
|
||||
command['func'](data_str, channel, userid)
|
||||
else:
|
||||
# 没有参数
|
||||
command['func']()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
停止事件处理线程
|
||||
"""
|
||||
self._event.set()
|
||||
self._thread.join()
|
||||
if self._db:
|
||||
self._db.close()
|
||||
try:
|
||||
self._thread.join()
|
||||
except Exception as e:
|
||||
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
def get_commands(self):
|
||||
"""
|
||||
@@ -216,30 +304,21 @@ class Command(metaclass=Singleton):
|
||||
command = self.get(cmd)
|
||||
if command:
|
||||
try:
|
||||
logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...")
|
||||
cmd_data = command['data'] if command.get('data') else {}
|
||||
args_num = ObjectUtils.arguments(command['func'])
|
||||
if args_num > 0:
|
||||
if cmd_data:
|
||||
# 有内置参数直接使用内置参数
|
||||
data = cmd_data.get("data") or {}
|
||||
data['channel'] = channel
|
||||
data['user'] = userid
|
||||
cmd_data['data'] = data
|
||||
command['func'](**cmd_data)
|
||||
elif args_num == 2:
|
||||
# 没有输入参数,只输入渠道和用户ID
|
||||
command['func'](channel, userid)
|
||||
elif args_num > 2:
|
||||
# 多个输入参数:用户输入、用户ID
|
||||
command['func'](data_str, channel, userid)
|
||||
if userid:
|
||||
logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...")
|
||||
else:
|
||||
# 没有参数
|
||||
command['func']()
|
||||
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
|
||||
logger.info(f"开始执行:{command.get('description')} ...")
|
||||
|
||||
# 执行命令
|
||||
self.__run_command(command, data_str=data_str,
|
||||
channel=channel, userid=userid)
|
||||
|
||||
if userid:
|
||||
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
|
||||
else:
|
||||
logger.info(f"{command.get('description')} 执行完成")
|
||||
except Exception as err:
|
||||
logger.error(f"执行命令 {cmd} 出错:{str(err)}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}")
|
||||
|
||||
@staticmethod
|
||||
def send_plugin_event(etype: EventType, data: dict) -> None:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseSettings
|
||||
from pydantic import BaseSettings, validator
|
||||
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -22,28 +25,30 @@ class Settings(BaseSettings):
|
||||
HOST: str = "0.0.0.0"
|
||||
# API监听端口
|
||||
PORT: int = 3001
|
||||
# 前端监听端口
|
||||
NGINX_PORT: int = 3000
|
||||
# 是否调试模式
|
||||
DEBUG: bool = False
|
||||
# 是否开发模式
|
||||
DEV: bool = False
|
||||
# 配置文件目录
|
||||
CONFIG_DIR: str = None
|
||||
CONFIG_DIR: Optional[str] = None
|
||||
# 超级管理员
|
||||
SUPERUSER: str = "admin"
|
||||
# 超级管理员初始密码
|
||||
SUPERUSER_PASSWORD: str = "password"
|
||||
# API密钥,需要更换
|
||||
API_TOKEN: str = "moviepilot"
|
||||
# 登录页面电影海报,tmdb/bing
|
||||
WALLPAPER: str = "tmdb"
|
||||
# 网络代理 IP:PORT
|
||||
PROXY_HOST: str = None
|
||||
# 媒体信息搜索来源
|
||||
SEARCH_SOURCE: str = "themoviedb"
|
||||
PROXY_HOST: Optional[str] = None
|
||||
# 媒体识别来源 themoviedb/douban
|
||||
RECOGNIZE_SOURCE: str = "themoviedb"
|
||||
# 刮削来源 themoviedb/douban
|
||||
SCRAP_SOURCE: str = "themoviedb"
|
||||
# 刮削入库的媒体文件
|
||||
SCRAP_METADATA: bool = True
|
||||
# 新增已入库媒体是否跟随TMDB信息变化
|
||||
SCRAP_FOLLOW_TMDB: bool = True
|
||||
# 刮削来源
|
||||
SCRAP_SOURCE: str = "themoviedb"
|
||||
# TMDB图片地址
|
||||
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
|
||||
# TMDB API地址
|
||||
@@ -52,6 +57,8 @@ class Settings(BaseSettings):
|
||||
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
||||
# TVDB API Key
|
||||
TVDB_API_KEY: str = "6b481081-10aa-440c-99f2-21d17717ee02"
|
||||
# Fanart开关
|
||||
FANART_ENABLE: bool = True
|
||||
# Fanart API Key
|
||||
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
|
||||
# 支持的后缀格式
|
||||
@@ -61,7 +68,9 @@ class Settings(BaseSettings):
|
||||
'.m4v', '.flv', '.m2ts', '.strm',
|
||||
'.tp']
|
||||
# 支持的字幕文件后缀格式
|
||||
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa']
|
||||
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa', '.sup']
|
||||
# 下载器临时文件后缀
|
||||
DOWNLOAD_TMPEXT: list = ['.!qB', '.part']
|
||||
# 支持的音轨文件后缀格式
|
||||
RMT_AUDIO_TRACK_EXT: list = ['.mka']
|
||||
# 索引器
|
||||
@@ -75,27 +84,27 @@ class Settings(BaseSettings):
|
||||
# 用户认证站点
|
||||
AUTH_SITE: str = ""
|
||||
# 交互搜索自动下载用户ID,使用,分割
|
||||
AUTO_DOWNLOAD_USER: str = None
|
||||
# 消息通知渠道 telegram/wechat/slack
|
||||
AUTO_DOWNLOAD_USER: Optional[str] = None
|
||||
# 消息通知渠道 telegram/wechat/slack/synologychat/vocechat,多个通知渠道用,分隔
|
||||
MESSAGER: str = "telegram"
|
||||
# WeChat企业ID
|
||||
WECHAT_CORPID: str = None
|
||||
WECHAT_CORPID: Optional[str] = None
|
||||
# WeChat应用Secret
|
||||
WECHAT_APP_SECRET: str = None
|
||||
WECHAT_APP_SECRET: Optional[str] = None
|
||||
# WeChat应用ID
|
||||
WECHAT_APP_ID: str = None
|
||||
WECHAT_APP_ID: Optional[str] = None
|
||||
# WeChat代理服务器
|
||||
WECHAT_PROXY: str = "https://qyapi.weixin.qq.com"
|
||||
# WeChat Token
|
||||
WECHAT_TOKEN: str = None
|
||||
WECHAT_TOKEN: Optional[str] = None
|
||||
# WeChat EncodingAESKey
|
||||
WECHAT_ENCODING_AESKEY: str = None
|
||||
WECHAT_ENCODING_AESKEY: Optional[str] = None
|
||||
# WeChat 管理员
|
||||
WECHAT_ADMINS: str = None
|
||||
WECHAT_ADMINS: Optional[str] = None
|
||||
# Telegram Bot Token
|
||||
TELEGRAM_TOKEN: str = None
|
||||
TELEGRAM_TOKEN: Optional[str] = None
|
||||
# Telegram Chat ID
|
||||
TELEGRAM_CHAT_ID: str = None
|
||||
TELEGRAM_CHAT_ID: Optional[str] = None
|
||||
# Telegram 用户ID,使用,分隔
|
||||
TELEGRAM_USERS: str = ""
|
||||
# Telegram 管理员ID,使用,分隔
|
||||
@@ -106,78 +115,98 @@ class Settings(BaseSettings):
|
||||
SLACK_APP_TOKEN: str = ""
|
||||
# Slack 频道名称
|
||||
SLACK_CHANNEL: str = ""
|
||||
# 下载器 qbittorrent/transmission
|
||||
# SynologyChat Webhook
|
||||
SYNOLOGYCHAT_WEBHOOK: str = ""
|
||||
# SynologyChat Token
|
||||
SYNOLOGYCHAT_TOKEN: str = ""
|
||||
# VoceChat地址
|
||||
VOCECHAT_HOST: str = ""
|
||||
# VoceChat ApiKey
|
||||
VOCECHAT_API_KEY: str = ""
|
||||
# VoceChat 频道ID
|
||||
VOCECHAT_CHANNEL_ID: str = ""
|
||||
# 下载器 qbittorrent/transmission,启用多个下载器时使用,分隔,只有第一个会被默认使用
|
||||
DOWNLOADER: str = "qbittorrent"
|
||||
# 下载器监控开关
|
||||
DOWNLOADER_MONITOR: bool = True
|
||||
# Qbittorrent地址,IP:PORT
|
||||
QB_HOST: str = None
|
||||
QB_HOST: Optional[str] = None
|
||||
# Qbittorrent用户名
|
||||
QB_USER: str = None
|
||||
QB_USER: Optional[str] = None
|
||||
# Qbittorrent密码
|
||||
QB_PASSWORD: str = None
|
||||
QB_PASSWORD: Optional[str] = None
|
||||
# Qbittorrent分类自动管理
|
||||
QB_CATEGORY: bool = False
|
||||
# Qbittorrent按顺序下载
|
||||
QB_SEQUENTIAL: bool = True
|
||||
# Qbittorrent忽略队列限制,强制继续
|
||||
QB_FORCE_RESUME: bool = False
|
||||
# Transmission地址,IP:PORT
|
||||
TR_HOST: str = None
|
||||
TR_HOST: Optional[str] = None
|
||||
# Transmission用户名
|
||||
TR_USER: str = None
|
||||
TR_USER: Optional[str] = None
|
||||
# Transmission密码
|
||||
TR_PASSWORD: str = None
|
||||
TR_PASSWORD: Optional[str] = None
|
||||
# 种子标签
|
||||
TORRENT_TAG: str = "MOVIEPILOT"
|
||||
# 下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_PATH: str = "/downloads"
|
||||
DOWNLOAD_PATH: Optional[str] = None
|
||||
# 电影下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_MOVIE_PATH: str = None
|
||||
DOWNLOAD_MOVIE_PATH: Optional[str] = None
|
||||
# 电视剧下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_TV_PATH: str = None
|
||||
DOWNLOAD_TV_PATH: Optional[str] = None
|
||||
# 动漫下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_ANIME_PATH: str = None
|
||||
DOWNLOAD_ANIME_PATH: Optional[str] = None
|
||||
# 下载目录二级分类
|
||||
DOWNLOAD_CATEGORY: bool = False
|
||||
# 下载站点字幕
|
||||
DOWNLOAD_SUBTITLE: bool = True
|
||||
# 媒体服务器 emby/jellyfin/plex
|
||||
# 媒体服务器 emby/jellyfin/plex,多个媒体服务器,分割
|
||||
MEDIASERVER: str = "emby"
|
||||
# 入库刷新媒体库
|
||||
REFRESH_MEDIASERVER: bool = True
|
||||
# 媒体服务器同步间隔(小时)
|
||||
MEDIASERVER_SYNC_INTERVAL: int = 6
|
||||
MEDIASERVER_SYNC_INTERVAL: Optional[int] = 6
|
||||
# 媒体服务器同步黑名单,多个媒体库名称,分割
|
||||
MEDIASERVER_SYNC_BLACKLIST: Optional[str] = None
|
||||
# EMBY服务器地址,IP:PORT
|
||||
EMBY_HOST: str = None
|
||||
EMBY_HOST: Optional[str] = None
|
||||
# EMBY外网地址,http(s)://DOMAIN:PORT,未设置时使用EMBY_HOST
|
||||
EMBY_PLAY_HOST: Optional[str] = None
|
||||
# EMBY Api Key
|
||||
EMBY_API_KEY: str = None
|
||||
EMBY_API_KEY: Optional[str] = None
|
||||
# Jellyfin服务器地址,IP:PORT
|
||||
JELLYFIN_HOST: str = None
|
||||
JELLYFIN_HOST: Optional[str] = None
|
||||
# Jellyfin外网地址,http(s)://DOMAIN:PORT,未设置时使用JELLYFIN_HOST
|
||||
JELLYFIN_PLAY_HOST: Optional[str] = None
|
||||
# Jellyfin Api Key
|
||||
JELLYFIN_API_KEY: str = None
|
||||
JELLYFIN_API_KEY: Optional[str] = None
|
||||
# Plex服务器地址,IP:PORT
|
||||
PLEX_HOST: str = None
|
||||
PLEX_HOST: Optional[str] = None
|
||||
# Plex外网地址,http(s)://DOMAIN:PORT,未设置时使用PLEX_HOST
|
||||
PLEX_PLAY_HOST: Optional[str] = None
|
||||
# Plex Token
|
||||
PLEX_TOKEN: str = None
|
||||
PLEX_TOKEN: Optional[str] = None
|
||||
# 转移方式 link/copy/move/softlink
|
||||
TRANSFER_TYPE: str = "copy"
|
||||
# CookieCloud服务器地址
|
||||
COOKIECLOUD_HOST: str = "https://movie-pilot.org/cookiecloud"
|
||||
# CookieCloud用户KEY
|
||||
COOKIECLOUD_KEY: str = None
|
||||
COOKIECLOUD_KEY: Optional[str] = None
|
||||
# CookieCloud端对端加密密码
|
||||
COOKIECLOUD_PASSWORD: str = None
|
||||
COOKIECLOUD_PASSWORD: Optional[str] = None
|
||||
# CookieCloud同步间隔(分钟)
|
||||
COOKIECLOUD_INTERVAL: int = 60 * 24
|
||||
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
||||
# OCR服务器地址
|
||||
OCR_HOST: str = "https://movie-pilot.org"
|
||||
# CookieCloud对应的浏览器UA
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
||||
# 媒体库目录,多个目录使用,分隔
|
||||
LIBRARY_PATH: str = None
|
||||
# 电影媒体库目录名,默认"电影"
|
||||
LIBRARY_MOVIE_NAME: str = None
|
||||
# 电视剧媒体库目录名,默认"电视剧"
|
||||
LIBRARY_TV_NAME: str = None
|
||||
# 动漫媒体库目录名,默认"电视剧/动漫"
|
||||
LIBRARY_ANIME_NAME: str = None
|
||||
LIBRARY_PATH: Optional[str] = None
|
||||
# 电影媒体库目录名
|
||||
LIBRARY_MOVIE_NAME: str = "电影"
|
||||
# 电视剧媒体库目录名
|
||||
LIBRARY_TV_NAME: str = "电视剧"
|
||||
# 动漫媒体库目录名,不设置时使用电视剧目录
|
||||
LIBRARY_ANIME_NAME: Optional[str] = None
|
||||
# 二级分类
|
||||
LIBRARY_CATEGORY: bool = True
|
||||
# 电视剧动漫的分类genre_ids
|
||||
@@ -191,8 +220,31 @@ class Settings(BaseSettings):
|
||||
"/Season {{season}}" \
|
||||
"/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}" \
|
||||
"{{fileExt}}"
|
||||
# 转移时覆盖模式
|
||||
OVERWRITE_MODE: str = "size"
|
||||
# 大内存模式
|
||||
BIG_MEMORY_MODE: bool = False
|
||||
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
||||
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
# Github token,提高请求api限流阈值 ghp_****
|
||||
GITHUB_TOKEN: Optional[str] = None
|
||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||
AUTO_UPDATE_RESOURCE: bool = True
|
||||
# 元数据识别缓存过期时间(小时)
|
||||
META_CACHE_EXPIRE: int = 0
|
||||
|
||||
@validator("SUBSCRIBE_RSS_INTERVAL",
|
||||
"COOKIECLOUD_INTERVAL",
|
||||
"MEDIASERVER_SYNC_INTERVAL",
|
||||
"META_CACHE_EXPIRE",
|
||||
pre=True, always=True)
|
||||
def convert_int(cls, value):
|
||||
if not value:
|
||||
return 0
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError(f"{value} 格式错误,不是有效数字!")
|
||||
|
||||
@property
|
||||
def INNER_CONFIG_PATH(self):
|
||||
@@ -202,7 +254,11 @@ class Settings(BaseSettings):
|
||||
def CONFIG_PATH(self):
|
||||
if self.CONFIG_DIR:
|
||||
return Path(self.CONFIG_DIR)
|
||||
return self.INNER_CONFIG_PATH
|
||||
elif SystemUtils.is_docker():
|
||||
return Path("/config")
|
||||
elif SystemUtils.is_frozen():
|
||||
return Path(sys.executable).parent / "config"
|
||||
return self.ROOT_PATH / "config"
|
||||
|
||||
@property
|
||||
def TEMP_PATH(self):
|
||||
@@ -229,7 +285,7 @@ class Settings(BaseSettings):
|
||||
"torrents": 100,
|
||||
"douban": 512,
|
||||
"fanart": 512,
|
||||
"meta": 15 * 24 * 3600
|
||||
"meta": (self.META_CACHE_EXPIRE or 168) * 3600
|
||||
}
|
||||
return {
|
||||
"tmdb": 256,
|
||||
@@ -237,7 +293,7 @@ class Settings(BaseSettings):
|
||||
"torrents": 50,
|
||||
"douban": 256,
|
||||
"fanart": 128,
|
||||
"meta": 7 * 24 * 3600
|
||||
"meta": (self.META_CACHE_EXPIRE or 72) * 3600
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -260,13 +316,81 @@ class Settings(BaseSettings):
|
||||
def LIBRARY_PATHS(self) -> List[Path]:
|
||||
if self.LIBRARY_PATH:
|
||||
return [Path(path) for path in self.LIBRARY_PATH.split(",")]
|
||||
return []
|
||||
return [self.CONFIG_PATH / "library"]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@property
|
||||
def SAVE_PATH(self) -> Path:
|
||||
"""
|
||||
获取下载保存目录
|
||||
"""
|
||||
if self.DOWNLOAD_PATH:
|
||||
return Path(self.DOWNLOAD_PATH)
|
||||
return self.CONFIG_PATH / "downloads"
|
||||
|
||||
@property
|
||||
def SAVE_MOVIE_PATH(self) -> Path:
|
||||
"""
|
||||
获取电影下载保存目录
|
||||
"""
|
||||
if self.DOWNLOAD_MOVIE_PATH:
|
||||
return Path(self.DOWNLOAD_MOVIE_PATH)
|
||||
return self.SAVE_PATH
|
||||
|
||||
@property
|
||||
def SAVE_TV_PATH(self) -> Path:
|
||||
"""
|
||||
获取电视剧下载保存目录
|
||||
"""
|
||||
if self.DOWNLOAD_TV_PATH:
|
||||
return Path(self.DOWNLOAD_TV_PATH)
|
||||
return self.SAVE_PATH
|
||||
|
||||
@property
|
||||
def SAVE_ANIME_PATH(self) -> Path:
|
||||
"""
|
||||
获取动漫下载保存目录
|
||||
"""
|
||||
if self.DOWNLOAD_ANIME_PATH:
|
||||
return Path(self.DOWNLOAD_ANIME_PATH)
|
||||
return self.SAVE_TV_PATH
|
||||
|
||||
@property
|
||||
def GITHUB_HEADERS(self):
|
||||
"""
|
||||
Github请求头
|
||||
"""
|
||||
if self.GITHUB_TOKEN:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.GITHUB_TOKEN}"
|
||||
}
|
||||
return {}
|
||||
|
||||
@property
|
||||
def DEFAULT_DOWNLOADER(self):
|
||||
"""
|
||||
默认下载器
|
||||
"""
|
||||
if not self.DOWNLOADER:
|
||||
return None
|
||||
return self.DOWNLOADER.split(",")[0]
|
||||
|
||||
@property
|
||||
def DOWNLOADERS(self):
|
||||
"""
|
||||
下载器列表
|
||||
"""
|
||||
if not self.DOWNLOADER:
|
||||
return []
|
||||
return self.DOWNLOADER.split(",")
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
with self.CONFIG_PATH as p:
|
||||
if not p.exists():
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
if SystemUtils.is_frozen():
|
||||
if not (p / "app.env").exists():
|
||||
SystemUtils.copy(self.INNER_CONFIG_PATH / "app.env", p / "app.env")
|
||||
with self.TEMP_PATH as p:
|
||||
if not p.exists():
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
@@ -278,4 +402,7 @@ class Settings(BaseSettings):
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
settings = Settings(
|
||||
_env_file=Settings().CONFIG_PATH / "app.env",
|
||||
_env_file_encoding="utf-8"
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -44,6 +45,8 @@ class TorrentInfo:
|
||||
pubdate: str = None
|
||||
# 已过时间
|
||||
date_elapsed: str = None
|
||||
# 免费截止时间
|
||||
freedate: str = None
|
||||
# 上传因子
|
||||
uploadvolumefactor: float = None
|
||||
# 下载因子
|
||||
@@ -54,6 +57,8 @@ class TorrentInfo:
|
||||
labels: list = field(default_factory=list)
|
||||
# 种子优先级
|
||||
pri_order: int = 0
|
||||
# 种子分类 电影/电视剧
|
||||
category: str = None
|
||||
|
||||
def __setattr__(self, name: str, value: Any):
|
||||
self.__dict__[name] = value
|
||||
@@ -90,7 +95,9 @@ class TorrentInfo:
|
||||
"1.0 1.0": "普通",
|
||||
"1.0 0.0": "免费",
|
||||
"2.0 1.0": "2X",
|
||||
"4.0 1.0": "4X",
|
||||
"2.0 0.0": "2X免费",
|
||||
"4.0 0.0": "4X免费",
|
||||
"1.0 0.5": "50%",
|
||||
"2.0 0.5": "2X 50%",
|
||||
"1.0 0.7": "70%",
|
||||
@@ -105,12 +112,22 @@ class TorrentInfo:
|
||||
"""
|
||||
return self.get_free_string(self.uploadvolumefactor, self.downloadvolumefactor)
|
||||
|
||||
@property
|
||||
def freedate_diff(self):
|
||||
"""
|
||||
返回免费剩余时间
|
||||
"""
|
||||
if not self.freedate:
|
||||
return ""
|
||||
return StringUtils.diff_time_str(self.freedate)
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
返回字典
|
||||
"""
|
||||
dicts = asdict(self)
|
||||
dicts["volume_factor"] = self.volume_factor
|
||||
dicts["freedate_diff"] = self.freedate_diff
|
||||
return dicts
|
||||
|
||||
|
||||
@@ -120,6 +137,10 @@ class MediaInfo:
|
||||
type: MediaType = None
|
||||
# 媒体标题
|
||||
title: str = None
|
||||
# 英文标题
|
||||
en_title: str = None
|
||||
# 新加坡标题
|
||||
sg_title: str = None
|
||||
# 年份
|
||||
year: str = None
|
||||
# 季
|
||||
@@ -145,7 +166,7 @@ class MediaInfo:
|
||||
# LOGO
|
||||
logo_path: str = None
|
||||
# 评分
|
||||
vote_average: int = 0
|
||||
vote_average: float = 0
|
||||
# 描述
|
||||
overview: str = None
|
||||
# 风格ID
|
||||
@@ -272,7 +293,7 @@ class MediaInfo:
|
||||
初始化媒信息
|
||||
"""
|
||||
|
||||
def __directors_actors(tmdbinfo: dict):
|
||||
def __directors_actors(tmdbinfo: dict) -> Tuple[List[dict], List[dict]]:
|
||||
"""
|
||||
查询导演和演员
|
||||
:param tmdbinfo: TMDB元数据
|
||||
@@ -353,6 +374,10 @@ class MediaInfo:
|
||||
self.genre_ids = info.get('genre_ids') or []
|
||||
# 原语种
|
||||
self.original_language = info.get('original_language')
|
||||
# 英文标题
|
||||
self.en_title = info.get('en_title')
|
||||
# 新加坡标题
|
||||
self.sg_title = info.get('sg_title')
|
||||
if self.type == MediaType.MOVIE:
|
||||
# 标题
|
||||
self.title = info.get('title')
|
||||
@@ -414,24 +439,34 @@ class MediaInfo:
|
||||
# 豆瓣ID
|
||||
self.douban_id = str(info.get("id"))
|
||||
# 类型
|
||||
|
||||
if not self.type:
|
||||
if isinstance(info.get('media_type'), MediaType):
|
||||
self.type = info.get('media_type')
|
||||
else:
|
||||
elif info.get("type"):
|
||||
self.type = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
|
||||
elif info.get("type_name"):
|
||||
self.type = MediaType(info.get("type_name"))
|
||||
# 标题
|
||||
if not self.title:
|
||||
self.title = info.get("title")
|
||||
# 识别标题中的季
|
||||
meta = MetaInfo(self.title)
|
||||
self.season = meta.begin_season
|
||||
# 英文标题,暂时不支持
|
||||
if not self.en_title:
|
||||
self.en_title = info.get('original_title')
|
||||
# 原语种标题
|
||||
if not self.original_title:
|
||||
self.original_title = info.get("original_title")
|
||||
# 年份
|
||||
if not self.year:
|
||||
self.year = info.get("year")[:4] if info.get("year") else None
|
||||
# 识别标题中的季
|
||||
meta = MetaInfo(info.get("title"))
|
||||
# 季
|
||||
if not self.season:
|
||||
self.season = meta.begin_season
|
||||
if self.season:
|
||||
self.type = MediaType.TV
|
||||
elif not self.type:
|
||||
self.type = MediaType.MOVIE
|
||||
# 评分
|
||||
if not self.vote_average:
|
||||
rating = info.get("rating")
|
||||
@@ -472,14 +507,34 @@ class MediaInfo:
|
||||
self.actors = info.get("actors") or []
|
||||
# 别名
|
||||
if not self.names:
|
||||
self.names = info.get("aka") or []
|
||||
akas = info.get("aka")
|
||||
if akas:
|
||||
self.names = [re.sub(r'\([港台豆友译名]+\)', "", aka) for aka in akas]
|
||||
# 剧集
|
||||
if self.type == MediaType.TV and not self.seasons:
|
||||
meta = MetaInfo(info.get("title"))
|
||||
if meta.begin_season:
|
||||
episodes_count = info.get("episodes_count")
|
||||
if episodes_count:
|
||||
self.seasons[meta.begin_season] = list(range(1, episodes_count + 1))
|
||||
season = meta.begin_season or 1
|
||||
episodes_count = info.get("episodes_count")
|
||||
if episodes_count:
|
||||
self.seasons[season] = list(range(1, episodes_count + 1))
|
||||
# 季年份
|
||||
if self.type == MediaType.TV and not self.season_years:
|
||||
season = self.season or 1
|
||||
self.season_years = {
|
||||
season: self.year
|
||||
}
|
||||
# 风格
|
||||
if not self.genres:
|
||||
self.genres = [{"id": genre, "name": genre} for genre in info.get("genres") or []]
|
||||
# 时长
|
||||
if not self.runtime and info.get("durations"):
|
||||
# 查找数字
|
||||
match = re.search(r'\d+', info.get("durations")[0])
|
||||
if match:
|
||||
self.runtime = int(match.group())
|
||||
# 国家
|
||||
if not self.production_countries:
|
||||
self.production_countries = [{"id": country, "name": country} for country in info.get("countries") or []]
|
||||
# 剩余属性赋值
|
||||
for key, value in info.items():
|
||||
if not hasattr(self, key):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from queue import Queue, Empty
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.singleton import Singleton
|
||||
@@ -10,16 +11,13 @@ class EventManager(metaclass=Singleton):
|
||||
事件管理器
|
||||
"""
|
||||
|
||||
# 事件队列
|
||||
_eventQueue: Queue = None
|
||||
# 事件响应函数字典
|
||||
_handlers: dict = {}
|
||||
|
||||
def __init__(self):
|
||||
# 事件队列
|
||||
self._eventQueue = Queue()
|
||||
# 事件响应函数字典
|
||||
self._handlers = {}
|
||||
self._handlers: Dict[str, Dict[str, Any]] = {}
|
||||
# 已禁用的事件响应
|
||||
self._disabled_handlers = []
|
||||
|
||||
def get_event(self):
|
||||
"""
|
||||
@@ -27,36 +25,56 @@ class EventManager(metaclass=Singleton):
|
||||
"""
|
||||
try:
|
||||
event = self._eventQueue.get(block=True, timeout=1)
|
||||
handlerList = self._handlers.get(event.event_type)
|
||||
return event, handlerList or []
|
||||
handlers = self._handlers.get(event.event_type) or {}
|
||||
if handlers:
|
||||
# 去除掉被禁用的事件响应
|
||||
handlerList = [handler for handler in handlers.values()
|
||||
if handler.__qualname__.split(".")[0] not in self._disabled_handlers]
|
||||
return event, handlerList
|
||||
return event, []
|
||||
except Empty:
|
||||
return None, []
|
||||
|
||||
def check(self, etype: EventType):
|
||||
"""
|
||||
检查事件是否存在响应,去除掉被禁用的事件响应
|
||||
"""
|
||||
if etype.value not in self._handlers:
|
||||
return False
|
||||
handlers = self._handlers.get(etype.value)
|
||||
return any([handler for handler in handlers.values()
|
||||
if handler.__qualname__.split(".")[0] not in self._disabled_handlers])
|
||||
|
||||
def add_event_listener(self, etype: EventType, handler: type):
|
||||
"""
|
||||
注册事件处理
|
||||
"""
|
||||
try:
|
||||
handlerList = self._handlers[etype.value]
|
||||
handlers = self._handlers[etype.value]
|
||||
except KeyError:
|
||||
handlerList = []
|
||||
self._handlers[etype.value] = handlerList
|
||||
if handler not in handlerList:
|
||||
handlerList.append(handler)
|
||||
logger.debug(f"Event Registed:{etype.value} - {handler}")
|
||||
handlers = {}
|
||||
self._handlers[etype.value] = handlers
|
||||
if handler.__qualname__ in handlers:
|
||||
handlers.pop(handler.__qualname__)
|
||||
else:
|
||||
logger.debug(f"Event Registed:{etype.value} - {handler.__qualname__}")
|
||||
handlers[handler.__qualname__] = handler
|
||||
|
||||
def remove_event_listener(self, etype: EventType, handler: type):
|
||||
def disable_events_hander(self, class_name: str):
|
||||
"""
|
||||
移除监听器的处理函数
|
||||
标记对应类事件处理为不可用
|
||||
"""
|
||||
try:
|
||||
handlerList = self._handlers[etype.value]
|
||||
if handler in handlerList[:]:
|
||||
handlerList.remove(handler)
|
||||
if not handlerList:
|
||||
del self._handlers[etype.value]
|
||||
except KeyError:
|
||||
pass
|
||||
if class_name not in self._disabled_handlers:
|
||||
self._disabled_handlers.append(class_name)
|
||||
logger.debug(f"Event Disabled:{class_name}")
|
||||
|
||||
def enable_events_hander(self, class_name: str):
|
||||
"""
|
||||
标记对应类事件处理为可用
|
||||
"""
|
||||
if class_name in self._disabled_handlers:
|
||||
self._disabled_handlers.remove(class_name)
|
||||
logger.debug(f"Event Enabled:{class_name}")
|
||||
|
||||
def send_event(self, etype: EventType, data: dict = None):
|
||||
"""
|
||||
|
||||
47
app/core/meta/customization.py
Normal file
47
app/core/meta/customization.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import regex as re
|
||||
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class CustomizationMatcher(metaclass=Singleton):
|
||||
"""
|
||||
识别自定义占位符
|
||||
"""
|
||||
customization = None
|
||||
custom_separator = None
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.customization = None
|
||||
self.custom_separator = None
|
||||
|
||||
def match(self, title=None):
|
||||
"""
|
||||
:param title: 资源标题或文件名
|
||||
:return: 匹配结果
|
||||
"""
|
||||
if not title:
|
||||
return ""
|
||||
if not self.customization:
|
||||
# 自定义占位符
|
||||
customization = self.systemconfig.get(SystemConfigKey.Customization)
|
||||
if not customization:
|
||||
return ""
|
||||
if isinstance(customization, str):
|
||||
customization = customization.replace("\n", ";").replace("|", ";").strip(";").split(";")
|
||||
self.customization = "|".join([f"({item})" for item in customization])
|
||||
|
||||
customization_re = re.compile(r"%s" % self.customization)
|
||||
# 处理重复多次的情况,保留先后顺序(按添加自定义占位符的顺序)
|
||||
unique_customization = {}
|
||||
for item in re.findall(customization_re, title):
|
||||
if not isinstance(item, tuple):
|
||||
item = (item,)
|
||||
for i in range(len(item)):
|
||||
if item[i] and unique_customization.get(item[i]) is None:
|
||||
unique_customization[item[i]] = i
|
||||
unique_customization = list(dict(sorted(unique_customization.items(), key=lambda x: x[1])).keys())
|
||||
separator = self.custom_separator or "@"
|
||||
return separator.join(unique_customization)
|
||||
@@ -1,8 +1,12 @@
|
||||
import re
|
||||
import traceback
|
||||
|
||||
import zhconv
|
||||
import anitopy
|
||||
from app.core.meta.customization import CustomizationMatcher
|
||||
from app.core.meta.metabase import MetaBase
|
||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
@@ -116,7 +120,7 @@ class MetaAnime(MetaBase):
|
||||
else:
|
||||
self.total_episode = 1
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
logger.debug(f"解析集数失败:{str(err)} - {traceback.format_exc()}")
|
||||
self.begin_episode = None
|
||||
self.end_episode = None
|
||||
self.type = MediaType.TV
|
||||
@@ -144,6 +148,8 @@ class MetaAnime(MetaBase):
|
||||
self.resource_team = \
|
||||
ReleaseGroupsMatcher().match(title=original_title) or \
|
||||
anitopy_info_origin.get("release_group") or None
|
||||
# 自定义占位符
|
||||
self.customization = CustomizationMatcher().match(title=original_title) or None
|
||||
# 视频编码
|
||||
self.video_encode = anitopy_info.get("video_term")
|
||||
if isinstance(self.video_encode, list):
|
||||
@@ -159,7 +165,7 @@ class MetaAnime(MetaBase):
|
||||
if not self.type:
|
||||
self.type = MediaType.TV
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
logger.error(f"解析动漫信息失败:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
@staticmethod
|
||||
def __prepare_title(title: str):
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import traceback
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Union, Optional, List, Self
|
||||
|
||||
import cn2an
|
||||
import regex as re
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
@@ -51,12 +53,17 @@ class MetaBase(object):
|
||||
resource_pix: Optional[str] = None
|
||||
# 识别的制作组/字幕组
|
||||
resource_team: Optional[str] = None
|
||||
# 识别的自定义占位符
|
||||
customization: Optional[str] = None
|
||||
# 视频编码
|
||||
video_encode: Optional[str] = None
|
||||
# 音频编码
|
||||
audio_encode: Optional[str] = None
|
||||
# 应用的识别词信息
|
||||
apply_words: Optional[List[str]] = None
|
||||
# 附加信息
|
||||
tmdbid: int = None
|
||||
doubanid: str = None
|
||||
|
||||
# 副标题解析
|
||||
_subtitle_flag = False
|
||||
@@ -85,6 +92,17 @@ class MetaBase(object):
|
||||
return self.cn_name
|
||||
return ""
|
||||
|
||||
@name.setter
|
||||
def name(self, name: str):
|
||||
"""
|
||||
设置名称
|
||||
"""
|
||||
if StringUtils.is_all_chinese(name):
|
||||
self.cn_name = name
|
||||
else:
|
||||
self.en_name = name
|
||||
self.cn_name = None
|
||||
|
||||
def init_subtitle(self, title_text: str):
|
||||
"""
|
||||
副标题识别
|
||||
@@ -111,7 +129,11 @@ class MetaBase(object):
|
||||
else:
|
||||
begin_season = int(cn2an.cn2an(seasons, mode='smart'))
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
|
||||
return
|
||||
if begin_season and begin_season > 100:
|
||||
return
|
||||
if end_season and end_season > 100:
|
||||
return
|
||||
if self.begin_season is None and isinstance(begin_season, int):
|
||||
self.begin_season = begin_season
|
||||
@@ -142,7 +164,11 @@ class MetaBase(object):
|
||||
else:
|
||||
begin_episode = int(cn2an.cn2an(episodes, mode='smart'))
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
||||
return
|
||||
if begin_episode and begin_episode >= 10000:
|
||||
return
|
||||
if end_episode and end_episode >= 10000:
|
||||
return
|
||||
if self.begin_episode is None and isinstance(begin_episode, int):
|
||||
self.begin_episode = begin_episode
|
||||
@@ -165,7 +191,7 @@ class MetaBase(object):
|
||||
try:
|
||||
self.total_episode = int(cn2an.cn2an(episode_all.strip(), mode='smart'))
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
||||
return
|
||||
self.begin_episode = None
|
||||
self.end_episode = None
|
||||
@@ -181,7 +207,7 @@ class MetaBase(object):
|
||||
try:
|
||||
self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart'))
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
|
||||
return
|
||||
self.begin_season = 1
|
||||
self.end_season = self.total_season
|
||||
@@ -492,6 +518,9 @@ class MetaBase(object):
|
||||
# 制作组/字幕组
|
||||
if not self.resource_team:
|
||||
self.resource_team = meta.resource_team
|
||||
# 自定义占位符
|
||||
if not self.customization:
|
||||
self.customization = meta.customization
|
||||
# 特效
|
||||
if not self.resource_effect:
|
||||
self.resource_effect = meta.resource_effect
|
||||
|
||||
@@ -2,6 +2,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta.customization import CustomizationMatcher
|
||||
from app.core.meta.metabase import MetaBase
|
||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.utils.string import StringUtils
|
||||
@@ -33,6 +34,7 @@ class MetaVideo(MetaBase):
|
||||
_name_no_begin_re = r"^\[.+?]"
|
||||
_name_no_chinese_re = r".*版|.*字幕"
|
||||
_name_se_words = ['共', '第', '季', '集', '话', '話', '期']
|
||||
_name_movie_words = ['剧场版', '劇場版', '电影版', '電影版']
|
||||
_name_nostring_re = r"^PTS|^JADE|^AOD|^CHC|^[A-Z]{1,4}TV[\-0-9UVHDK]*" \
|
||||
r"|HBO$|\s+HBO|\d{1,2}th|\d{1,2}bit|NETFLIX|AMAZON|IMAX|^3D|\s+3D|^BBC\s+|\s+BBC|BBC$|DISNEY\+?|XXX|\s+DC$" \
|
||||
r"|[第\s共]+[0-9一二三四五六七八九十\-\s]+季" \
|
||||
@@ -130,6 +132,8 @@ class MetaVideo(MetaBase):
|
||||
self.part = None
|
||||
# 制作组/字幕组
|
||||
self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None
|
||||
# 自定义占位符
|
||||
self.customization = CustomizationMatcher().match(title=original_title) or None
|
||||
|
||||
def __fix_name(self, name: str):
|
||||
if not name:
|
||||
@@ -179,8 +183,9 @@ class MetaVideo(MetaBase):
|
||||
if not self.cn_name:
|
||||
self.cn_name = token
|
||||
elif not self._stop_cnname_flag:
|
||||
if not re.search("%s" % self._name_no_chinese_re, token, flags=re.IGNORECASE) \
|
||||
and not re.search("%s" % self._name_se_words, token, flags=re.IGNORECASE):
|
||||
if re.search("%s" % self._name_movie_words, token, flags=re.IGNORECASE) \
|
||||
or (not re.search("%s" % self._name_no_chinese_re, token, flags=re.IGNORECASE)
|
||||
and not re.search("%s" % self._name_se_words, token, flags=re.IGNORECASE)):
|
||||
self.cn_name = "%s %s" % (self.cn_name, token)
|
||||
self._stop_cnname_flag = True
|
||||
else:
|
||||
@@ -265,7 +270,7 @@ class MetaVideo(MetaBase):
|
||||
self.tokens.get_next()
|
||||
self._last_token_type = "part"
|
||||
self._continue_flag = False
|
||||
self._stop_name_flag = False
|
||||
# self._stop_name_flag = False
|
||||
|
||||
def __init_year(self, token: str):
|
||||
if not self.name:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import traceback
|
||||
from typing import List, Tuple
|
||||
|
||||
import cn2an
|
||||
@@ -25,7 +26,7 @@ class WordsMatcher(metaclass=Singleton):
|
||||
# 读取自定义识别词
|
||||
words: List[str] = self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []
|
||||
for word in words:
|
||||
if not word:
|
||||
if not word or word.find('#') == 0:
|
||||
continue
|
||||
try:
|
||||
if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "):
|
||||
@@ -61,10 +62,9 @@ class WordsMatcher(metaclass=Singleton):
|
||||
|
||||
if state:
|
||||
appley_words.append(word)
|
||||
else:
|
||||
logger.debug(f"自定义识别词替换失败:{message}")
|
||||
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
logger.error(f"自定义识别词预处理标题失败:{str(err)} - {traceback.format_exc()}")
|
||||
|
||||
return title, appley_words
|
||||
|
||||
@@ -79,7 +79,7 @@ class WordsMatcher(metaclass=Singleton):
|
||||
else:
|
||||
return re.sub(r'%s' % replaced, r'%s' % replace, title), "", True
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
logger.error(f"自定义识别词正则替换失败:{str(err)} - {traceback.format_exc()}")
|
||||
return title, str(err), False
|
||||
|
||||
@staticmethod
|
||||
@@ -131,5 +131,5 @@ class WordsMatcher(metaclass=Singleton):
|
||||
title = re.sub(episode_offset_re, r'%s' % episode_num[1], title)
|
||||
return title, "", True
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
logger.error(f"自定义识别词集数偏移失败:{str(err)} - {traceback.format_exc()}")
|
||||
return title, str(err), False
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
import regex as re
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaAnime, MetaVideo, MetaBase
|
||||
from app.core.meta.words import WordsMatcher
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
||||
"""
|
||||
媒体整理入口,根据名称和副标题,判断是哪种类型的识别,返回对应对象
|
||||
根据标题和副标题识别元数据
|
||||
:param title: 标题、种子名、文件名
|
||||
:param subtitle: 副标题、描述
|
||||
:return: MetaAnime、MetaVideo
|
||||
@@ -18,6 +20,8 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
||||
org_title = title
|
||||
# 预处理标题
|
||||
title, apply_words = WordsMatcher().prepare(title)
|
||||
# 获取标题中媒体信息
|
||||
title, metainfo = find_metainfo(title)
|
||||
# 判断是否处理文件
|
||||
if title and Path(title).suffix.lower() in settings.RMT_MEDIAEXT:
|
||||
isfile = True
|
||||
@@ -29,10 +33,46 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
||||
meta.title = org_title
|
||||
# 记录使用的识别词
|
||||
meta.apply_words = apply_words or []
|
||||
|
||||
# 修正媒体信息
|
||||
if metainfo.get('tmdbid'):
|
||||
meta.tmdbid = metainfo['tmdbid']
|
||||
if metainfo.get('doubanid'):
|
||||
meta.tmdbid = metainfo['doubanid']
|
||||
if metainfo.get('type'):
|
||||
meta.type = metainfo['type']
|
||||
if metainfo.get('begin_season'):
|
||||
meta.begin_season = metainfo['begin_season']
|
||||
if metainfo.get('end_season'):
|
||||
meta.end_season = metainfo['end_season']
|
||||
if metainfo.get('total_season'):
|
||||
meta.total_season = metainfo['total_season']
|
||||
if metainfo.get('begin_episode'):
|
||||
meta.begin_episode = metainfo['begin_episode']
|
||||
if metainfo.get('end_episode'):
|
||||
meta.end_episode = metainfo['end_episode']
|
||||
if metainfo.get('total_episode'):
|
||||
meta.total_episode = metainfo['total_episode']
|
||||
return meta
|
||||
|
||||
|
||||
def MetaInfoPath(path: Path) -> MetaBase:
|
||||
"""
|
||||
根据路径识别元数据
|
||||
:param path: 路径
|
||||
"""
|
||||
# 文件元数据,不包含后缀
|
||||
file_meta = MetaInfo(title=path.stem)
|
||||
# 上级目录元数据
|
||||
dir_meta = MetaInfo(title=path.parent.name)
|
||||
# 合并元数据
|
||||
file_meta.merge(dir_meta)
|
||||
# 上上级目录元数据
|
||||
root_meta = MetaInfo(title=path.parent.parent.name)
|
||||
# 合并元数据
|
||||
file_meta.merge(root_meta)
|
||||
return file_meta
|
||||
|
||||
|
||||
def is_anime(name: str) -> bool:
|
||||
"""
|
||||
判断是否为动漫
|
||||
@@ -51,3 +91,76 @@ def is_anime(name: str) -> bool:
|
||||
if re.search(r'\[[+0-9XVPI-]+]\s*\[', name, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_metainfo(title: str) -> Tuple[str, dict]:
|
||||
"""
|
||||
从标题中提取媒体信息
|
||||
"""
|
||||
metainfo = {
|
||||
'tmdbid': None,
|
||||
'doubanid': None,
|
||||
'type': None,
|
||||
'begin_season': None,
|
||||
'end_season': None,
|
||||
'total_season': None,
|
||||
'begin_episode': None,
|
||||
'end_episode': None,
|
||||
'total_episode': None,
|
||||
}
|
||||
if not title:
|
||||
return title, metainfo
|
||||
# 从标题中提取媒体信息 格式为{[tmdbid=xxx;type=xxx;s=xxx;e=xxx]}
|
||||
results = re.findall(r'(?<={\[)[\W\w]+(?=]})', title)
|
||||
if not results:
|
||||
return title, metainfo
|
||||
for result in results:
|
||||
# 查找tmdbid信息
|
||||
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
|
||||
if tmdbid and tmdbid[0].isdigit():
|
||||
metainfo['tmdbid'] = tmdbid[0]
|
||||
# 查找豆瓣id信息
|
||||
doubanid = re.findall(r'(?<=doubanid=)\d+', result)
|
||||
if doubanid and doubanid[0].isdigit():
|
||||
metainfo['doubanid'] = doubanid[0]
|
||||
# 查找媒体类型
|
||||
mtype = re.findall(r'(?<=type=)\w+', result)
|
||||
if mtype:
|
||||
match mtype[0]:
|
||||
case "movie":
|
||||
metainfo['type'] = MediaType.MOVIE
|
||||
case "tv":
|
||||
metainfo['type'] = MediaType.TV
|
||||
case _:
|
||||
pass
|
||||
# 查找季信息
|
||||
begin_season = re.findall(r'(?<=s=)\d+', result)
|
||||
if begin_season and begin_season[0].isdigit():
|
||||
metainfo['begin_season'] = int(begin_season[0])
|
||||
end_season = re.findall(r'(?<=s=\d+-)\d+', result)
|
||||
if end_season and end_season[0].isdigit():
|
||||
metainfo['end_season'] = int(end_season[0])
|
||||
# 查找集信息
|
||||
begin_episode = re.findall(r'(?<=e=)\d+', result)
|
||||
if begin_episode and begin_episode[0].isdigit():
|
||||
metainfo['begin_episode'] = int(begin_episode[0])
|
||||
end_episode = re.findall(r'(?<=e=\d+-)\d+', result)
|
||||
if end_episode and end_episode[0].isdigit():
|
||||
metainfo['end_episode'] = int(end_episode[0])
|
||||
# 去除title中该部分
|
||||
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
|
||||
title = title.replace(f"{{[{result}]}}", '')
|
||||
# 计算季集总数
|
||||
if metainfo.get('begin_season') and metainfo.get('end_season'):
|
||||
if metainfo['begin_season'] > metainfo['end_season']:
|
||||
metainfo['begin_season'], metainfo['end_season'] = metainfo['end_season'], metainfo['begin_season']
|
||||
metainfo['total_season'] = metainfo['end_season'] - metainfo['begin_season'] + 1
|
||||
elif metainfo.get('begin_season') and not metainfo.get('end_season'):
|
||||
metainfo['total_season'] = 1
|
||||
if metainfo.get('begin_episode') and metainfo.get('end_episode'):
|
||||
if metainfo['begin_episode'] > metainfo['end_episode']:
|
||||
metainfo['begin_episode'], metainfo['end_episode'] = metainfo['end_episode'], metainfo['begin_episode']
|
||||
metainfo['total_episode'] = metainfo['end_episode'] - metainfo['begin_episode'] + 1
|
||||
elif metainfo.get('begin_episode') and not metainfo.get('end_episode'):
|
||||
metainfo['total_episode'] = 1
|
||||
return title, metainfo
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Generator, Optional
|
||||
from typing import Generator, Optional, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.module import ModuleHelper
|
||||
@@ -51,6 +51,25 @@ class ModuleManager(metaclass=Singleton):
|
||||
if hasattr(module, "stop"):
|
||||
module.stop()
|
||||
|
||||
def reload(self):
|
||||
"""
|
||||
重新加载所有模块
|
||||
"""
|
||||
self.stop()
|
||||
self.load_modules()
|
||||
|
||||
def test(self, modleid: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块
|
||||
"""
|
||||
if modleid not in self._running_modules:
|
||||
return False, "模块未加载,请检查参数设置"
|
||||
module = self._running_modules[modleid]
|
||||
if hasattr(module, "test") \
|
||||
and ObjectUtils.check_method(getattr(module, "test")):
|
||||
return module.test()
|
||||
return True, "模块不支持测试"
|
||||
|
||||
@staticmethod
|
||||
def check_setting(setting: Optional[tuple]) -> bool:
|
||||
"""
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import traceback
|
||||
from typing import List, Any, Dict, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.module import ModuleHelper
|
||||
from app.helper.plugin import PluginHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class PluginManager(metaclass=Singleton):
|
||||
@@ -25,11 +30,12 @@ class PluginManager(metaclass=Singleton):
|
||||
|
||||
def __init__(self):
|
||||
self.siteshelper = SitesHelper()
|
||||
self.pluginhelper = PluginHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.install_online_plugin()
|
||||
self.init_config()
|
||||
|
||||
def init_config(self):
|
||||
# 配置管理
|
||||
self.systemconfig = SystemConfigOper()
|
||||
# 停止已有插件
|
||||
self.stop()
|
||||
# 启动插件
|
||||
@@ -39,7 +45,6 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
启动加载插件
|
||||
"""
|
||||
|
||||
# 扫描插件目录
|
||||
plugins = ModuleHelper.load(
|
||||
"app.plugins",
|
||||
@@ -58,6 +63,8 @@ class PluginManager(metaclass=Singleton):
|
||||
self._plugins[plugin_id] = plugin
|
||||
# 未安装的不加载
|
||||
if plugin_id not in installed_plugins:
|
||||
# 设置事件状态为不可用
|
||||
eventmanager.disable_events_hander(plugin_id)
|
||||
continue
|
||||
# 生成实例
|
||||
plugin_obj = plugin()
|
||||
@@ -65,9 +72,14 @@ class PluginManager(metaclass=Singleton):
|
||||
plugin_obj.init_plugin(self.get_plugin_config(plugin_id))
|
||||
# 存储运行实例
|
||||
self._running_plugins[plugin_id] = plugin_obj
|
||||
logger.info(f"Plugin Loaded:{plugin_id}")
|
||||
logger.info(f"加载插件:{plugin_id} 版本:{plugin_obj.plugin_version}")
|
||||
# 启用的插件才设置事件注册状态可用
|
||||
if plugin_obj.get_state():
|
||||
eventmanager.enable_events_hander(plugin_id)
|
||||
else:
|
||||
eventmanager.disable_events_hander(plugin_id)
|
||||
except Exception as err:
|
||||
logger.error(f"加载插件 {plugin_id} 出错:{err} - {traceback.format_exc()}")
|
||||
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
|
||||
|
||||
def reload_plugin(self, plugin_id: str, conf: dict):
|
||||
"""
|
||||
@@ -76,6 +88,12 @@ class PluginManager(metaclass=Singleton):
|
||||
if not self._running_plugins.get(plugin_id):
|
||||
return
|
||||
self._running_plugins[plugin_id].init_plugin(conf)
|
||||
if self._running_plugins[plugin_id].get_state():
|
||||
# 设置启用的插件事件注册状态可用
|
||||
eventmanager.enable_events_hander(plugin_id)
|
||||
else:
|
||||
# 设置事件状态为不可用
|
||||
eventmanager.disable_events_hander(plugin_id)
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
@@ -93,13 +111,46 @@ class PluginManager(metaclass=Singleton):
|
||||
self._plugins = {}
|
||||
self._running_plugins = {}
|
||||
|
||||
def install_online_plugin(self):
|
||||
"""
|
||||
安装本地不存在的在线插件
|
||||
"""
|
||||
if SystemUtils.is_frozen():
|
||||
return
|
||||
logger.info("开始安装在线插件...")
|
||||
# 已安装插件
|
||||
install_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 在线插件
|
||||
online_plugins = self.get_online_plugins()
|
||||
if not online_plugins:
|
||||
logger.error("未获取到在线插件")
|
||||
return
|
||||
# 支持更新的插件自动更新
|
||||
for plugin in online_plugins:
|
||||
# 只处理已安装的插件
|
||||
if plugin.get("id") in install_plugins and not self.is_plugin_exists(plugin.get("id")):
|
||||
# 下载安装
|
||||
state, msg = self.pluginhelper.install(pid=plugin.get("id"),
|
||||
repo_url=plugin.get("repo_url"))
|
||||
# 安装失败
|
||||
if not state:
|
||||
logger.error(
|
||||
f"插件 {plugin.get('plugin_name')} v{plugin.get('plugin_version')} 安装失败:{msg}")
|
||||
continue
|
||||
logger.info(f"插件 {plugin.get('plugin_name')} 安装成功,版本:{plugin.get('plugin_version')}")
|
||||
logger.info("在线插件安装完成")
|
||||
|
||||
def get_plugin_config(self, pid: str) -> dict:
|
||||
"""
|
||||
获取插件配置
|
||||
"""
|
||||
if not self._plugins.get(pid):
|
||||
return {}
|
||||
return self.systemconfig.get(self._config_key % pid) or {}
|
||||
conf = self.systemconfig.get(self._config_key % pid)
|
||||
if conf:
|
||||
# 去掉空Key
|
||||
return {k: v for k, v in conf.items() if k}
|
||||
return {}
|
||||
|
||||
def save_plugin_config(self, pid: str, conf: dict) -> bool:
|
||||
"""
|
||||
@@ -109,6 +160,14 @@ class PluginManager(metaclass=Singleton):
|
||||
return False
|
||||
return self.systemconfig.set(self._config_key % pid, conf)
|
||||
|
||||
def delete_plugin_config(self, pid: str) -> bool:
|
||||
"""
|
||||
删除插件配置
|
||||
"""
|
||||
if not self._plugins.get(pid):
|
||||
return False
|
||||
return self.systemconfig.delete(self._config_key % pid)
|
||||
|
||||
def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
获取插件表单
|
||||
@@ -167,6 +226,36 @@ class PluginManager(metaclass=Singleton):
|
||||
ret_apis.extend(apis)
|
||||
return ret_apis
|
||||
|
||||
def get_plugin_services(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron、interval、date、CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwagrs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
ret_services = []
|
||||
for pid, plugin in self._running_plugins.items():
|
||||
if hasattr(plugin, "get_service") \
|
||||
and ObjectUtils.check_method(plugin.get_service):
|
||||
services = plugin.get_service()
|
||||
if services:
|
||||
ret_services.extend(services)
|
||||
return ret_services
|
||||
|
||||
def get_plugin_attr(self, pid: str, attr: str) -> Any:
|
||||
"""
|
||||
获取插件属性
|
||||
"""
|
||||
if not self._running_plugins.get(pid):
|
||||
return None
|
||||
if not hasattr(self._running_plugins[pid], attr):
|
||||
return None
|
||||
return getattr(self._running_plugins[pid], attr)
|
||||
|
||||
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
|
||||
"""
|
||||
运行插件方法
|
||||
@@ -177,9 +266,104 @@ class PluginManager(metaclass=Singleton):
|
||||
return None
|
||||
return getattr(self._running_plugins[pid], method)(*args, **kwargs)
|
||||
|
||||
def get_plugin_apps(self) -> List[dict]:
|
||||
def get_plugin_ids(self) -> List[str]:
|
||||
"""
|
||||
获取所有插件信息
|
||||
获取所有插件ID
|
||||
"""
|
||||
return list(self._plugins.keys())
|
||||
|
||||
def get_running_plugin_ids(self) -> List[str]:
|
||||
"""
|
||||
获取所有运行态插件ID
|
||||
"""
|
||||
return list(self._running_plugins.keys())
|
||||
|
||||
def get_online_plugins(self) -> List[dict]:
|
||||
"""
|
||||
获取所有在线插件信息
|
||||
"""
|
||||
# 返回值
|
||||
all_confs = []
|
||||
if not settings.PLUGIN_MARKET:
|
||||
return all_confs
|
||||
# 已安装插件
|
||||
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 线上插件列表
|
||||
markets = settings.PLUGIN_MARKET.split(",")
|
||||
for market in markets:
|
||||
online_plugins = self.pluginhelper.get_plugins(market) or {}
|
||||
if not online_plugins:
|
||||
logger.warn(f"获取插件库失败 {market}")
|
||||
for pid, plugin in online_plugins.items():
|
||||
# 运行状插件
|
||||
plugin_obj = self._running_plugins.get(pid)
|
||||
# 非运行态插件
|
||||
plugin_static = self._plugins.get(pid)
|
||||
# 基本属性
|
||||
conf = {}
|
||||
# ID
|
||||
conf.update({"id": pid})
|
||||
# 安装状态
|
||||
if pid in installed_apps and plugin_static:
|
||||
conf.update({"installed": True})
|
||||
else:
|
||||
conf.update({"installed": False})
|
||||
# 是否有新版本
|
||||
conf.update({"has_update": False})
|
||||
if plugin_static:
|
||||
installed_version = getattr(plugin_static, "plugin_version")
|
||||
if StringUtils.compare_version(installed_version, plugin.get("version")) < 0:
|
||||
# 需要更新
|
||||
conf.update({"has_update": True})
|
||||
# 运行状态
|
||||
if plugin_obj and hasattr(plugin_obj, "get_state"):
|
||||
try:
|
||||
state = plugin_obj.get_state()
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
|
||||
state = False
|
||||
conf.update({"state": state})
|
||||
else:
|
||||
conf.update({"state": False})
|
||||
# 是否有详情页面
|
||||
conf.update({"has_page": False})
|
||||
if plugin_obj and hasattr(plugin_obj, "get_page"):
|
||||
if ObjectUtils.check_method(plugin_obj.get_page):
|
||||
conf.update({"has_page": True})
|
||||
# 权限
|
||||
if plugin.get("level"):
|
||||
conf.update({"auth_level": plugin.get("level")})
|
||||
if self.siteshelper.auth_level < plugin.get("level"):
|
||||
continue
|
||||
# 名称
|
||||
if plugin.get("name"):
|
||||
conf.update({"plugin_name": plugin.get("name")})
|
||||
# 描述
|
||||
if plugin.get("description"):
|
||||
conf.update({"plugin_desc": plugin.get("description")})
|
||||
# 版本
|
||||
if plugin.get("version"):
|
||||
conf.update({"plugin_version": plugin.get("version")})
|
||||
# 图标
|
||||
if plugin.get("icon"):
|
||||
conf.update({"plugin_icon": plugin.get("icon")})
|
||||
# 作者
|
||||
if plugin.get("author"):
|
||||
conf.update({"plugin_author": plugin.get("author")})
|
||||
# 仓库链接
|
||||
conf.update({"repo_url": market})
|
||||
# 本地标志
|
||||
conf.update({"is_local": False})
|
||||
# 汇总
|
||||
all_confs.append(conf)
|
||||
# 按插件ID去重
|
||||
if all_confs:
|
||||
all_confs = list({v["id"]: v for v in all_confs}.values())
|
||||
return all_confs
|
||||
|
||||
def get_local_plugins(self) -> List[dict]:
|
||||
"""
|
||||
获取所有本地已下载的插件信息
|
||||
"""
|
||||
# 返回值
|
||||
all_confs = []
|
||||
@@ -198,8 +382,13 @@ class PluginManager(metaclass=Singleton):
|
||||
else:
|
||||
conf.update({"installed": False})
|
||||
# 运行状态
|
||||
if plugin_obj and hasattr(plugin, "get_state"):
|
||||
conf.update({"state": plugin_obj.get_state()})
|
||||
if plugin_obj and hasattr(plugin_obj, "get_state"):
|
||||
try:
|
||||
state = plugin_obj.get_state()
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
|
||||
state = False
|
||||
conf.update({"state": state})
|
||||
else:
|
||||
conf.update({"state": False})
|
||||
# 是否有详情页面
|
||||
@@ -210,6 +399,7 @@ class PluginManager(metaclass=Singleton):
|
||||
conf.update({"has_page": False})
|
||||
# 权限
|
||||
if hasattr(plugin, "auth_level"):
|
||||
conf.update({"auth_level": plugin.auth_level})
|
||||
if self.siteshelper.auth_level < plugin.auth_level:
|
||||
continue
|
||||
# 名称
|
||||
@@ -224,15 +414,26 @@ class PluginManager(metaclass=Singleton):
|
||||
# 图标
|
||||
if hasattr(plugin, "plugin_icon"):
|
||||
conf.update({"plugin_icon": plugin.plugin_icon})
|
||||
# 主题色
|
||||
if hasattr(plugin, "plugin_color"):
|
||||
conf.update({"plugin_color": plugin.plugin_color})
|
||||
# 作者
|
||||
if hasattr(plugin, "plugin_author"):
|
||||
conf.update({"plugin_author": plugin.plugin_author})
|
||||
# 作者链接
|
||||
if hasattr(plugin, "author_url"):
|
||||
conf.update({"author_url": plugin.author_url})
|
||||
# 是否需要更新
|
||||
conf.update({"has_update": False})
|
||||
# 本地标志
|
||||
conf.update({"is_local": True})
|
||||
# 汇总
|
||||
all_confs.append(conf)
|
||||
return all_confs
|
||||
|
||||
@staticmethod
|
||||
def is_plugin_exists(pid: str) -> bool:
|
||||
"""
|
||||
判断插件是否存在
|
||||
"""
|
||||
if not pid:
|
||||
return False
|
||||
plugin_dir = settings.ROOT_PATH / "app" / "plugins" / pid.lower()
|
||||
return plugin_dir.exists()
|
||||
|
||||
@@ -3,12 +3,13 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Union, Optional
|
||||
from typing import Any, Union, Optional, Annotated
|
||||
import jwt
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad
|
||||
from fastapi import HTTPException, status, Depends
|
||||
from fastapi import HTTPException, status, Depends, Header
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from passlib.context import CryptContext
|
||||
|
||||
@@ -16,6 +17,8 @@ from app import schemas
|
||||
from app.core.config import settings
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.log import logger
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
@@ -26,7 +29,8 @@ reusable_oauth2 = OAuth2PasswordBearer(
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: Union[str, Any], expires_delta: timedelta = None
|
||||
userid: Union[str, Any], username: str, super_user: bool = False,
|
||||
expires_delta: timedelta = None
|
||||
) -> str:
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
@@ -34,7 +38,12 @@ def create_access_token(
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
to_encode = {
|
||||
"exp": expire,
|
||||
"sub": str(userid),
|
||||
"username": username,
|
||||
"super_user": super_user
|
||||
}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
@@ -52,6 +61,44 @@ def verify_token(token: str = Depends(reusable_oauth2)) -> schemas.TokenPayload:
|
||||
)
|
||||
|
||||
|
||||
def get_token(token: str = None) -> str:
|
||||
"""
|
||||
从请求URL中获取token
|
||||
"""
|
||||
return token
|
||||
|
||||
|
||||
def get_apikey(apikey: str = None, x_api_key: Annotated[str | None, Header()] = None) -> str:
|
||||
"""
|
||||
从请求URL中获取apikey
|
||||
"""
|
||||
return apikey or x_api_key
|
||||
|
||||
|
||||
def verify_uri_token(token: str = Depends(get_token)) -> str:
|
||||
"""
|
||||
通过依赖项使用token进行身份认证
|
||||
"""
|
||||
if token != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="token校验不通过"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def verify_uri_apikey(apikey: str = Depends(get_apikey)) -> str:
|
||||
"""
|
||||
通过依赖项使用apikey进行身份认证
|
||||
"""
|
||||
if apikey != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="apikey校验不通过"
|
||||
)
|
||||
return apikey
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
@@ -68,7 +115,7 @@ def decrypt(data: bytes, key: bytes) -> Optional[bytes]:
|
||||
try:
|
||||
return fernet.decrypt(data)
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
logger.error(f"解密失败:{str(e)} - {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
from typing import Any, Self, List
|
||||
from typing import Tuple, Optional, Generator
|
||||
|
||||
from sqlalchemy import create_engine, QueuePool
|
||||
from sqlalchemy.orm import sessionmaker, Session, scoped_session
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.orm import declared_attr
|
||||
from sqlalchemy.orm import sessionmaker, Session, scoped_session, as_declarative
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
@@ -9,20 +14,20 @@ Engine = create_engine(f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
||||
echo=False,
|
||||
poolclass=QueuePool,
|
||||
pool_size=1024,
|
||||
pool_recycle=600,
|
||||
pool_recycle=3600,
|
||||
pool_timeout=180,
|
||||
max_overflow=0,
|
||||
max_overflow=10,
|
||||
connect_args={"timeout": 60})
|
||||
# 会话工厂
|
||||
SessionFactory = sessionmaker(autocommit=False, autoflush=False, bind=Engine)
|
||||
SessionFactory = sessionmaker(bind=Engine)
|
||||
|
||||
# 多线程全局使用的数据库会话
|
||||
ScopedSession = scoped_session(SessionFactory)
|
||||
|
||||
|
||||
def get_db():
|
||||
def get_db() -> Generator:
|
||||
"""
|
||||
获取数据库会话
|
||||
获取数据库会话,用于WEB请求
|
||||
:return: Session
|
||||
"""
|
||||
db = None
|
||||
@@ -34,11 +39,156 @@ def get_db():
|
||||
db.close()
|
||||
|
||||
|
||||
def get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
|
||||
"""
|
||||
从参数中获取数据库Session对象
|
||||
"""
|
||||
db = None
|
||||
if args:
|
||||
for arg in args:
|
||||
if isinstance(arg, Session):
|
||||
db = arg
|
||||
break
|
||||
if kwargs:
|
||||
for key, value in kwargs.items():
|
||||
if isinstance(value, Session):
|
||||
db = value
|
||||
break
|
||||
return db
|
||||
|
||||
|
||||
def update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]:
|
||||
"""
|
||||
更新参数中的数据库Session对象,关键字传参时更新db的值,否则更新第1或第2个参数
|
||||
"""
|
||||
if kwargs and 'db' in kwargs:
|
||||
kwargs['db'] = db
|
||||
elif args:
|
||||
if args[0] is None:
|
||||
args = (db, *args[1:])
|
||||
else:
|
||||
args = (args[0], db, *args[2:])
|
||||
return args, kwargs
|
||||
|
||||
|
||||
def db_update(func):
|
||||
"""
|
||||
数据库更新类操作装饰器,第一个参数必须是数据库会话或存在db参数
|
||||
"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
# 是否关闭数据库会话
|
||||
_close_db = False
|
||||
# 从参数中获取数据库会话
|
||||
db = get_args_db(args, kwargs)
|
||||
if not db:
|
||||
# 如果没有获取到数据库会话,创建一个
|
||||
db = ScopedSession()
|
||||
# 标记需要关闭数据库会话
|
||||
_close_db = True
|
||||
# 更新参数中的数据库会话
|
||||
args, kwargs = update_args_db(args, kwargs, db)
|
||||
try:
|
||||
# 执行函数
|
||||
result = func(*args, **kwargs)
|
||||
# 提交事务
|
||||
db.commit()
|
||||
except Exception as err:
|
||||
# 回滚事务
|
||||
db.rollback()
|
||||
raise err
|
||||
finally:
|
||||
# 关闭数据库会话
|
||||
if _close_db:
|
||||
db.close()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def db_query(func):
|
||||
"""
|
||||
数据库查询操作装饰器,第一个参数必须是数据库会话或存在db参数
|
||||
注意:db.query列表数据时,需要转换为list返回
|
||||
"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
# 是否关闭数据库会话
|
||||
_close_db = False
|
||||
# 从参数中获取数据库会话
|
||||
db = get_args_db(args, kwargs)
|
||||
if not db:
|
||||
# 如果没有获取到数据库会话,创建一个
|
||||
db = ScopedSession()
|
||||
# 标记需要关闭数据库会话
|
||||
_close_db = True
|
||||
# 更新参数中的数据库会话
|
||||
args, kwargs = update_args_db(args, kwargs, db)
|
||||
try:
|
||||
# 执行函数
|
||||
result = func(*args, **kwargs)
|
||||
except Exception as err:
|
||||
raise err
|
||||
finally:
|
||||
# 关闭数据库会话
|
||||
if _close_db:
|
||||
db.close()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@as_declarative()
|
||||
class Base:
|
||||
id: Any
|
||||
__name__: str
|
||||
|
||||
@db_update
|
||||
def create(self, db: Session):
|
||||
db.add(self)
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get(cls, db: Session, rid: int) -> Self:
|
||||
return db.query(cls).filter(cls.id == rid).first()
|
||||
|
||||
@db_update
|
||||
def update(self, db: Session, payload: dict):
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
for key, value in payload.items():
|
||||
setattr(self, key, value)
|
||||
if inspect(self).detached:
|
||||
db.add(self)
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def delete(cls, db: Session, rid):
|
||||
db.query(cls).filter(cls.id == rid).delete()
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def truncate(cls, db: Session):
|
||||
db.query(cls).delete()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list(cls, db: Session) -> List[Self]:
|
||||
result = db.query(cls).all()
|
||||
return list(result)
|
||||
|
||||
def to_dict(self):
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
|
||||
|
||||
@declared_attr
|
||||
def __tablename__(self) -> str:
|
||||
return self.__name__.lower()
|
||||
|
||||
|
||||
class DbOper:
|
||||
"""
|
||||
数据库操作基类
|
||||
"""
|
||||
_db: Session = None
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
if db:
|
||||
self._db = db
|
||||
else:
|
||||
self._db = ScopedSession()
|
||||
self._db = db
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from app.db import DbOper
|
||||
@@ -10,12 +9,12 @@ class DownloadHistoryOper(DbOper):
|
||||
下载历史管理
|
||||
"""
|
||||
|
||||
def get_by_path(self, path: Path) -> DownloadHistory:
|
||||
def get_by_path(self, path: str) -> DownloadHistory:
|
||||
"""
|
||||
按路径查询下载记录
|
||||
:param path: 数据key
|
||||
"""
|
||||
return DownloadHistory.get_by_path(self._db, str(path))
|
||||
return DownloadHistory.get_by_path(self._db, path)
|
||||
|
||||
def get_by_hash(self, download_hash: str) -> DownloadHistory:
|
||||
"""
|
||||
@@ -24,12 +23,11 @@ class DownloadHistoryOper(DbOper):
|
||||
"""
|
||||
return DownloadHistory.get_by_hash(self._db, download_hash)
|
||||
|
||||
def add(self, **kwargs) -> DownloadHistory:
|
||||
def add(self, **kwargs):
|
||||
"""
|
||||
新增下载历史
|
||||
"""
|
||||
downloadhistory = DownloadHistory(**kwargs)
|
||||
return downloadhistory.create(self._db)
|
||||
DownloadHistory(**kwargs).create(self._db)
|
||||
|
||||
def add_files(self, file_items: List[dict]):
|
||||
"""
|
||||
@@ -58,7 +56,14 @@ class DownloadHistoryOper(DbOper):
|
||||
按fullpath查询下载文件记录
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
return DownloadFiles.get_by_fullpath(self._db, fullpath)
|
||||
return DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False)
|
||||
|
||||
def get_files_by_fullpath(self, fullpath: str) -> List[DownloadFiles]:
|
||||
"""
|
||||
按fullpath查询下载文件记录
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
return DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=True)
|
||||
|
||||
def get_files_by_savepath(self, fullpath: str) -> List[DownloadFiles]:
|
||||
"""
|
||||
@@ -74,6 +79,16 @@ class DownloadHistoryOper(DbOper):
|
||||
"""
|
||||
DownloadFiles.delete_by_fullpath(self._db, fullpath)
|
||||
|
||||
def get_hash_by_fullpath(self, fullpath: str) -> str:
|
||||
"""
|
||||
按fullpath查询下载文件记录hash
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False)
|
||||
if fileinfo:
|
||||
return fileinfo.download_hash
|
||||
return ""
|
||||
|
||||
def list_by_page(self, page: int = 1, count: int = 30) -> List[DownloadHistory]:
|
||||
"""
|
||||
分页查询下载历史
|
||||
@@ -98,3 +113,29 @@ class DownloadHistoryOper(DbOper):
|
||||
season=season,
|
||||
episode=episode,
|
||||
tmdbid=tmdbid)
|
||||
|
||||
def list_by_user_date(self, date: str, username: str = None) -> List[DownloadHistory]:
|
||||
"""
|
||||
查询某用户某时间之前的下载历史
|
||||
"""
|
||||
return DownloadHistory.list_by_user_date(db=self._db,
|
||||
date=date,
|
||||
username=username)
|
||||
|
||||
def list_by_date(self, date: str, type: str, tmdbid: str, seasons: str = None) -> List[DownloadHistory]:
|
||||
"""
|
||||
查询某时间之后的下载历史
|
||||
"""
|
||||
return DownloadHistory.list_by_date(db=self._db,
|
||||
date=date,
|
||||
type=type,
|
||||
tmdbid=tmdbid,
|
||||
seasons=seasons)
|
||||
|
||||
def list_by_type(self, mtype: str, days: int = 7) -> List[DownloadHistory]:
|
||||
"""
|
||||
获取指定类型的下载历史
|
||||
"""
|
||||
return DownloadHistory.list_by_type(db=self._db,
|
||||
mtype=mtype,
|
||||
days=days)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import random
|
||||
import string
|
||||
|
||||
from alembic.command import upgrade
|
||||
from alembic.config import Config
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.db import Engine, SessionFactory
|
||||
from app.db.models import Base
|
||||
from app.db.models.user import User
|
||||
from app.db import Engine, SessionFactory, Base
|
||||
from app.db.models import *
|
||||
from app.log import logger
|
||||
|
||||
|
||||
@@ -16,22 +15,29 @@ def init_db():
|
||||
"""
|
||||
初始化数据库
|
||||
"""
|
||||
# 导入模块,避免建表缺失
|
||||
for module in Path(__file__).with_name("models").glob("*.py"):
|
||||
importlib.import_module(f"app.db.models.{module.stem}")
|
||||
# 全量建表
|
||||
Base.metadata.create_all(bind=Engine)
|
||||
|
||||
|
||||
def init_super_user():
|
||||
"""
|
||||
初始化超级管理员
|
||||
"""
|
||||
# 初始化超级管理员
|
||||
db = SessionFactory()
|
||||
user = User.get_by_name(db=db, name=settings.SUPERUSER)
|
||||
if not user:
|
||||
user = User(
|
||||
name=settings.SUPERUSER,
|
||||
hashed_password=get_password_hash(settings.SUPERUSER_PASSWORD),
|
||||
is_superuser=True,
|
||||
)
|
||||
user.create(db)
|
||||
db.close()
|
||||
with SessionFactory() as db:
|
||||
_user = User.get_by_name(db=db, name=settings.SUPERUSER)
|
||||
if not _user:
|
||||
# 定义包含数字、大小写字母的字符集合
|
||||
characters = string.ascii_letters + string.digits
|
||||
# 生成随机密码
|
||||
random_password = ''.join(random.choice(characters) for _ in range(16))
|
||||
logger.info(f"【超级管理员初始密码】{random_password} 请登录系统后在设定中修改。 注:该密码只会显示一次,请注意保存。")
|
||||
_user = User(
|
||||
name=settings.SUPERUSER,
|
||||
hashed_password=get_password_hash(random_password),
|
||||
is_superuser=True,
|
||||
)
|
||||
_user.create(db)
|
||||
|
||||
|
||||
def update_db():
|
||||
@@ -39,11 +45,11 @@ def update_db():
|
||||
更新数据库
|
||||
"""
|
||||
db_location = settings.CONFIG_PATH / 'user.db'
|
||||
script_location = settings.ROOT_PATH / 'alembic'
|
||||
script_location = settings.ROOT_PATH / 'database'
|
||||
try:
|
||||
alembic_cfg = Config()
|
||||
alembic_cfg.set_main_option('script_location', str(script_location))
|
||||
alembic_cfg.set_main_option('sqlalchemy.url', f"sqlite:///{db_location}")
|
||||
upgrade(alembic_cfg, 'head')
|
||||
except Exception as e:
|
||||
logger.error(f'数据库更新失败:{e}')
|
||||
logger.error(f'数据库更新失败:{str(e)}')
|
||||
|
||||
@@ -25,7 +25,7 @@ class MediaServerOper(DbOper):
|
||||
return True
|
||||
return False
|
||||
|
||||
def empty(self, server: str):
|
||||
def empty(self, server: Optional[str] = None):
|
||||
"""
|
||||
清空媒体服务器数据
|
||||
"""
|
||||
@@ -39,10 +39,12 @@ class MediaServerOper(DbOper):
|
||||
# 优先按TMDBID查
|
||||
item = MediaServerItem.exist_by_tmdbid(self._db, tmdbid=kwargs.get("tmdbid"),
|
||||
mtype=kwargs.get("mtype"))
|
||||
else:
|
||||
elif kwargs.get("title"):
|
||||
# 按标题、类型、年份查
|
||||
item = MediaServerItem.exists_by_title(self._db, title=kwargs.get("title"),
|
||||
mtype=kwargs.get("mtype"), year=kwargs.get("year"))
|
||||
else:
|
||||
return None
|
||||
if not item:
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,53 +1,9 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import as_declarative, declared_attr, Session
|
||||
|
||||
|
||||
@as_declarative()
|
||||
class Base:
|
||||
id: Any
|
||||
__name__: str
|
||||
|
||||
@staticmethod
|
||||
def commit(db: Session):
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as err:
|
||||
db.rollback()
|
||||
raise err
|
||||
|
||||
def create(self, db: Session):
|
||||
db.add(self)
|
||||
self.commit(db)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def get(cls, db: Session, rid: int):
|
||||
return db.query(cls).filter(cls.id == rid).first()
|
||||
|
||||
def update(self, db: Session, payload: dict):
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
for key, value in payload.items():
|
||||
setattr(self, key, value)
|
||||
Base.commit(db)
|
||||
|
||||
@classmethod
|
||||
def delete(cls, db: Session, rid):
|
||||
db.query(cls).filter(cls.id == rid).delete()
|
||||
Base.commit(db)
|
||||
|
||||
@classmethod
|
||||
def truncate(cls, db: Session):
|
||||
db.query(cls).delete()
|
||||
Base.commit(db)
|
||||
|
||||
@classmethod
|
||||
def list(cls, db: Session):
|
||||
return db.query(cls).all()
|
||||
|
||||
def to_dict(self):
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
|
||||
|
||||
@declared_attr
|
||||
def __tablename__(self) -> str:
|
||||
return self.__name__.lower()
|
||||
from .downloadhistory import DownloadHistory, DownloadFiles
|
||||
from .mediaserver import MediaServerItem
|
||||
from .plugindata import PluginData
|
||||
from .site import Site
|
||||
from .siteicon import SiteIcon
|
||||
from .subscribe import Subscribe
|
||||
from .systemconfig import SystemConfig
|
||||
from .transferhistory import TransferHistory
|
||||
from .user import User
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import time
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class DownloadHistory(Base):
|
||||
@@ -35,61 +37,121 @@ class DownloadHistory(Base):
|
||||
torrent_description = Column(String)
|
||||
# 种子站点
|
||||
torrent_site = Column(String)
|
||||
# 下载用户
|
||||
userid = Column(String)
|
||||
# 下载用户名/插件名
|
||||
username = Column(String)
|
||||
# 下载渠道
|
||||
channel = Column(String)
|
||||
# 创建时间
|
||||
date = Column(String)
|
||||
# 附加信息
|
||||
note = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_hash(db: Session, download_hash: str):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).first()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_page(db: Session, page: int = 1, count: int = 30):
|
||||
return db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()
|
||||
result = db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_path(db: Session, path: str):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.path == path).first()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_last_by(db: Session, mtype: str = None, title: str = None, year: int = None, season: str = None,
|
||||
episode: str = None, tmdbid: int = None):
|
||||
"""
|
||||
据tmdbid、season、season_episode查询转移记录
|
||||
"""
|
||||
result = None
|
||||
if tmdbid and not season and not episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).order_by(
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
if tmdbid and season and not episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
if tmdbid and season and episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧所有季集|电影
|
||||
if not season and not episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year).order_by(
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季
|
||||
if season and not episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
if result:
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_user_date(db: Session, date: str, username: str = None):
|
||||
"""
|
||||
查询某用户某时间之后的下载历史
|
||||
"""
|
||||
if username:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.date < date,
|
||||
DownloadHistory.username == username).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_date(db: Session, date: str, type: str, tmdbid: str, seasons: str = None):
|
||||
"""
|
||||
查询某时间之后的下载历史
|
||||
"""
|
||||
if seasons:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date > date,
|
||||
DownloadHistory.type == type,
|
||||
DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == seasons).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date > date,
|
||||
DownloadHistory.type == type,
|
||||
DownloadHistory.tmdbid == tmdbid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, days: int):
|
||||
result = db.query(DownloadHistory) \
|
||||
.filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * int(days)))
|
||||
).all()
|
||||
return list(result)
|
||||
|
||||
|
||||
class DownloadFiles(Base):
|
||||
"""
|
||||
@@ -112,23 +174,33 @@ class DownloadFiles(Base):
|
||||
state = Column(Integer, nullable=False, default=1)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_hash(db: Session, download_hash: str, state: int = None):
|
||||
if state:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash,
|
||||
DownloadFiles.state == state).all()
|
||||
result = db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash,
|
||||
DownloadFiles.state == state).all()
|
||||
else:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash).all()
|
||||
result = db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash).all()
|
||||
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
def get_by_fullpath(db: Session, fullpath: str):
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
||||
DownloadFiles.id.desc()).first()
|
||||
@db_query
|
||||
def get_by_fullpath(db: Session, fullpath: str, all_files: bool = False):
|
||||
if not all_files:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
||||
DownloadFiles.id.desc()).first()
|
||||
else:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
||||
DownloadFiles.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_savepath(db: Session, savepath: str):
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
|
||||
result = db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_update
|
||||
def delete_by_fullpath(db: Session, fullpath: str):
|
||||
db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath,
|
||||
DownloadFiles.state == 1).update(
|
||||
@@ -136,4 +208,3 @@ class DownloadFiles(Base):
|
||||
"state": 0
|
||||
}
|
||||
)
|
||||
Base.commit(db)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class MediaServerItem(Base):
|
||||
"""
|
||||
站点表
|
||||
媒体服务器媒体条目表
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
# 服务器类型
|
||||
@@ -41,20 +42,26 @@ class MediaServerItem(Base):
|
||||
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_itemid(db: Session, item_id: str):
|
||||
return db.query(MediaServerItem).filter(MediaServerItem.item_id == item_id).first()
|
||||
|
||||
@staticmethod
|
||||
def empty(db: Session, server: str):
|
||||
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
|
||||
Base.commit(db)
|
||||
@db_update
|
||||
def empty(db: Session, server: Optional[str] = None):
|
||||
if server is None:
|
||||
db.query(MediaServerItem).delete()
|
||||
else:
|
||||
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def exist_by_tmdbid(db: Session, tmdbid: int, mtype: str):
|
||||
return db.query(MediaServerItem).filter(MediaServerItem.tmdbid == tmdbid,
|
||||
MediaServerItem.item_type == mtype).first()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def exists_by_title(db: Session, title: str, mtype: str, year: str):
|
||||
return db.query(MediaServerItem).filter(MediaServerItem.title == title,
|
||||
MediaServerItem.item_type == mtype,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class PluginData(Base):
|
||||
@@ -14,18 +14,23 @@ class PluginData(Base):
|
||||
value = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_plugin_data(db: Session, plugin_id: str):
|
||||
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
result = db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_plugin_data_by_key(db: Session, plugin_id: str, key: str):
|
||||
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).first()
|
||||
|
||||
@staticmethod
|
||||
@db_update
|
||||
def del_plugin_data_by_key(db: Session, plugin_id: str, key: str):
|
||||
db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).delete()
|
||||
Base.commit(db)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_plugin_data_by_plugin_id(db: Session, plugin_id: str):
|
||||
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
result = db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
return list(result)
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class Site(Base):
|
||||
@@ -47,18 +47,23 @@ class Site(Base):
|
||||
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_domain(db: Session, domain: str):
|
||||
return db.query(Site).filter(Site.domain == domain).first()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_actives(db: Session):
|
||||
return db.query(Site).filter(Site.is_active == 1).all()
|
||||
result = db.query(Site).filter(Site.is_active == 1).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_order_by_pri(db: Session):
|
||||
return db.query(Site).order_by(Site.pri).all()
|
||||
result = db.query(Site).order_by(Site.pri).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_update
|
||||
def reset(db: Session):
|
||||
db.query(Site).delete()
|
||||
Base.commit(db)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, Base
|
||||
|
||||
|
||||
class SiteIcon(Base):
|
||||
@@ -19,5 +19,6 @@ class SiteIcon(Base):
|
||||
base64 = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_domain(db: Session, domain: str):
|
||||
return db.query(SiteIcon).filter(SiteIcon.domain == domain).first()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import time
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class Subscribe(Base):
|
||||
@@ -37,6 +39,12 @@ class Subscribe(Base):
|
||||
include = Column(String)
|
||||
# 排除
|
||||
exclude = Column(String)
|
||||
# 质量
|
||||
quality = Column(String)
|
||||
# 分辨率
|
||||
resolution = Column(String)
|
||||
# 特效
|
||||
effect = Column(String)
|
||||
# 总集数
|
||||
total_episode = Column(Integer)
|
||||
# 开始集数
|
||||
@@ -59,41 +67,74 @@ class Subscribe(Base):
|
||||
best_version = Column(Integer, default=0)
|
||||
# 当前优先级
|
||||
current_priority = Column(Integer)
|
||||
# 保存路径
|
||||
save_path = Column(String)
|
||||
# 是否使用 imdbid 搜索
|
||||
search_imdbid = Column(Integer, default=0)
|
||||
# 是否手动修改过总集数 0否 1是
|
||||
manual_total_episode = Column(Integer, default=0)
|
||||
|
||||
@staticmethod
|
||||
def exists(db: Session, tmdbid: int, season: int = None):
|
||||
if season:
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
|
||||
Subscribe.season == season).first()
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).first()
|
||||
@db_query
|
||||
def exists(db: Session, tmdbid: int = None, doubanid: str = None, season: int = None):
|
||||
if tmdbid:
|
||||
if season:
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
|
||||
Subscribe.season == season).first()
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).first()
|
||||
elif doubanid:
|
||||
return db.query(Subscribe).filter(Subscribe.doubanid == doubanid).first()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_state(db: Session, state: str):
|
||||
return db.query(Subscribe).filter(Subscribe.state == state).all()
|
||||
result = db.query(Subscribe).filter(Subscribe.state == state).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_tmdbid(db: Session, tmdbid: int, season: int = None):
|
||||
if season:
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
|
||||
Subscribe.season == season).all()
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).all()
|
||||
result = db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
|
||||
Subscribe.season == season).all()
|
||||
else:
|
||||
result = db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
def get_by_title(db: Session, title: str):
|
||||
@db_query
|
||||
def get_by_title(db: Session, title: str, season: int = None):
|
||||
if season:
|
||||
return db.query(Subscribe).filter(Subscribe.name == title,
|
||||
Subscribe.season == season).first()
|
||||
return db.query(Subscribe).filter(Subscribe.name == title).first()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_doubanid(db: Session, doubanid: str):
|
||||
return db.query(Subscribe).filter(Subscribe.doubanid == doubanid).first()
|
||||
|
||||
@db_update
|
||||
def delete_by_tmdbid(self, db: Session, tmdbid: int, season: int):
|
||||
subscrbies = self.get_by_tmdbid(db, tmdbid, season)
|
||||
for subscrbie in subscrbies:
|
||||
subscrbie.delete(db, subscrbie.id)
|
||||
return True
|
||||
|
||||
@db_update
|
||||
def delete_by_doubanid(self, db: Session, doubanid: str):
|
||||
subscribe = self.get_by_doubanid(db, doubanid)
|
||||
if subscribe:
|
||||
subscribe.delete(db, subscribe.id)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, days: int):
|
||||
result = db.query(Subscribe) \
|
||||
.filter(Subscribe.type == mtype,
|
||||
Subscribe.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * int(days)))
|
||||
).all()
|
||||
return list(result)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
@@ -15,9 +15,11 @@ class SystemConfig(Base):
|
||||
value = Column(String, nullable=True)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_key(db: Session, key: str):
|
||||
return db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||
|
||||
@db_update
|
||||
def delete_by_key(self, db: Session, key: str):
|
||||
systemconfig = self.get_by_key(db, key)
|
||||
if systemconfig:
|
||||
|
||||
@@ -3,7 +3,7 @@ import time
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Boolean, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class TransferHistory(Base):
|
||||
@@ -47,25 +47,49 @@ class TransferHistory(Base):
|
||||
files = Column(String)
|
||||
|
||||
@staticmethod
|
||||
def list_by_title(db: Session, title: str, page: int = 1, count: int = 30):
|
||||
return db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%')).order_by(
|
||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
@db_query
|
||||
def list_by_title(db: Session, title: str, page: int = 1, count: int = 30, status: bool = None):
|
||||
if status is not None:
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%'),
|
||||
TransferHistory.status == status).order_by(
|
||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
else:
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%')).order_by(
|
||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
def list_by_page(db: Session, page: int = 1, count: int = 30):
|
||||
return db.query(TransferHistory).order_by(TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
@db_query
|
||||
def list_by_page(db: Session, page: int = 1, count: int = 30, status: bool = None):
|
||||
if status is not None:
|
||||
result = db.query(TransferHistory).filter(TransferHistory.status == status).order_by(
|
||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
else:
|
||||
result = db.query(TransferHistory).order_by(TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_hash(db: Session, download_hash: str):
|
||||
return db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).first()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_src(db: Session, src: str):
|
||||
return db.query(TransferHistory).filter(TransferHistory.src == src).first()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_hash(db: Session, download_hash: str):
|
||||
result = db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def statistic(db: Session, days: int = 7):
|
||||
"""
|
||||
统计最近days天的下载历史数量,按日期分组返回每日数量
|
||||
@@ -74,74 +98,96 @@ class TransferHistory(Base):
|
||||
TransferHistory.id.label('id')).filter(
|
||||
TransferHistory.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * days))).subquery()
|
||||
return db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all()
|
||||
result = db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
def count(db: Session):
|
||||
return db.query(func.count(TransferHistory.id)).first()[0]
|
||||
@db_query
|
||||
def count(db: Session, status: bool = None):
|
||||
if status is not None:
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.status == status).first()[0]
|
||||
else:
|
||||
return db.query(func.count(TransferHistory.id)).first()[0]
|
||||
|
||||
@staticmethod
|
||||
def count_by_title(db: Session, title: str):
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%')).first()[0]
|
||||
@db_query
|
||||
def count_by_title(db: Session, title: str, status: bool = None):
|
||||
if status is not None:
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%'),
|
||||
TransferHistory.status == status).first()[0]
|
||||
else:
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%')).first()[0]
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by(db: Session, mtype: str = None, title: str = None, year: str = None, season: str = None,
|
||||
episode: str = None, tmdbid: int = None, dest: str = None):
|
||||
"""
|
||||
据tmdbid、season、season_episode查询转移记录
|
||||
tmdbid + mtype 或 title + year 必输
|
||||
"""
|
||||
result = None
|
||||
# TMDBID + 类型
|
||||
if tmdbid and mtype:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season).all()
|
||||
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season).all()
|
||||
else:
|
||||
if dest:
|
||||
# 电影
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.dest == dest).all()
|
||||
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.dest == dest).all()
|
||||
else:
|
||||
# 电视剧所有季集
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype).all()
|
||||
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype).all()
|
||||
# 标题 + 年份
|
||||
elif title and year:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season).all()
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season).all()
|
||||
else:
|
||||
if dest:
|
||||
# 电影
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.dest == dest).all()
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.dest == dest).all()
|
||||
else:
|
||||
# 电视剧所有季集
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year).all()
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year).all()
|
||||
# 类型 + 转移路径(emby webhook season无tmdbid场景)
|
||||
elif mtype and season and dest:
|
||||
# 电视剧某季
|
||||
result = db.query(TransferHistory).filter(TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.dest.like(f"{dest}%")).all()
|
||||
|
||||
if result:
|
||||
return list(result)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_type_tmdbid(db: Session, mtype: str = None, tmdbid: int = None):
|
||||
"""
|
||||
据tmdbid、type查询转移记录
|
||||
@@ -150,10 +196,10 @@ class TransferHistory(Base):
|
||||
TransferHistory.type == mtype).first()
|
||||
|
||||
@staticmethod
|
||||
@db_update
|
||||
def update_download_hash(db: Session, historyid: int = None, download_hash: str = None):
|
||||
db.query(TransferHistory).filter(TransferHistory.id == historyid).update(
|
||||
{
|
||||
"download_hash": download_hash
|
||||
}
|
||||
)
|
||||
Base.commit(db)
|
||||
|
||||
@@ -2,7 +2,7 @@ from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import verify_password
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
@@ -25,6 +25,7 @@ class User(Base):
|
||||
avatar = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def authenticate(db: Session, name: str, password: str):
|
||||
user = db.query(User).filter(User.name == name).first()
|
||||
if not user:
|
||||
@@ -34,9 +35,11 @@ class User(Base):
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_name(db: Session, name: str):
|
||||
return db.query(User).filter(User.name == name).first()
|
||||
|
||||
@db_update
|
||||
def delete_by_name(self, db: Session, name: str):
|
||||
user = self.get_by_name(db, name)
|
||||
if user:
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
from typing import Any
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models.plugin import PluginData
|
||||
from app.db.models.plugindata import PluginData
|
||||
from app.utils.object import ObjectUtils
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class PluginDataOper(DbOper):
|
||||
插件数据管理
|
||||
"""
|
||||
|
||||
def save(self, plugin_id: str, key: str, value: Any) -> PluginData:
|
||||
def save(self, plugin_id: str, key: str, value: Any):
|
||||
"""
|
||||
保存插件数据
|
||||
:param plugin_id: 插件id
|
||||
@@ -25,23 +25,24 @@ class PluginDataOper(DbOper):
|
||||
plugin.update(self._db, {
|
||||
"value": value
|
||||
})
|
||||
return plugin
|
||||
else:
|
||||
plugin = PluginData(plugin_id=plugin_id, key=key, value=value)
|
||||
return plugin.create(self._db)
|
||||
PluginData(plugin_id=plugin_id, key=key, value=value).create(self._db)
|
||||
|
||||
def get_data(self, plugin_id: str, key: str) -> Any:
|
||||
def get_data(self, plugin_id: str, key: str = None) -> Any:
|
||||
"""
|
||||
获取插件数据
|
||||
:param plugin_id: 插件id
|
||||
:param key: 数据key
|
||||
"""
|
||||
data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
|
||||
if not data:
|
||||
return None
|
||||
if ObjectUtils.is_obj(data.value):
|
||||
return json.loads(data.value)
|
||||
return data.value
|
||||
if key:
|
||||
data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
|
||||
if not data:
|
||||
return None
|
||||
if ObjectUtils.is_obj(data.value):
|
||||
return json.loads(data.value)
|
||||
return data.value
|
||||
else:
|
||||
return PluginData.get_plugin_data(self._db, plugin_id)
|
||||
|
||||
def del_data(self, plugin_id: str, key: str) -> Any:
|
||||
"""
|
||||
|
||||
@@ -31,6 +31,12 @@ class SiteOper(DbOper):
|
||||
"""
|
||||
return Site.list(self._db)
|
||||
|
||||
def list_order_by_pri(self) -> List[Site]:
|
||||
"""
|
||||
获取站点列表
|
||||
"""
|
||||
return Site.list_order_by_pri(self._db)
|
||||
|
||||
def list_active(self) -> List[Site]:
|
||||
"""
|
||||
按状态获取站点列表
|
||||
|
||||
@@ -26,9 +26,9 @@ class SiteIconOper(DbOper):
|
||||
更新站点图标
|
||||
"""
|
||||
icon_base64 = f"data:image/ico;base64,{icon_base64}" if icon_base64 else ""
|
||||
siteicon = SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64)
|
||||
if not self.get_by_domain(domain):
|
||||
siteicon.create(self._db)
|
||||
siteicon = self.get_by_domain(domain)
|
||||
if not siteicon:
|
||||
SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64).create(self._db)
|
||||
elif icon_base64:
|
||||
siteicon.update(self._db, {
|
||||
"url": icon_url,
|
||||
|
||||
@@ -15,7 +15,10 @@ class SubscribeOper(DbOper):
|
||||
"""
|
||||
新增订阅
|
||||
"""
|
||||
subscribe = Subscribe.exists(self._db, tmdbid=mediainfo.tmdb_id, season=kwargs.get('season'))
|
||||
subscribe = Subscribe.exists(self._db,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
season=kwargs.get('season'))
|
||||
if not subscribe:
|
||||
subscribe = Subscribe(name=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
@@ -23,6 +26,7 @@ class SubscribeOper(DbOper):
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
tvdbid=mediainfo.tvdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
poster=mediainfo.get_poster_image(),
|
||||
backdrop=mediainfo.get_backdrop_image(),
|
||||
vote=mediainfo.vote_average,
|
||||
@@ -30,18 +34,27 @@ class SubscribeOper(DbOper):
|
||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
**kwargs)
|
||||
subscribe.create(self._db)
|
||||
# 查询订阅
|
||||
subscribe = Subscribe.exists(self._db,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
season=kwargs.get('season'))
|
||||
return subscribe.id, "新增订阅成功"
|
||||
else:
|
||||
return subscribe.id, "订阅已存在"
|
||||
|
||||
def exists(self, tmdbid: int, season: int) -> bool:
|
||||
def exists(self, tmdbid: int = None, doubanid: str = None, season: int = None) -> bool:
|
||||
"""
|
||||
判断是否存在
|
||||
"""
|
||||
if season:
|
||||
return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False
|
||||
else:
|
||||
return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False
|
||||
if tmdbid:
|
||||
if season:
|
||||
return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False
|
||||
else:
|
||||
return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False
|
||||
elif doubanid:
|
||||
return True if Subscribe.exists(self._db, doubanid=doubanid) else False
|
||||
return False
|
||||
|
||||
def get(self, sid: int) -> Subscribe:
|
||||
"""
|
||||
@@ -70,3 +83,9 @@ class SubscribeOper(DbOper):
|
||||
subscribe = self.get(sid)
|
||||
subscribe.update(self._db, payload)
|
||||
return subscribe
|
||||
|
||||
def list_by_type(self, mtype: str, days: int = 7) -> Subscribe:
|
||||
"""
|
||||
获取指定类型的订阅
|
||||
"""
|
||||
return Subscribe.list_by_type(self._db, mtype=mtype, days=days)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
from typing import Any, Union
|
||||
|
||||
from app.db import DbOper, SessionFactory
|
||||
from app.db import DbOper
|
||||
from app.db.models.systemconfig import SystemConfig
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.object import ObjectUtils
|
||||
@@ -16,8 +16,7 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
"""
|
||||
加载配置到内存
|
||||
"""
|
||||
self._db = SessionFactory()
|
||||
super().__init__(self._db)
|
||||
super().__init__()
|
||||
for item in SystemConfig.list(self._db):
|
||||
if ObjectUtils.is_obj(item.value):
|
||||
self.__SYSTEMCONF[item.key] = json.loads(item.value)
|
||||
@@ -57,6 +56,20 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
return self.__SYSTEMCONF
|
||||
return self.__SYSTEMCONF.get(key)
|
||||
|
||||
def delete(self, key: Union[str, SystemConfigKey]):
|
||||
"""
|
||||
删除系统设置
|
||||
"""
|
||||
if isinstance(key, SystemConfigKey):
|
||||
key = key.value
|
||||
# 更新内存
|
||||
self.__SYSTEMCONF.pop(key, None)
|
||||
# 写入数据库
|
||||
conf = SystemConfig.get_by_key(self._db, key)
|
||||
if conf:
|
||||
conf.delete(self._db, conf.id)
|
||||
return True
|
||||
|
||||
def __del__(self):
|
||||
if self._db:
|
||||
self._db.close()
|
||||
|
||||
@@ -36,14 +36,21 @@ class TransferHistoryOper(DbOper):
|
||||
"""
|
||||
return TransferHistory.get_by_src(self._db, src)
|
||||
|
||||
def add(self, **kwargs) -> TransferHistory:
|
||||
def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
|
||||
"""
|
||||
按种子hash查询转移记录
|
||||
:param download_hash: 种子hash
|
||||
"""
|
||||
return TransferHistory.list_by_hash(self._db, download_hash)
|
||||
|
||||
def add(self, **kwargs):
|
||||
"""
|
||||
新增转移历史
|
||||
"""
|
||||
kwargs.update({
|
||||
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
})
|
||||
return TransferHistory(**kwargs).create(self._db)
|
||||
TransferHistory(**kwargs).create(self._db)
|
||||
|
||||
def statistic(self, days: int = 7) -> List[Any]:
|
||||
"""
|
||||
@@ -96,7 +103,8 @@ class TransferHistoryOper(DbOper):
|
||||
kwargs.update({
|
||||
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
})
|
||||
return TransferHistory(**kwargs).create(self._db)
|
||||
TransferHistory(**kwargs).create(self._db)
|
||||
return TransferHistory.get_by_src(self._db, kwargs.get("src"))
|
||||
|
||||
def update_download_hash(self, historyid, download_hash):
|
||||
"""
|
||||
@@ -112,7 +120,7 @@ class TransferHistoryOper(DbOper):
|
||||
"""
|
||||
self.add_force(
|
||||
src=str(src_path),
|
||||
dest=str(transferinfo.target_path),
|
||||
dest=str(transferinfo.target_path or ''),
|
||||
mode=mode,
|
||||
type=mediainfo.type.value,
|
||||
category=mediainfo.category,
|
||||
@@ -138,7 +146,7 @@ class TransferHistoryOper(DbOper):
|
||||
if mediainfo and transferinfo:
|
||||
his = self.add_force(
|
||||
src=str(src_path),
|
||||
dest=str(transferinfo.target_path),
|
||||
dest=str(transferinfo.target_path or ''),
|
||||
mode=mode,
|
||||
type=mediainfo.type.value,
|
||||
category=mediainfo.category,
|
||||
|
||||
@@ -49,11 +49,11 @@ class PlaywrightHelper:
|
||||
# 回调函数
|
||||
return callback(page)
|
||||
except Exception as e:
|
||||
logger.error(f"网页操作失败: {e}")
|
||||
logger.error(f"网页操作失败: {str(e)}")
|
||||
finally:
|
||||
browser.close()
|
||||
except Exception as e:
|
||||
logger.error(f"网页操作失败: {e}")
|
||||
logger.error(f"网页操作失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_page_source(self, url: str,
|
||||
@@ -85,12 +85,12 @@ class PlaywrightHelper:
|
||||
page.wait_for_load_state("networkidle", timeout=timeout * 1000)
|
||||
source = page.content()
|
||||
except Exception as e:
|
||||
logger.error(f"获取网页源码失败: {e}")
|
||||
logger.error(f"获取网页源码失败: {str(e)}")
|
||||
source = None
|
||||
finally:
|
||||
browser.close()
|
||||
except Exception as e:
|
||||
logger.error(f"获取网页源码失败: {e}")
|
||||
logger.error(f"获取网页源码失败: {str(e)}")
|
||||
return source
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from playwright.sync_api import Page
|
||||
|
||||
from app.helper.browser import PlaywrightHelper
|
||||
from app.helper.ocr import OcrHelper
|
||||
from app.helper.twofa import TwoFactorAuth
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.site import SiteUtils
|
||||
@@ -23,14 +24,17 @@ class CookieHelper:
|
||||
"password": [
|
||||
'//input[@name="password"]',
|
||||
'//input[@id="form_item_password"]',
|
||||
'//input[@id="password"]'
|
||||
'//input[@id="password"]',
|
||||
'//input[@type="password"]'
|
||||
],
|
||||
"captcha": [
|
||||
'//input[@name="imagestring"]',
|
||||
'//input[@name="captcha"]',
|
||||
'//input[@id="form_item_captcha"]'
|
||||
'//input[@id="form_item_captcha"]',
|
||||
'//input[@placeholder="驗證碼"]'
|
||||
],
|
||||
"captcha_img": [
|
||||
'//img[@alt="captcha"]/@src',
|
||||
'//img[@alt="CAPTCHA"]/@src',
|
||||
'//img[@alt="SECURITY CODE"]/@src',
|
||||
'//img[@id="LAY-user-get-vercode"]/@src',
|
||||
@@ -48,7 +52,8 @@ class CookieHelper:
|
||||
],
|
||||
"twostep": [
|
||||
'//input[@name="two_step_code"]',
|
||||
'//input[@name="2fa_secret"]'
|
||||
'//input[@name="2fa_secret"]',
|
||||
'//input[@name="otp"]'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -68,12 +73,14 @@ class CookieHelper:
|
||||
url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
two_step_code: str = None,
|
||||
proxies: dict = None) -> Tuple[Optional[str], Optional[str], str]:
|
||||
"""
|
||||
获取站点cookie和ua
|
||||
:param url: 站点地址
|
||||
:param username: 用户名
|
||||
:param password: 密码
|
||||
:param two_step_code: 二步验证码或密钥
|
||||
:param proxies: 代理
|
||||
:return: cookie、ua、message
|
||||
"""
|
||||
@@ -104,6 +111,15 @@ class CookieHelper:
|
||||
break
|
||||
if not password_xpath:
|
||||
return None, None, "未找到密码输入框"
|
||||
# 处理二步验证码
|
||||
otp_code = TwoFactorAuth(two_step_code).get_code()
|
||||
# 查找二步验证码输入框
|
||||
twostep_xpath = None
|
||||
if otp_code:
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
|
||||
if html.xpath(xpath):
|
||||
twostep_xpath = xpath
|
||||
break
|
||||
# 查找验证码输入框
|
||||
captcha_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("captcha"):
|
||||
@@ -135,6 +151,9 @@ class CookieHelper:
|
||||
page.fill(username_xpath, username)
|
||||
# 输入密码
|
||||
page.fill(password_xpath, password)
|
||||
# 输入二步验证码
|
||||
if twostep_xpath:
|
||||
page.fill(twostep_xpath, otp_code)
|
||||
# 识别验证码
|
||||
if captcha_xpath and captcha_img_url:
|
||||
captcha_element = page.query_selector(captcha_xpath)
|
||||
@@ -159,8 +178,26 @@ class CookieHelper:
|
||||
page.click(submit_xpath)
|
||||
page.wait_for_load_state("networkidle", timeout=30 * 1000)
|
||||
except Exception as e:
|
||||
logger.error(f"仿真登录失败:{e}")
|
||||
return None, None, f"仿真登录失败:{e}"
|
||||
logger.error(f"仿真登录失败:{str(e)}")
|
||||
return None, None, f"仿真登录失败:{str(e)}"
|
||||
# 对于某二次验证码为单页面的站点,输入二次验证码
|
||||
if "verify" in page.url:
|
||||
if not otp_code:
|
||||
return None, None, "需要二次验证码"
|
||||
html = etree.HTML(page.content())
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
|
||||
if html.xpath(xpath):
|
||||
try:
|
||||
# 刷新一下 2fa code
|
||||
otp_code = TwoFactorAuth(two_step_code).get_code()
|
||||
page.fill(xpath, otp_code)
|
||||
# 登录按钮 xpath 理论上相同,不再重复查找
|
||||
page.click(submit_xpath)
|
||||
page.wait_for_load_state("networkidle", timeout=30 * 1000)
|
||||
except Exception as e:
|
||||
logger.error(f"二次验证码输入失败:{str(e)}")
|
||||
return None, None, f"二次验证码输入失败:{str(e)}"
|
||||
break
|
||||
# 登录后的源码
|
||||
html_text = page.content()
|
||||
if not html_text:
|
||||
|
||||
@@ -8,7 +8,7 @@ class CookieCloudHelper:
|
||||
|
||||
_ignore_cookies: list = ["CookieAutoDeleteBrowsingDataCleanup", "CookieAutoDeleteCleaningDiscarded"]
|
||||
|
||||
def __init__(self, server, key, password):
|
||||
def __init__(self, server: str, key: str, password: str):
|
||||
self._server = server
|
||||
self._key = key
|
||||
self._password = password
|
||||
@@ -21,8 +21,8 @@ class CookieCloudHelper:
|
||||
"""
|
||||
if not self._server or not self._key or not self._password:
|
||||
return None, "CookieCloud参数不正确"
|
||||
req_url = "%s/get/%s" % (self._server, self._key)
|
||||
ret = self._req.post_res(url=req_url, json={"password": self._password})
|
||||
req_url = "%s/get/%s" % (self._server, str(self._key).strip())
|
||||
ret = self._req.post_res(url=req_url, json={"password": str(self._password).strip()})
|
||||
if ret and ret.status_code == 200:
|
||||
result = ret.json()
|
||||
if not result:
|
||||
|
||||
@@ -2,17 +2,20 @@ from pyvirtualdisplay import Display
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class DisplayHelper(metaclass=Singleton):
|
||||
_display: Display = None
|
||||
|
||||
def __init__(self):
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
try:
|
||||
self._display = Display(visible=False, size=(1024, 768))
|
||||
self._display.start()
|
||||
except Exception as err:
|
||||
logger.error(f"DisplayHelper init error: {err}")
|
||||
logger.error(f"DisplayHelper init error: {str(err)}")
|
||||
|
||||
def stop(self):
|
||||
if self._display:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import importlib
|
||||
import pkgutil
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class ModuleHelper:
|
||||
@@ -20,14 +24,32 @@ class ModuleHelper:
|
||||
submodules: list = []
|
||||
packages = importlib.import_module(package_path)
|
||||
for importer, package_name, _ in pkgutil.iter_modules(packages.__path__):
|
||||
if package_name.startswith('_'):
|
||||
continue
|
||||
full_package_name = f'{package_path}.{package_name}'
|
||||
module = importlib.import_module(full_package_name)
|
||||
for name, obj in module.__dict__.items():
|
||||
if name.startswith('_'):
|
||||
try:
|
||||
if package_name.startswith('_'):
|
||||
continue
|
||||
if isinstance(obj, type) and filter_func(name, obj):
|
||||
submodules.append(obj)
|
||||
full_package_name = f'{package_path}.{package_name}'
|
||||
module = importlib.import_module(full_package_name)
|
||||
importlib.reload(module)
|
||||
for name, obj in module.__dict__.items():
|
||||
if name.startswith('_'):
|
||||
continue
|
||||
if isinstance(obj, type) and filter_func(name, obj):
|
||||
submodules.append(obj)
|
||||
except Exception as err:
|
||||
logger.error(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
|
||||
|
||||
return submodules
|
||||
|
||||
@staticmethod
|
||||
def dynamic_import_all_modules(base_path: Path, package_name: str):
|
||||
"""
|
||||
动态导入目录下所有模块
|
||||
"""
|
||||
modules = []
|
||||
# 遍历文件夹,找到所有模块文件
|
||||
for file in base_path.glob("*.py"):
|
||||
file_name = file.stem
|
||||
if file_name != "__init__":
|
||||
modules.append(file_name)
|
||||
full_module_name = f"{package_name}.{file_name}"
|
||||
importlib.import_module(full_module_name)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class NfoReader:
|
||||
@@ -8,6 +9,9 @@ class NfoReader:
|
||||
self.tree = ET.parse(xml_file_path)
|
||||
self.root = self.tree.getroot()
|
||||
|
||||
def get_element_value(self, element_path):
|
||||
def get_element_value(self, element_path) -> Optional[str]:
|
||||
element = self.root.find(element_path)
|
||||
return element.text if element is not None else None
|
||||
|
||||
def get_elements(self, element_path) -> List[ET.Element]:
|
||||
return self.root.findall(element_path)
|
||||
|
||||
157
app/helper/plugin.py
Normal file
157
app/helper/plugin.py
Normal file
@@ -0,0 +1,157 @@
|
||||
import json
|
||||
import shutil
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple, Optional, List
|
||||
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class PluginHelper(metaclass=Singleton):
|
||||
"""
|
||||
插件市场管理,下载安装插件到本地
|
||||
"""
|
||||
|
||||
_base_url = "https://raw.githubusercontent.com/%s/%s/main/"
|
||||
|
||||
@cached(cache=TTLCache(maxsize=100, ttl=1800))
|
||||
def get_plugins(self, repo_url: str) -> Dict[str, dict]:
|
||||
"""
|
||||
获取Github所有最新插件列表
|
||||
:param repo_url: Github仓库地址
|
||||
"""
|
||||
if not repo_url:
|
||||
return {}
|
||||
user, repo = self.get_repo_info(repo_url)
|
||||
if not user or not repo:
|
||||
return {}
|
||||
raw_url = self._base_url % (user, repo)
|
||||
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
|
||||
timeout=10).get_res(f"{raw_url}package.json")
|
||||
if res:
|
||||
try:
|
||||
return json.loads(res.text)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"插件包数据解析失败:{res.text}")
|
||||
return {}
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def get_repo_info(repo_url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
获取Github仓库信息
|
||||
:param repo_url: Github仓库地址
|
||||
"""
|
||||
if not repo_url:
|
||||
return None, None
|
||||
if not repo_url.endswith("/"):
|
||||
repo_url += "/"
|
||||
if repo_url.count("/") < 6:
|
||||
repo_url = f"{repo_url}main/"
|
||||
try:
|
||||
user, repo = repo_url.split("/")[-4:-2]
|
||||
except Exception as e:
|
||||
logger.error(f"解析Github仓库地址失败:{str(e)} - {traceback.format_exc()}")
|
||||
return None, None
|
||||
return user, repo
|
||||
|
||||
def install(self, pid: str, repo_url: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
安装插件
|
||||
"""
|
||||
if SystemUtils.is_frozen():
|
||||
return False, "可执行文件模式下,只能安装本地插件"
|
||||
|
||||
# 从Github的repo_url获取用户和项目名
|
||||
user, repo = self.get_repo_info(repo_url)
|
||||
if not user or not repo:
|
||||
return False, "不支持的插件仓库地址格式"
|
||||
|
||||
def __get_filelist(_p: str) -> Tuple[Optional[list], Optional[str]]:
|
||||
"""
|
||||
获取插件的文件列表
|
||||
"""
|
||||
file_api = f"https://api.github.com/repos/{user}/{repo}/contents/plugins/{_p.lower()}"
|
||||
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=30).get_res(file_api)
|
||||
if not r or r.status_code != 200:
|
||||
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
|
||||
ret = r.json()
|
||||
if ret and ret[0].get("message") == "Not Found":
|
||||
return None, "插件在仓库中不存在"
|
||||
return ret, ""
|
||||
|
||||
def __download_files(_p: str, _l: List[dict]) -> Tuple[bool, str]:
|
||||
"""
|
||||
下载插件文件
|
||||
"""
|
||||
if not _l:
|
||||
return False, "文件列表为空"
|
||||
for item in _l:
|
||||
if item.get("download_url"):
|
||||
# 下载插件文件
|
||||
res = RequestUtils(proxies=settings.PROXY,
|
||||
headers=settings.GITHUB_HEADERS, timeout=60).get_res(item["download_url"])
|
||||
if not res:
|
||||
return False, f"文件 {item.get('name')} 下载失败!"
|
||||
elif res.status_code != 200:
|
||||
return False, f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}"
|
||||
# 创建插件文件夹
|
||||
file_path = Path(settings.ROOT_PATH) / "app" / item.get("path")
|
||||
if not file_path.parent.exists():
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(res.text)
|
||||
else:
|
||||
# 递归下载子目录
|
||||
p = f"{_p}/{item.get('name')}"
|
||||
l, m = __get_filelist(p)
|
||||
if not l:
|
||||
return False, m
|
||||
__download_files(p, l)
|
||||
return True, ""
|
||||
|
||||
if not pid or not repo_url:
|
||||
return False, "参数错误"
|
||||
|
||||
# 获取插件的文件列表
|
||||
"""
|
||||
[
|
||||
{
|
||||
"name": "__init__.py",
|
||||
"path": "plugins/autobackup/__init__.py",
|
||||
"sha": "cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
|
||||
"size": 12385,
|
||||
"url": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/contents/plugins/autobackup/__init__.py?ref=main",
|
||||
"html_url": "https://github.com/jxxghp/MoviePilot-Plugins/blob/main/plugins/autobackup/__init__.py",
|
||||
"git_url": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/git/blobs/cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
|
||||
"download_url": "https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/plugins/autobackup/__init__.py",
|
||||
"type": "file",
|
||||
"_links": {
|
||||
"self": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/contents/plugins/autobackup/__init__.py?ref=main",
|
||||
"git": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/git/blobs/cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
|
||||
"html": "https://github.com/jxxghp/MoviePilot-Plugins/blob/main/plugins/autobackup/__init__.py"
|
||||
}
|
||||
}
|
||||
]
|
||||
"""
|
||||
# 获取第一级文件列表
|
||||
file_list, msg = __get_filelist(pid.lower())
|
||||
if not file_list:
|
||||
return False, msg
|
||||
# 本地存在时先删除
|
||||
plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
|
||||
if plugin_dir.exists():
|
||||
shutil.rmtree(plugin_dir, ignore_errors=True)
|
||||
# 下载所有文件
|
||||
__download_files(pid.lower(), file_list)
|
||||
# 插件目录下如有requirements.txt则安装依赖
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
if requirements_file.exists():
|
||||
SystemUtils.execute(f"pip install -r {requirements_file} > /dev/null 2>&1")
|
||||
return True, ""
|
||||
107
app/helper/resource.py
Normal file
107
app/helper/resource.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class ResourceHelper(metaclass=Singleton):
|
||||
"""
|
||||
检测和更新资源包
|
||||
"""
|
||||
# 资源包的git仓库地址
|
||||
_repo = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.json"
|
||||
_files_api = f"https://api.github.com/repos/jxxghp/MoviePilot-Resources/contents/resources"
|
||||
_base_dir: Path = settings.ROOT_PATH
|
||||
|
||||
def __init__(self):
|
||||
self.siteshelper = SitesHelper()
|
||||
self.check()
|
||||
|
||||
def check(self):
|
||||
"""
|
||||
检测是否有更新,如有则下载安装
|
||||
"""
|
||||
if not settings.AUTO_UPDATE_RESOURCE:
|
||||
return
|
||||
if SystemUtils.is_frozen():
|
||||
return
|
||||
logger.info("开始检测资源包版本...")
|
||||
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo)
|
||||
if res:
|
||||
try:
|
||||
resource_info = json.loads(res.text)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("资源包仓库数据解析失败!")
|
||||
return
|
||||
else:
|
||||
logger.warn("无法连接资源包仓库!")
|
||||
return
|
||||
online_version = resource_info.get("version")
|
||||
if online_version:
|
||||
logger.info(f"最新资源包版本:v{online_version}")
|
||||
# 需要更新的资源包
|
||||
need_updates = {}
|
||||
# 资源明细
|
||||
resources: dict = resource_info.get("resources") or {}
|
||||
for rname, resource in resources.items():
|
||||
rtype = resource.get("type")
|
||||
platform = resource.get("platform")
|
||||
target = resource.get("target")
|
||||
version = resource.get("version")
|
||||
# 判断平台
|
||||
if platform and platform != SystemUtils.platform:
|
||||
continue
|
||||
# 判断本地是否存在
|
||||
local_path = self._base_dir / target / rname
|
||||
if not local_path.exists():
|
||||
continue
|
||||
# 判断版本号
|
||||
if rtype == "auth":
|
||||
# 站点认证资源
|
||||
local_version = self.siteshelper.auth_version
|
||||
elif rtype == "sites":
|
||||
# 站点索引资源
|
||||
local_version = self.siteshelper.indexer_version
|
||||
else:
|
||||
continue
|
||||
if StringUtils.compare_version(version, local_version) > 0:
|
||||
logger.info(f"{rname} 资源包有更新,最新版本:v{version}")
|
||||
else:
|
||||
continue
|
||||
# 需要安装
|
||||
need_updates[rname] = target
|
||||
if need_updates:
|
||||
# 下载文件信息列表
|
||||
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
|
||||
timeout=30).get_res(self._files_api)
|
||||
if not r or r.status_code != 200:
|
||||
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
|
||||
files_info = r.json()
|
||||
for item in files_info:
|
||||
save_path = need_updates.get(item.get("name"))
|
||||
if not save_path:
|
||||
continue
|
||||
if item.get("download_url"):
|
||||
# 下载资源文件
|
||||
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
|
||||
timeout=180).get_res(item["download_url"])
|
||||
if not res:
|
||||
logger.error(f"文件 {item.get('name')} 下载失败!")
|
||||
elif res.status_code != 200:
|
||||
logger.error(f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}")
|
||||
# 创建插件文件夹
|
||||
file_path = self._base_dir / save_path / item.get("name")
|
||||
if not file_path.parent.exists():
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 写入文件
|
||||
file_path.write_bytes(res.content)
|
||||
logger.info("资源包更新完成,开始重启服务...")
|
||||
SystemUtils.restart()
|
||||
else:
|
||||
logger.info("所有资源已最新,无需更新")
|
||||
@@ -1,11 +1,15 @@
|
||||
import re
|
||||
import traceback
|
||||
import xml.dom.minidom
|
||||
from typing import List, Tuple, Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import chardet
|
||||
from lxml import etree
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.browser import PlaywrightHelper
|
||||
from app.log import logger
|
||||
from app.utils.dom import DomUtils
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
@@ -221,11 +225,12 @@ class RssHelper:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse(url, proxy: bool = False) -> Union[List[dict], None]:
|
||||
def parse(url, proxy: bool = False, timeout: int = 30) -> Union[List[dict], None]:
|
||||
"""
|
||||
解析RSS订阅URL,获取RSS中的种子信息
|
||||
:param url: RSS地址
|
||||
:param proxy: 是否使用代理
|
||||
:param timeout: 请求超时
|
||||
:return: 种子信息列表,如为None代表Rss过期
|
||||
"""
|
||||
# 开始处理
|
||||
@@ -233,16 +238,35 @@ class RssHelper:
|
||||
if not url:
|
||||
return []
|
||||
try:
|
||||
ret = RequestUtils(proxies=settings.PROXY if proxy else None).get_res(url)
|
||||
ret = RequestUtils(proxies=settings.PROXY if proxy else None, timeout=timeout).get_res(url)
|
||||
if not ret:
|
||||
return []
|
||||
ret.encoding = ret.apparent_encoding
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
logger.error(f"获取RSS失败:{str(err)} - {traceback.format_exc()}")
|
||||
return []
|
||||
if ret:
|
||||
ret_xml = ret.text
|
||||
ret_xml = ""
|
||||
try:
|
||||
# 使用chardet检测字符编码
|
||||
raw_data = ret.content
|
||||
if raw_data:
|
||||
try:
|
||||
result = chardet.detect(raw_data)
|
||||
encoding = result['encoding']
|
||||
# 解码为字符串
|
||||
ret_xml = raw_data.decode(encoding)
|
||||
except Exception as e:
|
||||
logger.debug(f"chardet解码失败:{str(e)}")
|
||||
# 探测utf-8解码
|
||||
match = re.search(r'encoding\s*=\s*["\']([^"\']+)["\']', ret.text)
|
||||
if match:
|
||||
encoding = match.group(1)
|
||||
if encoding:
|
||||
ret_xml = raw_data.decode(encoding)
|
||||
else:
|
||||
ret.encoding = ret.apparent_encoding
|
||||
if not ret_xml:
|
||||
ret_xml = ret.text
|
||||
# 解析XML
|
||||
dom_tree = xml.dom.minidom.parseString(ret_xml)
|
||||
rootNode = dom_tree.documentElement
|
||||
@@ -284,10 +308,10 @@ class RssHelper:
|
||||
'pubdate': pubdate}
|
||||
ret_array.append(tmp_dict)
|
||||
except Exception as e1:
|
||||
print(str(e1))
|
||||
logger.debug(f"解析RSS失败:{str(e1)} - {traceback.format_exc()}")
|
||||
continue
|
||||
except Exception as e2:
|
||||
print(str(e2))
|
||||
logger.error(f"解析RSS失败:{str(e2)} - {traceback.format_exc()}")
|
||||
# RSS过期 观众RSS 链接已过期,您需要获得一个新的! pthome RSS Link has expired, You need to get a new one!
|
||||
_rss_expired_msg = [
|
||||
"RSS 链接已过期, 您需要获得一个新的!",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
31
app/helper/thread.py
Normal file
31
app/helper/thread.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class ThreadHelper(metaclass=Singleton):
|
||||
"""
|
||||
线程池管理
|
||||
"""
|
||||
def __init__(self, max_workers=50):
|
||||
self.pool = ThreadPoolExecutor(max_workers=max_workers)
|
||||
|
||||
def submit(self, func, *args, **kwargs):
|
||||
"""
|
||||
提交任务
|
||||
:param func: 函数
|
||||
:param args: 参数
|
||||
:param kwargs: 参数
|
||||
:return: future
|
||||
"""
|
||||
return self.pool.submit(func, *args, **kwargs)
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
关闭线程池
|
||||
:return:
|
||||
"""
|
||||
self.pool.shutdown()
|
||||
|
||||
def __del__(self):
|
||||
self.shutdown()
|
||||
@@ -1,26 +1,32 @@
|
||||
import datetime
|
||||
import re
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional, List, Union
|
||||
from typing import Tuple, Optional, List, Union, Dict
|
||||
from urllib.parse import unquote
|
||||
|
||||
from requests import Response
|
||||
from torrentool.api import Torrent
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context
|
||||
from app.core.context import Context, TorrentInfo, MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.schemas.types import MediaType, SystemConfigKey
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class TorrentHelper:
|
||||
class TorrentHelper(metaclass=Singleton):
|
||||
"""
|
||||
种子帮助类
|
||||
"""
|
||||
|
||||
# 失败的种子:站点链接
|
||||
_invalid_torrents = []
|
||||
|
||||
def __init__(self):
|
||||
self.system_config = SystemConfigOper()
|
||||
|
||||
@@ -95,7 +101,7 @@ class TorrentHelper:
|
||||
logger.warn(f"触发了站点首次种子下载,且无法自动跳过:{url}")
|
||||
break
|
||||
except Exception as err:
|
||||
logger.warn(f"触发了站点首次种子下载,尝试自动跳过时出现错误:{err},链接:{url}")
|
||||
logger.warn(f"触发了站点首次种子下载,尝试自动跳过时出现错误:{str(err)},链接:{url}")
|
||||
if not skip_flag:
|
||||
return None, None, "", [], "种子数据有误,请确认链接是否正确,如为PT站点则需手工在站点下载一次种子"
|
||||
# 种子内容
|
||||
@@ -113,7 +119,7 @@ class TorrentHelper:
|
||||
# 成功拿到种子数据
|
||||
return file_path, req.content, folder_name, file_list, ""
|
||||
except Exception as err:
|
||||
logger.error(f"种子文件解析失败:{err}")
|
||||
logger.error(f"种子文件解析失败:{str(err)}")
|
||||
# 种子数据仍然错误
|
||||
return None, None, "", [], "种子数据有误,请确认链接是否正确"
|
||||
# 返回失败
|
||||
@@ -123,6 +129,8 @@ class TorrentHelper:
|
||||
elif req.status_code == 429:
|
||||
return None, None, "", [], "触发站点流控,请稍后重试"
|
||||
else:
|
||||
# 把错误的种子记下来,避免重复使用
|
||||
self.add_invalid(url)
|
||||
return None, None, "", [], f"下载种子出错,状态码:{req.status_code}"
|
||||
|
||||
@staticmethod
|
||||
@@ -157,10 +165,10 @@ class TorrentHelper:
|
||||
file_list.append(str(file_path.relative_to(root_path)))
|
||||
else:
|
||||
file_list.append(fileinfo.name)
|
||||
logger.info(f"解析种子:{torrent_path.name} => 目录:{folder_name},文件清单:{file_list}")
|
||||
logger.debug(f"解析种子:{torrent_path.name} => 目录:{folder_name},文件清单:{file_list}")
|
||||
return folder_name, file_list
|
||||
except Exception as err:
|
||||
logger.error(f"种子文件解析失败:{err}")
|
||||
logger.error(f"种子文件解析失败:{str(err)}")
|
||||
return "", []
|
||||
|
||||
@staticmethod
|
||||
@@ -276,3 +284,118 @@ class TorrentHelper:
|
||||
continue
|
||||
episodes = list(set(episodes).union(set(meta.episode_list)))
|
||||
return episodes
|
||||
|
||||
def is_invalid(self, url: str) -> bool:
|
||||
"""
|
||||
判断种子是否是无效种子
|
||||
"""
|
||||
return url in self._invalid_torrents
|
||||
|
||||
def add_invalid(self, url: str):
|
||||
"""
|
||||
添加无效种子
|
||||
"""
|
||||
if url not in self._invalid_torrents:
|
||||
self._invalid_torrents.append(url)
|
||||
|
||||
@staticmethod
|
||||
def filter_torrent(torrent_info: TorrentInfo,
|
||||
filter_rule: Dict[str, str],
|
||||
mediainfo: MediaInfo) -> bool:
|
||||
"""
|
||||
检查种子是否匹配订阅过滤规则
|
||||
"""
|
||||
|
||||
def __get_size_range(size_str: str) -> Tuple[float, float]:
|
||||
"""
|
||||
获取大小范围
|
||||
"""
|
||||
if not size_str:
|
||||
return 0, 0
|
||||
try:
|
||||
size_range = size_str.split("-")
|
||||
if len(size_range) == 1:
|
||||
return 0, float(size_range[0])
|
||||
elif len(size_range) == 2:
|
||||
return float(size_range[0]), float(size_range[1])
|
||||
except Exception as e:
|
||||
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
|
||||
return 0, 0
|
||||
|
||||
if not filter_rule:
|
||||
return True
|
||||
|
||||
# 匹配内容
|
||||
content = f"{torrent_info.title} {torrent_info.description} {' '.join(torrent_info.labels or [])}"
|
||||
|
||||
# 最少做种人数
|
||||
min_seeders = filter_rule.get("min_seeders")
|
||||
if min_seeders and torrent_info.seeders < int(min_seeders):
|
||||
logger.info(f"{torrent_info.title} 做种人数不足 {min_seeders}")
|
||||
return False
|
||||
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
if include:
|
||||
if not re.search(r"%s" % include, content, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
|
||||
return False
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude, content, re.I):
|
||||
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
# 质量
|
||||
quality = filter_rule.get("quality")
|
||||
if quality:
|
||||
if not re.search(r"%s" % quality, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}")
|
||||
return False
|
||||
# 分辨率
|
||||
resolution = filter_rule.get("resolution")
|
||||
if resolution:
|
||||
if not re.search(r"%s" % resolution, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}")
|
||||
return False
|
||||
# 特效
|
||||
effect = filter_rule.get("effect")
|
||||
if effect:
|
||||
if not re.search(r"%s" % effect, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
|
||||
return False
|
||||
|
||||
# 大小
|
||||
tv_size = filter_rule.get("tv_size")
|
||||
movie_size = filter_rule.get("movie_size")
|
||||
if movie_size or tv_size:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
size = tv_size
|
||||
else:
|
||||
size = movie_size
|
||||
# 大小范围
|
||||
begin_size, end_size = __get_size_range(size)
|
||||
if begin_size or end_size:
|
||||
meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description)
|
||||
# 集数
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧
|
||||
season = meta.begin_season or 1
|
||||
if meta.total_episode:
|
||||
# 识别的总集数
|
||||
episodes_num = meta.total_episode
|
||||
else:
|
||||
# 整季集数
|
||||
episodes_num = len(mediainfo.seasons.get(season) or [1])
|
||||
# 比较大小
|
||||
if not (begin_size * 1024 ** 3 <= (torrent_info.size / episodes_num) <= end_size * 1024 ** 3):
|
||||
logger.info(f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} "
|
||||
f"共{episodes_num}集,不匹配大小规则 {size}")
|
||||
return False
|
||||
else:
|
||||
# 电影比较大小
|
||||
if not (begin_size * 1024 ** 3 <= torrent_info.size <= end_size * 1024 ** 3):
|
||||
logger.info(
|
||||
f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} 不匹配大小规则 {size}")
|
||||
return False
|
||||
return True
|
||||
|
||||
43
app/helper/twofa.py
Normal file
43
app/helper/twofa.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class TwoFactorAuth:
|
||||
def __init__(self, code_or_secret: str):
|
||||
if code_or_secret and len(code_or_secret) > 16:
|
||||
self.code = None
|
||||
self.secret = code_or_secret
|
||||
else:
|
||||
self.code = code_or_secret
|
||||
self.secret = None
|
||||
|
||||
@staticmethod
|
||||
def __calc(secret_key: str) -> str:
|
||||
if not secret_key:
|
||||
return ""
|
||||
try:
|
||||
input_time = int(time.time()) // 30
|
||||
key = base64.b32decode(secret_key)
|
||||
msg = struct.pack(">Q", input_time)
|
||||
google_code = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
o = (
|
||||
google_code[19] & 15
|
||||
if sys.version_info > (2, 7)
|
||||
else ord(str(google_code[19])) & 15
|
||||
)
|
||||
google_code = str(
|
||||
(struct.unpack(">I", google_code[o: o + 4])[0] & 0x7FFFFFFF) % 1000000
|
||||
)
|
||||
return f"0{google_code}" if len(google_code) == 5 else google_code
|
||||
except Exception as e:
|
||||
logger.error(f"计算动态验证码失败:{str(e)}")
|
||||
return ""
|
||||
|
||||
def get_code(self) -> str:
|
||||
return self.code or self.__calc(self.secret)
|
||||
184
app/log.py
184
app/log.py
@@ -1,28 +1,14 @@
|
||||
import inspect
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
import click
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# logger
|
||||
logger = logging.getLogger()
|
||||
if settings.DEBUG:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 创建终端输出Handler
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
|
||||
# 创建文件输出Handler
|
||||
file_handler = RotatingFileHandler(filename=settings.LOG_PATH / 'moviepilot.log',
|
||||
mode='w',
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
# 日志级别颜色
|
||||
level_name_colors = {
|
||||
logging.DEBUG: lambda level_name: click.style(str(level_name), fg="cyan"),
|
||||
logging.INFO: lambda level_name: click.style(str(level_name), fg="green"),
|
||||
@@ -34,20 +20,164 @@ level_name_colors = {
|
||||
}
|
||||
|
||||
|
||||
# 定义日志输出格式
|
||||
class CustomFormatter(logging.Formatter):
|
||||
"""
|
||||
定义日志输出格式
|
||||
"""
|
||||
|
||||
def format(self, record):
|
||||
seperator = " " * (8 - len(record.levelname))
|
||||
record.leveltext = level_name_colors[record.levelno](record.levelname + ":") + seperator
|
||||
return super().format(record)
|
||||
|
||||
|
||||
# 终端日志
|
||||
console_formatter = CustomFormatter("%(leveltext)s%(filename)s - %(message)s")
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
class LoggerManager:
|
||||
"""
|
||||
日志管理
|
||||
"""
|
||||
# 管理所有的Logger
|
||||
_loggers: Dict[str, Any] = {}
|
||||
# 默认日志文件
|
||||
_default_log_file = "moviepilot.log"
|
||||
|
||||
# 文件日志
|
||||
file_formater = CustomFormatter("【%(levelname)s】%(asctime)s - %(filename)s - %(message)s")
|
||||
file_handler.setFormatter(file_formater)
|
||||
logger.addHandler(file_handler)
|
||||
@staticmethod
|
||||
def __get_caller():
|
||||
"""
|
||||
获取调用者的文件名称与插件名称(如果是插件调用内置的模块, 也能写入到插件日志文件中)
|
||||
"""
|
||||
# 调用者文件名称
|
||||
caller_name = None
|
||||
# 调用者插件名称
|
||||
plugin_name = None
|
||||
for i in inspect.stack()[3:]:
|
||||
filepath = Path(i.filename)
|
||||
parts = filepath.parts
|
||||
if not caller_name:
|
||||
# 设定调用者文件名称
|
||||
if parts[-1] == "__init__.py":
|
||||
caller_name = parts[-2]
|
||||
else:
|
||||
caller_name = parts[-1]
|
||||
if "app" in parts:
|
||||
if not plugin_name and "plugins" in parts:
|
||||
# 设定调用者插件名称
|
||||
plugin_name = parts[parts.index("plugins") + 1]
|
||||
if plugin_name == "__init__.py":
|
||||
plugin_name = "plugin"
|
||||
break
|
||||
if "main.py" in parts:
|
||||
# 已经到达程序的入口
|
||||
break
|
||||
elif len(parts) != 1:
|
||||
# 已经超出程序范围
|
||||
break
|
||||
return caller_name or "log.py", plugin_name
|
||||
|
||||
@staticmethod
|
||||
def __setup_logger(log_file: str):
|
||||
"""
|
||||
设置日志
|
||||
log_file:日志文件相对路径
|
||||
"""
|
||||
log_file_path = settings.LOG_PATH / log_file
|
||||
if not log_file_path.parent.exists():
|
||||
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 创建新实例
|
||||
_logger = logging.getLogger(log_file_path.stem)
|
||||
|
||||
# DEBUG
|
||||
if settings.DEBUG:
|
||||
_logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
_logger.setLevel(logging.INFO)
|
||||
|
||||
# 移除已有的 handler,避免重复添加
|
||||
for handler in _logger.handlers:
|
||||
_logger.removeHandler(handler)
|
||||
|
||||
# 终端日志
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
console_formatter = CustomFormatter(f"%(leveltext)s%(message)s")
|
||||
console_handler.setFormatter(console_formatter)
|
||||
_logger.addHandler(console_handler)
|
||||
|
||||
# 文件日志
|
||||
file_handler = RotatingFileHandler(filename=log_file_path,
|
||||
mode='w',
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_formater = CustomFormatter(f"【%(levelname)s】%(asctime)s - %(message)s")
|
||||
file_handler.setFormatter(file_formater)
|
||||
_logger.addHandler(file_handler)
|
||||
|
||||
return _logger
|
||||
|
||||
def logger(self, method: str, msg: str, *args, **kwargs):
|
||||
"""
|
||||
获取模块的logger
|
||||
:param method: 日志方法
|
||||
:param msg: 日志信息
|
||||
"""
|
||||
|
||||
# 获取调用者文件名和插件名
|
||||
caller_name, plugin_name = self.__get_caller()
|
||||
# 区分插件日志
|
||||
if plugin_name:
|
||||
# 使用插件日志文件
|
||||
logfile = Path("plugins") / f"{plugin_name}.log"
|
||||
else:
|
||||
# 使用默认日志文件
|
||||
logfile = self._default_log_file
|
||||
|
||||
# 获取调用者的模块的logger
|
||||
_logger = self._loggers.get(logfile)
|
||||
if not _logger:
|
||||
_logger = self.__setup_logger(logfile)
|
||||
self._loggers[logfile] = _logger
|
||||
if hasattr(_logger, method):
|
||||
method = getattr(_logger, method)
|
||||
method(f"{caller_name} - {msg}", *args, **kwargs)
|
||||
|
||||
def info(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载info方法
|
||||
"""
|
||||
self.logger("info", msg, *args, **kwargs)
|
||||
|
||||
def debug(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载debug方法
|
||||
"""
|
||||
self.logger("debug", msg, *args, **kwargs)
|
||||
|
||||
def warning(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载warning方法
|
||||
"""
|
||||
self.logger("warning", msg, *args, **kwargs)
|
||||
|
||||
def warn(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载warn方法
|
||||
"""
|
||||
self.logger("warning", msg, *args, **kwargs)
|
||||
|
||||
def error(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载error方法
|
||||
"""
|
||||
self.logger("error", msg, *args, **kwargs)
|
||||
|
||||
def critical(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载critical方法
|
||||
"""
|
||||
self.logger("critical", msg, *args, **kwargs)
|
||||
|
||||
|
||||
# 初始化公共日志
|
||||
logger = LoggerManager()
|
||||
|
||||
134
app/main.py
134
app/main.py
@@ -1,18 +1,33 @@
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import uvicorn as uvicorn
|
||||
from PIL import Image
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from uvicorn import Config
|
||||
|
||||
from app.command import Command
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
# 禁用输出
|
||||
if SystemUtils.is_frozen():
|
||||
sys.stdout = open(os.devnull, 'w')
|
||||
sys.stderr = open(os.devnull, 'w')
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db.init import init_db, update_db
|
||||
from app.db.init import init_db, update_db, init_super_user
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.helper.display import DisplayHelper
|
||||
from app.helper.resource import ResourceHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.command import Command, CommandChian
|
||||
from app.schemas import Notification, NotificationType
|
||||
|
||||
# App
|
||||
App = FastAPI(title=settings.PROJECT_NAME,
|
||||
@@ -44,6 +59,103 @@ def init_routers():
|
||||
App.include_router(arr_router, prefix="/api/v3")
|
||||
|
||||
|
||||
def start_frontend():
|
||||
"""
|
||||
启动前端服务
|
||||
"""
|
||||
# 仅Windows可执行文件支持内嵌nginx
|
||||
if not SystemUtils.is_frozen() \
|
||||
or not SystemUtils.is_windows():
|
||||
return
|
||||
# 临时Nginx目录
|
||||
nginx_path = settings.ROOT_PATH / 'nginx'
|
||||
if not nginx_path.exists():
|
||||
return
|
||||
# 配置目录下的Nginx目录
|
||||
run_nginx_dir = settings.CONFIG_PATH.with_name('nginx')
|
||||
if not run_nginx_dir.exists():
|
||||
# 移动到配置目录
|
||||
SystemUtils.move(nginx_path, run_nginx_dir)
|
||||
# 启动Nginx
|
||||
import subprocess
|
||||
subprocess.Popen("start nginx.exe",
|
||||
cwd=run_nginx_dir,
|
||||
shell=True)
|
||||
|
||||
|
||||
def stop_frontend():
|
||||
"""
|
||||
停止前端服务
|
||||
"""
|
||||
if not SystemUtils.is_frozen() \
|
||||
or not SystemUtils.is_windows():
|
||||
return
|
||||
import subprocess
|
||||
subprocess.Popen(f"taskkill /f /im nginx.exe", shell=True)
|
||||
|
||||
|
||||
def start_tray():
|
||||
"""
|
||||
启动托盘图标
|
||||
"""
|
||||
|
||||
if not SystemUtils.is_frozen():
|
||||
return
|
||||
|
||||
if not SystemUtils.is_windows():
|
||||
return
|
||||
|
||||
def open_web():
|
||||
"""
|
||||
调用浏览器打开前端页面
|
||||
"""
|
||||
import webbrowser
|
||||
webbrowser.open(f"http://localhost:{settings.NGINX_PORT}")
|
||||
|
||||
def quit_app():
|
||||
"""
|
||||
退出程序
|
||||
"""
|
||||
TrayIcon.stop()
|
||||
Server.should_exit = True
|
||||
|
||||
import pystray
|
||||
|
||||
# 托盘图标
|
||||
TrayIcon = pystray.Icon(
|
||||
settings.PROJECT_NAME,
|
||||
icon=Image.open(settings.ROOT_PATH / 'app.ico'),
|
||||
menu=pystray.Menu(
|
||||
pystray.MenuItem(
|
||||
'打开',
|
||||
open_web,
|
||||
),
|
||||
pystray.MenuItem(
|
||||
'退出',
|
||||
quit_app,
|
||||
)
|
||||
)
|
||||
)
|
||||
# 启动托盘图标
|
||||
threading.Thread(target=TrayIcon.run, daemon=True).start()
|
||||
|
||||
|
||||
def check_auth():
|
||||
"""
|
||||
检查认证状态
|
||||
"""
|
||||
if SitesHelper().auth_level < 2:
|
||||
err_msg = "用户认证失败,站点相关功能将无法使用!"
|
||||
MessageHelper().put(f"注意:{err_msg}")
|
||||
CommandChian().post_message(
|
||||
Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title="MoviePilot用户认证",
|
||||
text=err_msg
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@App.on_event("shutdown")
|
||||
def shutdown_server():
|
||||
"""
|
||||
@@ -59,6 +171,10 @@ def shutdown_server():
|
||||
DisplayHelper().stop()
|
||||
# 停止定时服务
|
||||
Scheduler().stop()
|
||||
# 停止线程池
|
||||
ThreadHelper().shutdown()
|
||||
# 停止前端服务
|
||||
stop_frontend()
|
||||
|
||||
|
||||
@App.on_event("startup")
|
||||
@@ -66,10 +182,14 @@ def start_module():
|
||||
"""
|
||||
启动模块
|
||||
"""
|
||||
# 虚伪显示
|
||||
# 初始化超级管理员
|
||||
init_super_user()
|
||||
# 虚拟显示
|
||||
DisplayHelper()
|
||||
# 站点管理
|
||||
SitesHelper()
|
||||
# 资源包检测
|
||||
ResourceHelper()
|
||||
# 加载模块
|
||||
ModuleManager()
|
||||
# 加载插件
|
||||
@@ -80,12 +200,18 @@ def start_module():
|
||||
Command()
|
||||
# 初始化路由
|
||||
init_routers()
|
||||
# 启动前端服务
|
||||
start_frontend()
|
||||
# 检查认证状态
|
||||
check_auth()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 启动托盘
|
||||
start_tray()
|
||||
# 初始化数据库
|
||||
init_db()
|
||||
# 更新数据库
|
||||
update_db()
|
||||
# 启动服务
|
||||
# 启动API服务
|
||||
Server.run()
|
||||
|
||||
@@ -35,6 +35,13 @@ class _ModuleBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
模块测试, 返回测试结果和错误信息
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def checkMessage(channel_type: MessageChannel):
|
||||
"""
|
||||
@@ -58,6 +65,10 @@ def checkMessage(channel_type: MessageChannel):
|
||||
return None
|
||||
if channel_type == MessageChannel.Slack and not switch.get("slack"):
|
||||
return None
|
||||
if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"):
|
||||
return None
|
||||
if channel_type == MessageChannel.VoceChat and not switch.get("vocechat"):
|
||||
return None
|
||||
return func(self, message, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
@@ -8,48 +9,431 @@ from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.douban.apiv2 import DoubanApi
|
||||
from app.modules.douban.douban_cache import DoubanCache
|
||||
from app.modules.douban.scraper import DoubanScraper
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class DoubanModule(_ModuleBase):
|
||||
|
||||
doubanapi: DoubanApi = None
|
||||
scraper: DoubanScraper = None
|
||||
cache: DoubanCache = None
|
||||
|
||||
def init_module(self) -> None:
|
||||
self.doubanapi = DoubanApi()
|
||||
self.scraper = DoubanScraper()
|
||||
self.cache = DoubanCache()
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils().get_res("https://movie.douban.com/")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
|
||||
return False, "豆瓣网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
def douban_info(self, doubanid: str) -> Optional[dict]:
|
||||
def recognize_media(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
doubanid: str = None,
|
||||
cache: bool = True,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与doubanid配套
|
||||
:param doubanid: 豆瓣ID
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "douban":
|
||||
return None
|
||||
|
||||
if not meta:
|
||||
cache_info = {}
|
||||
elif not meta.name:
|
||||
logger.error("识别媒体信息时未提供元数据名称")
|
||||
return None
|
||||
else:
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if doubanid:
|
||||
meta.doubanid = doubanid
|
||||
# 读取缓存
|
||||
cache_info = self.cache.get(meta)
|
||||
if not cache_info or not cache:
|
||||
# 缓存没有或者强制不使用缓存
|
||||
if doubanid:
|
||||
# 直接查询详情
|
||||
info = self.douban_info(doubanid=doubanid, mtype=mtype or meta.type)
|
||||
elif meta:
|
||||
if meta.begin_season:
|
||||
logger.info(f"正在识别 {meta.name} 第{meta.begin_season}季 ...")
|
||||
else:
|
||||
logger.info(f"正在识别 {meta.name} ...")
|
||||
# 匹配豆瓣信息
|
||||
match_info = self.match_doubaninfo(name=meta.name,
|
||||
mtype=mtype or meta.type,
|
||||
year=meta.year,
|
||||
season=meta.begin_season)
|
||||
if match_info:
|
||||
# 匹配到豆瓣信息
|
||||
info = self.douban_info(
|
||||
doubanid=match_info.get("id"),
|
||||
mtype=mtype or meta.type
|
||||
)
|
||||
else:
|
||||
logger.info(f"{meta.name if meta else doubanid} 未匹配到豆瓣媒体信息")
|
||||
return None
|
||||
else:
|
||||
logger.error("识别媒体信息时未提供元数据或豆瓣ID")
|
||||
return None
|
||||
# 保存到缓存
|
||||
if meta and cache:
|
||||
self.cache.update(meta, info)
|
||||
else:
|
||||
# 使用缓存信息
|
||||
if cache_info.get("title"):
|
||||
logger.info(f"{meta.name} 使用豆瓣识别缓存:{cache_info.get('title')}")
|
||||
info = self.douban_info(mtype=cache_info.get("type"),
|
||||
doubanid=cache_info.get("id"))
|
||||
else:
|
||||
logger.info(f"{meta.name} 使用豆瓣识别缓存:无法识别")
|
||||
info = None
|
||||
|
||||
if info:
|
||||
# 赋值TMDB信息并返回
|
||||
mediainfo = MediaInfo(douban_info=info)
|
||||
if meta:
|
||||
logger.info(f"{meta.name} 豆瓣识别结果:{mediainfo.type.value} "
|
||||
f"{mediainfo.title_year} "
|
||||
f"{mediainfo.douban_id}")
|
||||
else:
|
||||
logger.info(f"{doubanid} 豆瓣识别结果:{mediainfo.type.value} "
|
||||
f"{mediainfo.title_year}")
|
||||
return mediainfo
|
||||
else:
|
||||
logger.info(f"{meta.name if meta else doubanid} 未匹配到豆瓣媒体信息")
|
||||
|
||||
return None
|
||||
|
||||
def douban_info(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
|
||||
"""
|
||||
获取豆瓣信息
|
||||
:param doubanid: 豆瓣ID
|
||||
:param mtype: 媒体类型
|
||||
:return: 豆瓣信息
|
||||
"""
|
||||
"""
|
||||
{
|
||||
"rating": {
|
||||
"count": 287365,
|
||||
"max": 10,
|
||||
"star_count": 3.5,
|
||||
"value": 6.6
|
||||
},
|
||||
"lineticket_url": "",
|
||||
"controversy_reason": "",
|
||||
"pubdate": [
|
||||
"2021-10-29(中国大陆)"
|
||||
],
|
||||
"last_episode_number": null,
|
||||
"interest_control_info": null,
|
||||
"pic": {
|
||||
"large": "https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp",
|
||||
"normal": "https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2707553644.webp"
|
||||
},
|
||||
"vendor_count": 6,
|
||||
"body_bg_color": "f4f5f9",
|
||||
"is_tv": false,
|
||||
"head_info": null,
|
||||
"album_no_interact": false,
|
||||
"ticket_price_info": "",
|
||||
"webisode_count": 0,
|
||||
"year": "2021",
|
||||
"card_subtitle": "2021 / 英国 美国 / 动作 惊悚 冒险 / 凯瑞·福永 / 丹尼尔·克雷格 蕾雅·赛杜",
|
||||
"forum_info": null,
|
||||
"webisode": null,
|
||||
"id": "20276229",
|
||||
"gallery_topic_count": 0,
|
||||
"languages": [
|
||||
"英语",
|
||||
"法语",
|
||||
"意大利语",
|
||||
"俄语",
|
||||
"西班牙语"
|
||||
],
|
||||
"genres": [
|
||||
"动作",
|
||||
"惊悚",
|
||||
"冒险"
|
||||
],
|
||||
"review_count": 926,
|
||||
"title": "007:无暇赴死",
|
||||
"intro": "世界局势波诡云谲,再度出山的邦德(丹尼尔·克雷格 饰)面临有史以来空前的危机,传奇特工007的故事在本片中达到高潮。新老角色集结亮相,蕾雅·赛杜回归,二度饰演邦女郎玛德琳。系列最恐怖反派萨芬(拉米·马雷克 饰)重磅登场,毫不留情地展示了自己狠辣的一面,不仅揭开了玛德琳身上隐藏的秘密,还酝酿着危及数百万人性命的阴谋,幽灵党的身影也似乎再次浮出水面。半路杀出的新00号特工(拉什纳·林奇 饰)与神秘女子(安娜·德·阿玛斯 饰)看似与邦德同阵作战,但其真实目的依然成谜。关乎邦德生死的新仇旧怨接踵而至,暗潮汹涌之下他能否拯救世界?",
|
||||
"interest_cmt_earlier_tip_title": "发布于上映前",
|
||||
"has_linewatch": true,
|
||||
"ugc_tabs": [
|
||||
{
|
||||
"source": "reviews",
|
||||
"type": "review",
|
||||
"title": "影评"
|
||||
},
|
||||
{
|
||||
"source": "forum_topics",
|
||||
"type": "forum",
|
||||
"title": "讨论"
|
||||
}
|
||||
],
|
||||
"forum_topic_count": 857,
|
||||
"ticket_promo_text": "",
|
||||
"webview_info": {},
|
||||
"is_released": true,
|
||||
"actors": [
|
||||
{
|
||||
"name": "丹尼尔·克雷格",
|
||||
"roles": [
|
||||
"演员",
|
||||
"制片人",
|
||||
"配音"
|
||||
],
|
||||
"title": "丹尼尔·克雷格(同名)英国,英格兰,柴郡,切斯特影视演员",
|
||||
"url": "https://movie.douban.com/celebrity/1025175/",
|
||||
"user": null,
|
||||
"character": "饰 詹姆斯·邦德 James Bond 007",
|
||||
"uri": "douban://douban.com/celebrity/1025175?subject_id=27230907",
|
||||
"avatar": {
|
||||
"large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/600/h/3000/format/webp",
|
||||
"normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/200/h/300/format/webp"
|
||||
},
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1025175/",
|
||||
"type": "celebrity",
|
||||
"id": "1025175",
|
||||
"latin_name": "Daniel Craig"
|
||||
}
|
||||
],
|
||||
"interest": null,
|
||||
"vendor_icons": [
|
||||
"https://img9.doubanio.com/f/frodo/fbc90f355fc45d5d2056e0d88c697f9414b56b44/pics/vendors/tencent.png",
|
||||
"https://img2.doubanio.com/f/frodo/8286b9b5240f35c7e59e1b1768cd2ccf0467cde5/pics/vendors/migu_video.png",
|
||||
"https://img9.doubanio.com/f/frodo/88a62f5e0cf9981c910e60f4421c3e66aac2c9bc/pics/vendors/bilibili.png"
|
||||
],
|
||||
"episodes_count": 0,
|
||||
"color_scheme": {
|
||||
"is_dark": true,
|
||||
"primary_color_light": "868ca5",
|
||||
"_base_color": [
|
||||
0.6333333333333333,
|
||||
0.18867924528301885,
|
||||
0.20784313725490197
|
||||
],
|
||||
"secondary_color": "f4f5f9",
|
||||
"_avg_color": [
|
||||
0.059523809523809625,
|
||||
0.09790209790209795,
|
||||
0.5607843137254902
|
||||
],
|
||||
"primary_color_dark": "676c7f"
|
||||
},
|
||||
"type": "movie",
|
||||
"null_rating_reason": "",
|
||||
"linewatches": [
|
||||
{
|
||||
"url": "http://v.youku.com/v_show/id_XNTIwMzM2NDg5Mg==.html?tpa=dW5pb25faWQ9MzAwMDA4XzEwMDAwMl8wMl8wMQ&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900",
|
||||
"source": {
|
||||
"literal": "youku",
|
||||
"pic": "https://img1.doubanio.com/img/files/file-1432869267.png",
|
||||
"name": "优酷视频"
|
||||
},
|
||||
"source_uri": "youku://play?vid=XNTIwMzM2NDg5Mg==&source=douban&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900",
|
||||
"free": false
|
||||
},
|
||||
],
|
||||
"info_url": "https://www.douban.com/doubanapp//h5/movie/20276229/desc",
|
||||
"tags": [],
|
||||
"durations": [
|
||||
"163分钟"
|
||||
],
|
||||
"comment_count": 97204,
|
||||
"cover": {
|
||||
"description": "",
|
||||
"author": {
|
||||
"loc": {
|
||||
"id": "108288",
|
||||
"name": "北京",
|
||||
"uid": "beijing"
|
||||
},
|
||||
"kind": "user",
|
||||
"name": "雨落下",
|
||||
"reg_time": "2020-08-11 16:22:48",
|
||||
"url": "https://www.douban.com/people/221011676/",
|
||||
"uri": "douban://douban.com/user/221011676",
|
||||
"id": "221011676",
|
||||
"avatar_side_icon_type": 3,
|
||||
"avatar_side_icon_id": "234",
|
||||
"avatar": "https://img2.doubanio.com/icon/up221011676-2.jpg",
|
||||
"is_club": false,
|
||||
"type": "user",
|
||||
"avatar_side_icon": "https://img2.doubanio.com/view/files/raw/file-1683625971.png",
|
||||
"uid": "221011676"
|
||||
},
|
||||
"url": "https://movie.douban.com/photos/photo/2707553644/",
|
||||
"image": {
|
||||
"large": {
|
||||
"url": "https://img9.doubanio.com/view/photo/l/public/p2707553644.webp",
|
||||
"width": 1082,
|
||||
"height": 1600,
|
||||
"size": 0
|
||||
},
|
||||
"raw": null,
|
||||
"small": {
|
||||
"url": "https://img9.doubanio.com/view/photo/s/public/p2707553644.webp",
|
||||
"width": 405,
|
||||
"height": 600,
|
||||
"size": 0
|
||||
},
|
||||
"normal": {
|
||||
"url": "https://img9.doubanio.com/view/photo/m/public/p2707553644.webp",
|
||||
"width": 405,
|
||||
"height": 600,
|
||||
"size": 0
|
||||
},
|
||||
"is_animated": false
|
||||
},
|
||||
"uri": "douban://douban.com/photo/2707553644",
|
||||
"create_time": "2021-10-26 15:05:01",
|
||||
"position": 0,
|
||||
"owner_uri": "douban://douban.com/movie/20276229",
|
||||
"type": "photo",
|
||||
"id": "2707553644",
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/photo/2707553644/"
|
||||
},
|
||||
"cover_url": "https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp",
|
||||
"restrictive_icon_url": "",
|
||||
"header_bg_color": "676c7f",
|
||||
"is_douban_intro": false,
|
||||
"ticket_vendor_icons": [
|
||||
"https://img9.doubanio.com/view/dale-online/dale_ad/public/0589a62f2f2d7c2.jpg"
|
||||
],
|
||||
"honor_infos": [],
|
||||
"sharing_url": "https://movie.douban.com/subject/20276229/",
|
||||
"subject_collections": [],
|
||||
"wechat_timeline_share": "screenshot",
|
||||
"countries": [
|
||||
"英国",
|
||||
"美国"
|
||||
],
|
||||
"url": "https://movie.douban.com/subject/20276229/",
|
||||
"release_date": null,
|
||||
"original_title": "No Time to Die",
|
||||
"uri": "douban://douban.com/movie/20276229",
|
||||
"pre_playable_date": null,
|
||||
"episodes_info": "",
|
||||
"subtype": "movie",
|
||||
"directors": [
|
||||
{
|
||||
"name": "凯瑞·福永",
|
||||
"roles": [
|
||||
"导演",
|
||||
"制片人",
|
||||
"编剧",
|
||||
"摄影",
|
||||
"演员"
|
||||
],
|
||||
"title": "凯瑞·福永(同名)美国,加利福尼亚州,奥克兰影视演员",
|
||||
"url": "https://movie.douban.com/celebrity/1009531/",
|
||||
"user": null,
|
||||
"character": "导演",
|
||||
"uri": "douban://douban.com/celebrity/1009531?subject_id=27215222",
|
||||
"avatar": {
|
||||
"large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/600/h/3000/format/webp",
|
||||
"normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/200/h/300/format/webp"
|
||||
},
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1009531/",
|
||||
"type": "celebrity",
|
||||
"id": "1009531",
|
||||
"latin_name": "Cary Fukunaga"
|
||||
}
|
||||
],
|
||||
"is_show": false,
|
||||
"in_blacklist": false,
|
||||
"pre_release_desc": "",
|
||||
"video": null,
|
||||
"aka": [
|
||||
"007:生死有时(港)",
|
||||
"007:生死交战(台)",
|
||||
"007:间不容死",
|
||||
"邦德25",
|
||||
"007:没空去死(豆友译名)",
|
||||
"James Bond 25",
|
||||
"Never Dream of Dying",
|
||||
"Shatterhand"
|
||||
],
|
||||
"is_restrictive": false,
|
||||
"trailer": {
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/movie/20276229/trailer%3Ftrailer_id%3D282585%26trailer_type%3DA",
|
||||
"video_url": "https://vt1.doubanio.com/202310011325/3b1f5827e91dde7826dc20930380dfc2/view/movie/M/402820585.mp4",
|
||||
"title": "中国预告片:终极决战版 (中文字幕)",
|
||||
"uri": "douban://douban.com/movie/20276229/trailer?trailer_id=282585&trailer_type=A",
|
||||
"cover_url": "https://img1.doubanio.com/img/trailer/medium/2712944408.jpg",
|
||||
"term_num": 0,
|
||||
"n_comments": 21,
|
||||
"create_time": "2021-11-01",
|
||||
"subject_title": "007:无暇赴死",
|
||||
"file_size": 10520074,
|
||||
"runtime": "00:42",
|
||||
"type": "A",
|
||||
"id": "282585",
|
||||
"desc": ""
|
||||
},
|
||||
"interest_cmt_earlier_tip_desc": "该短评的发布时间早于公开上映时间,作者可能通过其他渠道提前观看,请谨慎参考。其评分将不计入总评分。"
|
||||
}
|
||||
"""
|
||||
|
||||
def __douban_tv():
|
||||
"""
|
||||
获取豆瓣剧集信息
|
||||
"""
|
||||
info = self.doubanapi.tv_detail(doubanid)
|
||||
if info:
|
||||
celebrities = self.doubanapi.tv_celebrities(doubanid)
|
||||
if celebrities:
|
||||
info["directors"] = celebrities.get("directors")
|
||||
info["actors"] = celebrities.get("actors")
|
||||
return info
|
||||
|
||||
def __douban_movie():
|
||||
"""
|
||||
获取豆瓣电影信息
|
||||
"""
|
||||
info = self.doubanapi.movie_detail(doubanid)
|
||||
if info:
|
||||
celebrities = self.doubanapi.movie_celebrities(doubanid)
|
||||
if celebrities:
|
||||
info["directors"] = celebrities.get("directors")
|
||||
info["actors"] = celebrities.get("actors")
|
||||
return info
|
||||
|
||||
if not doubanid:
|
||||
return None
|
||||
logger.info(f"开始获取豆瓣信息:{doubanid} ...")
|
||||
douban_info = self.doubanapi.movie_detail(doubanid)
|
||||
if douban_info:
|
||||
celebrities = self.doubanapi.movie_celebrities(doubanid)
|
||||
if celebrities:
|
||||
douban_info["directors"] = celebrities.get("directors")
|
||||
douban_info["actors"] = celebrities.get("actors")
|
||||
if mtype == MediaType.TV:
|
||||
return __douban_tv()
|
||||
elif mtype == MediaType.MOVIE:
|
||||
return __douban_movie()
|
||||
else:
|
||||
douban_info = self.doubanapi.tv_detail(doubanid)
|
||||
celebrities = self.doubanapi.tv_celebrities(doubanid)
|
||||
if douban_info and celebrities:
|
||||
douban_info["directors"] = celebrities.get("directors")
|
||||
douban_info["actors"] = celebrities.get("actors")
|
||||
return douban_info
|
||||
return __douban_movie() or __douban_tv()
|
||||
|
||||
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
|
||||
page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
@@ -103,6 +487,36 @@ class DoubanModule(_ModuleBase):
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
|
||||
def tv_animation(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
"""
|
||||
获取豆瓣动画剧
|
||||
"""
|
||||
infos = self.doubanapi.tv_animation(start=(page - 1) * count,
|
||||
count=count)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
|
||||
def movie_hot(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
"""
|
||||
获取豆瓣热门电影
|
||||
"""
|
||||
infos = self.doubanapi.movie_hot_gaia(start=(page - 1) * count,
|
||||
count=count)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
|
||||
def tv_hot(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
"""
|
||||
获取豆瓣热门剧集
|
||||
"""
|
||||
infos = self.doubanapi.tv_hot(start=(page - 1) * count,
|
||||
count=count)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
|
||||
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
@@ -110,7 +524,7 @@ class DoubanModule(_ModuleBase):
|
||||
:reutrn: 媒体信息
|
||||
"""
|
||||
# 未启用豆瓣搜索时返回None
|
||||
if settings.SEARCH_SOURCE != "douban":
|
||||
if settings.RECOGNIZE_SOURCE != "douban":
|
||||
return None
|
||||
|
||||
if not meta.name:
|
||||
@@ -121,7 +535,7 @@ class DoubanModule(_ModuleBase):
|
||||
# 返回数据
|
||||
ret_medias = []
|
||||
for item_obj in result.get("items"):
|
||||
if meta.type and meta.type.value != item_obj.get("type_name"):
|
||||
if meta.type and meta.type != MediaType.UNKNOWN and meta.type.value != item_obj.get("type_name"):
|
||||
continue
|
||||
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
|
||||
continue
|
||||
@@ -129,22 +543,59 @@ class DoubanModule(_ModuleBase):
|
||||
|
||||
return ret_medias
|
||||
|
||||
def __match(self, name: str, year: str, season: int = None) -> dict:
|
||||
@retry(Exception, 5, 3, 3, logger=logger)
|
||||
def match_doubaninfo(self, name: str, imdbid: str = None,
|
||||
mtype: MediaType = None, year: str = None, season: int = None) -> dict:
|
||||
"""
|
||||
搜索和匹配豆瓣信息
|
||||
:param name: 名称
|
||||
:param imdbid: IMDB ID
|
||||
:param mtype: 类型
|
||||
:param year: 年份
|
||||
:param season: 季号
|
||||
"""
|
||||
result = self.doubanapi.search(f"{name} {year or ''}")
|
||||
if imdbid:
|
||||
# 优先使用IMDBID查询
|
||||
logger.info(f"开始使用IMDBID {imdbid} 查询豆瓣信息 ...")
|
||||
result = self.doubanapi.imdbid(imdbid)
|
||||
if result:
|
||||
doubanid = result.get("id")
|
||||
if doubanid and not str(doubanid).isdigit():
|
||||
doubanid = re.search(r"\d+", doubanid).group(0)
|
||||
result["id"] = doubanid
|
||||
logger.info(f"{imdbid} 查询到豆瓣信息:{result.get('title')}")
|
||||
return result
|
||||
# 搜索
|
||||
logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...")
|
||||
result = self.doubanapi.search(f"{name} {year or ''}".strip())
|
||||
if not result:
|
||||
logger.warn(f"未找到 {name} 的豆瓣信息")
|
||||
return {}
|
||||
# 触发rate limit
|
||||
if "search_access_rate_limit" in result.values():
|
||||
logger.warn(f"触发豆瓣API速率限制 错误信息 {result} ...")
|
||||
raise Exception("触发豆瓣API速率限制")
|
||||
for item_obj in result.get("items"):
|
||||
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
|
||||
type_name = item_obj.get("type_name")
|
||||
if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]:
|
||||
continue
|
||||
title = item_obj.get("title")
|
||||
if mtype and mtype.value != type_name:
|
||||
continue
|
||||
if mtype and mtype == MediaType.TV and not season:
|
||||
season = 1
|
||||
item = item_obj.get("target")
|
||||
title = item.get("title")
|
||||
if not title:
|
||||
continue
|
||||
meta = MetaInfo(title)
|
||||
if meta.name == name and (not season or meta.begin_season == season):
|
||||
return item_obj
|
||||
if type_name == MediaType.TV.value:
|
||||
meta.type = MediaType.TV
|
||||
meta.begin_season = meta.begin_season or 1
|
||||
if meta.name == name \
|
||||
and ((not season and not meta.begin_season) or meta.begin_season == season) \
|
||||
and (not year or item.get('year') == year):
|
||||
logger.info(f"{name} 匹配到豆瓣信息:{item.get('id')} {item.get('title')}")
|
||||
return item
|
||||
return {}
|
||||
|
||||
def movie_top250(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
@@ -157,11 +608,15 @@ class DoubanModule(_ModuleBase):
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo) -> None:
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
|
||||
force_nfo: bool = False, force_img: bool = False) -> None:
|
||||
"""
|
||||
刮削元数据
|
||||
:param path: 媒体文件路径
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 传输类型
|
||||
:param force_nfo: 是否强制刮削nfo
|
||||
:param force_img: 是否强制刮削图片
|
||||
:return: 成功或失败
|
||||
"""
|
||||
if settings.SCRAP_SOURCE != "douban":
|
||||
@@ -172,15 +627,35 @@ class DoubanModule(_ModuleBase):
|
||||
meta = MetaInfo(path.stem)
|
||||
if not meta.name:
|
||||
return
|
||||
# 根据名称查询豆瓣数据
|
||||
doubaninfo = self.__match(name=mediainfo.title, year=mediainfo.year, season=meta.begin_season)
|
||||
# 查询豆瓣详情
|
||||
if not mediainfo.douban_id:
|
||||
# 根据名称查询豆瓣数据
|
||||
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
mtype=mediainfo.type,
|
||||
year=mediainfo.year)
|
||||
if not doubaninfo:
|
||||
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
|
||||
return
|
||||
doubaninfo = self.douban_info(doubanid=doubaninfo.get("id"), mtype=mediainfo.type)
|
||||
else:
|
||||
doubaninfo = self.douban_info(doubanid=mediainfo.douban_id,
|
||||
mtype=mediainfo.type)
|
||||
if not doubaninfo:
|
||||
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
|
||||
logger(f"未获取到 {mediainfo.douban_id} 的豆瓣媒体信息,无法刮削!")
|
||||
return
|
||||
# 豆瓣媒体信息
|
||||
mediainfo = MediaInfo(douban_info=doubaninfo)
|
||||
# 补充图片
|
||||
self.obtain_images(mediainfo)
|
||||
# 刮削路径
|
||||
scrape_path = path / path.name
|
||||
self.scraper.gen_scraper_files(meta=meta,
|
||||
mediainfo=MediaInfo(douban_info=doubaninfo),
|
||||
file_path=scrape_path)
|
||||
mediainfo=mediainfo,
|
||||
file_path=scrape_path,
|
||||
transfer_type=transfer_type,
|
||||
force_nfo=force_nfo,
|
||||
force_img=force_img)
|
||||
else:
|
||||
# 目录下的所有文件
|
||||
for file in SystemUtils.list_files(path, settings.RMT_MEDIAEXT):
|
||||
@@ -191,17 +666,119 @@ class DoubanModule(_ModuleBase):
|
||||
meta = MetaInfo(file.stem)
|
||||
if not meta.name:
|
||||
continue
|
||||
# 根据名称查询豆瓣数据
|
||||
doubaninfo = self.__match(name=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
season=meta.begin_season)
|
||||
if not mediainfo.douban_id:
|
||||
# 根据名称查询豆瓣数据
|
||||
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
mtype=mediainfo.type,
|
||||
year=mediainfo.year,
|
||||
season=meta.begin_season)
|
||||
if not doubaninfo:
|
||||
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
|
||||
break
|
||||
# 查询豆瓣详情
|
||||
doubaninfo = self.douban_info(doubanid=doubaninfo.get("id"), mtype=mediainfo.type)
|
||||
else:
|
||||
doubaninfo = self.douban_info(doubanid=mediainfo.douban_id,
|
||||
mtype=mediainfo.type)
|
||||
if not doubaninfo:
|
||||
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
|
||||
break
|
||||
logger(f"未获取到 {mediainfo.douban_id} 的豆瓣媒体信息,无法刮削!")
|
||||
continue
|
||||
# 豆瓣媒体信息
|
||||
mediainfo = MediaInfo(douban_info=doubaninfo)
|
||||
# 补充图片
|
||||
self.obtain_images(mediainfo)
|
||||
# 刮削
|
||||
self.scraper.gen_scraper_files(meta=meta,
|
||||
mediainfo=MediaInfo(douban_info=doubaninfo),
|
||||
file_path=file)
|
||||
mediainfo=mediainfo,
|
||||
file_path=file,
|
||||
transfer_type=transfer_type,
|
||||
force_nfo=force_nfo,
|
||||
force_img=force_img)
|
||||
except Exception as e:
|
||||
logger.error(f"刮削文件 {file} 失败,原因:{e}")
|
||||
logger.error(f"刮削文件 {file} 失败,原因:{str(e)}")
|
||||
logger.info(f"{path} 刮削完成")
|
||||
|
||||
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
"""
|
||||
补充抓取媒体信息图片
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 更新后的媒体信息
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "douban":
|
||||
return None
|
||||
if not mediainfo.douban_id:
|
||||
return None
|
||||
if mediainfo.backdrop_path:
|
||||
# 没有图片缺失
|
||||
return mediainfo
|
||||
# 调用图片接口
|
||||
if not mediainfo.backdrop_path:
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
info = self.doubanapi.movie_photos(mediainfo.douban_id)
|
||||
else:
|
||||
info = self.doubanapi.tv_photos(mediainfo.douban_id)
|
||||
if not info:
|
||||
return mediainfo
|
||||
images = info.get("photos")
|
||||
# 背景图
|
||||
if images:
|
||||
backdrop = images[0].get("image", {}).get("large") or {}
|
||||
if backdrop:
|
||||
mediainfo.backdrop_path = backdrop.get("url")
|
||||
return mediainfo
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
清除缓存
|
||||
"""
|
||||
logger.info("开始清除豆瓣缓存 ...")
|
||||
self.doubanapi.clear_cache()
|
||||
self.cache.clear()
|
||||
logger.info("豆瓣缓存清除完成")
|
||||
|
||||
def douban_movie_credits(self, doubanid: str, page: int = 1, count: int = 20) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询电影演职员表
|
||||
:param doubanid: 豆瓣ID
|
||||
:param page: 页码
|
||||
:param count: 数量
|
||||
"""
|
||||
result = self.doubanapi.movie_celebrities(subject_id=doubanid)
|
||||
if not result:
|
||||
return []
|
||||
ret_list = result.get("actors") or []
|
||||
if ret_list:
|
||||
return ret_list[(page - 1) * count: page * count]
|
||||
else:
|
||||
return []
|
||||
|
||||
def douban_tv_credits(self, doubanid: str, page: int = 1, count: int = 20) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职员表
|
||||
:param doubanid: 豆瓣ID
|
||||
:param page: 页码
|
||||
:param count: 数量
|
||||
"""
|
||||
result = self.doubanapi.tv_celebrities(subject_id=doubanid)
|
||||
if not result:
|
||||
return []
|
||||
ret_list = result.get("actors") or []
|
||||
if ret_list:
|
||||
return ret_list[(page - 1) * count: page * count]
|
||||
else:
|
||||
return []
|
||||
|
||||
def douban_movie_recommend(self, doubanid: str) -> List[dict]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.doubanapi.movie_recommendations(subject_id=doubanid) or []
|
||||
|
||||
def douban_tv_recommend(self, doubanid: str) -> List[dict]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电视剧
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.doubanapi.tv_recommendations(subject_id=doubanid) or []
|
||||
|
||||
@@ -18,28 +18,29 @@ class DoubanApi(metaclass=Singleton):
|
||||
_urls = {
|
||||
# 搜索类
|
||||
# sort=U:近期热门 T:标记最多 S:评分最高 R:最新上映
|
||||
# q=search_word&start=0&count=20&sort=U
|
||||
# q=search_word&start: int = 0&count: int = 20&sort=U
|
||||
# 聚合搜索
|
||||
"search": "/search/weixin",
|
||||
"search_agg": "/search",
|
||||
"imdbid": "/movie/imdb/%s",
|
||||
|
||||
# 电影探索
|
||||
# sort=U:综合排序 T:近期热度 S:高分优先 R:首播时间
|
||||
# tags='日本,动画,2022'&start=0&count=20&sort=U
|
||||
# tags='日本,动画,2022'&start: int = 0&count: int = 20&sort=U
|
||||
"movie_recommend": "/movie/recommend",
|
||||
# 电视剧探索
|
||||
"tv_recommend": "/tv/recommend",
|
||||
# 搜索
|
||||
"movie_tag": "/movie/tag",
|
||||
"tv_tag": "/tv/tag",
|
||||
# q=search_word&start=0&count=20
|
||||
# q=search_word&start: int = 0&count: int = 20
|
||||
"movie_search": "/search/movie",
|
||||
"tv_search": "/search/movie",
|
||||
"book_search": "/search/book",
|
||||
"group_search": "/search/group",
|
||||
|
||||
# 各类主题合集
|
||||
# start=0&count=20
|
||||
# start: int = 0&count: int = 20
|
||||
# 正在上映
|
||||
"movie_showing": "/subject_collection/movie_showing/items",
|
||||
# 热门电影
|
||||
@@ -145,112 +146,277 @@ class DoubanApi(metaclass=Singleton):
|
||||
"api-client/1 com.douban.frodo/7.3.0(207) Android/22 product/MI 9 vendor/Xiaomi model/MI 9 brand/Android rom/miui6 network/wifi platform/mobile nd/1"]
|
||||
_api_secret_key = "bf7dddc7c9cfe6f7"
|
||||
_api_key = "0dad551ec0f84ed02907ff5c42e8ec70"
|
||||
_api_key2 = "0ab215a8b1977939201640fa14c66bab"
|
||||
_base_url = "https://frodo.douban.com/api/v2"
|
||||
_session = requests.Session()
|
||||
_api_url = "https://api.douban.com/v2"
|
||||
_session = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
self._session = requests.Session()
|
||||
|
||||
@classmethod
|
||||
def __sign(cls, url: str, ts: int, method='GET') -> str:
|
||||
"""
|
||||
签名
|
||||
"""
|
||||
url_path = parse.urlparse(url).path
|
||||
raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), str(ts)])
|
||||
return base64.b64encode(hmac.new(cls._api_secret_key.encode(), raw_sign.encode(), hashlib.sha1).digest()
|
||||
).decode()
|
||||
return base64.b64encode(
|
||||
hmac.new(
|
||||
cls._api_secret_key.encode(),
|
||||
raw_sign.encode(),
|
||||
hashlib.sha1
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
|
||||
def __invoke(cls, url, **kwargs):
|
||||
req_url = cls._base_url + url
|
||||
def __invoke(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
GET请求
|
||||
"""
|
||||
req_url = self._base_url + url
|
||||
|
||||
params = {'apiKey': cls._api_key}
|
||||
params = {'apiKey': self._api_key}
|
||||
if kwargs:
|
||||
params.update(kwargs)
|
||||
|
||||
ts = params.pop('_ts', int(datetime.strftime(datetime.now(), '%Y%m%d')))
|
||||
params.update({'os_rom': 'android', 'apiKey': cls._api_key, '_ts': ts, '_sig': cls.__sign(url=req_url, ts=ts)})
|
||||
|
||||
resp = RequestUtils(ua=choice(cls._user_agents), session=cls._session).get_res(url=req_url, params=params)
|
||||
|
||||
ts = params.pop(
|
||||
'_ts',
|
||||
datetime.strftime(datetime.now(), '%Y%m%d')
|
||||
)
|
||||
params.update({
|
||||
'os_rom': 'android',
|
||||
'apiKey': self._api_key,
|
||||
'_ts': ts,
|
||||
'_sig': self.__sign(url=req_url, ts=ts)
|
||||
})
|
||||
resp = RequestUtils(
|
||||
ua=choice(self._user_agents),
|
||||
session=self._session
|
||||
).get_res(url=req_url, params=params)
|
||||
if resp.status_code == 400 and "rate_limit" in resp.text:
|
||||
return resp.json()
|
||||
return resp.json() if resp else {}
|
||||
|
||||
def search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
|
||||
def __post(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
POST请求
|
||||
esponse = requests.post(
|
||||
url="https://api.douban.com/v2/movie/imdb/tt29139455",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
"Cookie": "bid=J9zb1zA5sJc",
|
||||
},
|
||||
data={
|
||||
"apikey": "0ab215a8b1977939201640fa14c66bab",
|
||||
},
|
||||
)
|
||||
"""
|
||||
req_url = self._api_url + url
|
||||
params = {'apikey': self._api_key2}
|
||||
if kwargs:
|
||||
params.update(kwargs)
|
||||
if '_ts' in params:
|
||||
params.pop('_ts')
|
||||
resp = RequestUtils(
|
||||
ua=settings.USER_AGENT,
|
||||
session=self._session,
|
||||
).post_res(url=req_url, data=params)
|
||||
if resp.status_code == 400 and "rate_limit" in resp.text:
|
||||
return resp.json()
|
||||
return resp.json() if resp else {}
|
||||
|
||||
def movie_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:
|
||||
"""
|
||||
关键字搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def imdbid(self, imdbid: str,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
IMDBID搜索
|
||||
"""
|
||||
return self.__post(self._urls["imdbid"] % imdbid, _ts=ts)
|
||||
|
||||
def book_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["book_search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def movie_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def group_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["group_search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def tv_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_showing(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_showing"], start=start, count=count, _ts=ts)
|
||||
def book_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
书籍搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["book_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_soon(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_soon"], start=start, count=count, _ts=ts)
|
||||
def group_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
小组搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["group_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_hot_gaia(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_hot_gaia"], start=start, count=count, _ts=ts)
|
||||
def movie_showing(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
正在热映
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_showing"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_hot(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_hot"], start=start, count=count, _ts=ts)
|
||||
def movie_soon(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
即将上映
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_soon"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_animation(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_animation"], start=start, count=count, _ts=ts)
|
||||
def movie_hot_gaia(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
热门电影
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_hot_gaia"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_variety_show(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_variety_show"], start=start, count=count, _ts=ts)
|
||||
def tv_hot(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
热门剧集
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_hot"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_rank_list(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_rank_list"], start=start, count=count, _ts=ts)
|
||||
def tv_animation(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
动画
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_animation"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def show_hot(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["show_hot"], start=start, count=count, _ts=ts)
|
||||
def tv_variety_show(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
综艺
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_variety_show"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_detail(self, subject_id):
|
||||
def tv_rank_list(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视剧排行榜
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_rank_list"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def show_hot(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
综艺热门
|
||||
"""
|
||||
return self.__invoke(self._urls["show_hot"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_detail(self, subject_id: str):
|
||||
"""
|
||||
电影详情
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_detail"] + subject_id)
|
||||
|
||||
def movie_celebrities(self, subject_id):
|
||||
def movie_celebrities(self, subject_id: str):
|
||||
"""
|
||||
电影演职员
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_celebrities"] % subject_id)
|
||||
|
||||
def tv_detail(self, subject_id):
|
||||
def tv_detail(self, subject_id: str):
|
||||
"""
|
||||
电视剧详情
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_detail"] + subject_id)
|
||||
|
||||
def tv_celebrities(self, subject_id):
|
||||
def tv_celebrities(self, subject_id: str):
|
||||
"""
|
||||
电视剧演职员
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_celebrities"] % subject_id)
|
||||
|
||||
def book_detail(self, subject_id):
|
||||
def book_detail(self, subject_id: str):
|
||||
"""
|
||||
书籍详情
|
||||
"""
|
||||
return self.__invoke(self._urls["book_detail"] + subject_id)
|
||||
|
||||
def movie_top250(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_top250"], start=start, count=count, _ts=ts)
|
||||
def movie_top250(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影TOP250
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_top250"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_recommend(self, tags='', sort='R', start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts)
|
||||
def movie_recommend(self, tags='', sort='R', start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影探索
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_recommend"], tags=tags, sort=sort,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_recommend(self, tags='', sort='R', start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts)
|
||||
def tv_recommend(self, tags='', sort='R', start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视剧探索
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_recommend"], tags=tags, sort=sort,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_chinese_best_weekly(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_chinese_best_weekly"], start=start, count=count, _ts=ts)
|
||||
def tv_chinese_best_weekly(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
华语口碑周榜
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_chinese_best_weekly"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_global_best_weekly(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_global_best_weekly"], start=start, count=count, _ts=ts)
|
||||
def tv_global_best_weekly(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
全球口碑周榜
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_global_best_weekly"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def doulist_detail(self, subject_id):
|
||||
def doulist_detail(self, subject_id: str):
|
||||
"""
|
||||
豆列详情
|
||||
:param subject_id: 豆列id
|
||||
"""
|
||||
return self.__invoke(self._urls["doulist"] + subject_id)
|
||||
|
||||
def doulist_items(self, subject_id, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
def doulist_items(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
豆列列表
|
||||
:param subject_id: 豆列id
|
||||
@@ -258,4 +424,63 @@ class DoubanApi(metaclass=Singleton):
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["doulist_items"] % subject_id, start=start, count=count, _ts=ts)
|
||||
return self.__invoke(self._urls["doulist_items"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_recommendations(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影推荐
|
||||
:param subject_id: 电影id
|
||||
:param start: 开始
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_recommendations"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_recommendations(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视剧推荐
|
||||
:param subject_id: 电视剧id
|
||||
:param start: 开始
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_recommendations"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_photos(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影剧照
|
||||
:param subject_id: 电影id
|
||||
:param start: 开始
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_photos"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_photos(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视剧剧照
|
||||
:param subject_id: 电视剧id
|
||||
:param start: 开始
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_photos"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
清空LRU缓存
|
||||
"""
|
||||
self.__invoke.cache_clear()
|
||||
|
||||
def __del__(self):
|
||||
if self._session:
|
||||
self._session.close()
|
||||
|
||||
235
app/modules/douban/douban_cache.py
Normal file
235
app/modules/douban/douban_cache.py
Normal file
@@ -0,0 +1,235 @@
|
||||
import pickle
|
||||
import random
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.utils.singleton import Singleton
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
lock = RLock()
|
||||
|
||||
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
|
||||
EXPIRE_TIMESTAMP = settings.CACHE_CONF.get('meta')
|
||||
|
||||
|
||||
class DoubanCache(metaclass=Singleton):
|
||||
"""
|
||||
豆瓣缓存数据
|
||||
{
|
||||
"id": '',
|
||||
"title": '',
|
||||
"year": '',
|
||||
"type": MediaType
|
||||
}
|
||||
"""
|
||||
_meta_data: dict = {}
|
||||
# 缓存文件路径
|
||||
_meta_path: Path = None
|
||||
# TMDB缓存过期
|
||||
_tmdb_cache_expire: bool = True
|
||||
|
||||
def __init__(self):
|
||||
self._meta_path = settings.TEMP_PATH / "__douban_cache__"
|
||||
self._meta_data = self.__load(self._meta_path)
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
清空所有TMDB缓存
|
||||
"""
|
||||
with lock:
|
||||
self._meta_data = {}
|
||||
|
||||
@staticmethod
|
||||
def __get_key(meta: MetaBase) -> str:
|
||||
"""
|
||||
获取缓存KEY
|
||||
"""
|
||||
return f"[{meta.type.value if meta.type else '未知'}]" \
|
||||
f"{meta.name or meta.doubanid}-{meta.year}-{meta.begin_season}"
|
||||
|
||||
def get(self, meta: MetaBase):
|
||||
"""
|
||||
根据KEY值获取缓存值
|
||||
"""
|
||||
key = self.__get_key(meta)
|
||||
with lock:
|
||||
info: dict = self._meta_data.get(key)
|
||||
if info:
|
||||
expire = info.get(CACHE_EXPIRE_TIMESTAMP_STR)
|
||||
if not expire or int(time.time()) < expire:
|
||||
info[CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
|
||||
self._meta_data[key] = info
|
||||
elif expire and self._tmdb_cache_expire:
|
||||
self.delete(key)
|
||||
return info or {}
|
||||
|
||||
def delete(self, key: str) -> dict:
|
||||
"""
|
||||
删除缓存信息
|
||||
@param key: 缓存key
|
||||
@return: 被删除的缓存内容
|
||||
"""
|
||||
with lock:
|
||||
return self._meta_data.pop(key, None)
|
||||
|
||||
def delete_by_doubanid(self, doubanid: str) -> None:
|
||||
"""
|
||||
清空对应豆瓣ID的所有缓存记录,以强制更新TMDB中最新的数据
|
||||
"""
|
||||
for key in list(self._meta_data):
|
||||
if self._meta_data.get(key, {}).get("id") == doubanid:
|
||||
with lock:
|
||||
self._meta_data.pop(key)
|
||||
|
||||
def delete_unknown(self) -> None:
|
||||
"""
|
||||
清除未识别的缓存记录,以便重新搜索TMDB
|
||||
"""
|
||||
for key in list(self._meta_data):
|
||||
if self._meta_data.get(key, {}).get("id") == "0":
|
||||
with lock:
|
||||
self._meta_data.pop(key)
|
||||
|
||||
def modify(self, key: str, title: str) -> dict:
|
||||
"""
|
||||
删除缓存信息
|
||||
@param key: 缓存key
|
||||
@param title: 标题
|
||||
@return: 被修改后缓存内容
|
||||
"""
|
||||
with lock:
|
||||
if self._meta_data.get(key):
|
||||
self._meta_data[key]['title'] = title
|
||||
self._meta_data[key][CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
|
||||
return self._meta_data.get(key)
|
||||
|
||||
@staticmethod
|
||||
def __load(path: Path) -> dict:
|
||||
"""
|
||||
从文件中加载缓存
|
||||
"""
|
||||
try:
|
||||
if path.exists():
|
||||
with open(path, 'rb') as f:
|
||||
data = pickle.load(f)
|
||||
return data
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"加载缓存失败: {str(e)} - {traceback.format_exc()}")
|
||||
return {}
|
||||
|
||||
def update(self, meta: MetaBase, info: dict) -> None:
|
||||
"""
|
||||
新增或更新缓存条目
|
||||
"""
|
||||
with lock:
|
||||
if info:
|
||||
# 缓存标题
|
||||
cache_title = info.get("title")
|
||||
# 缓存年份
|
||||
cache_year = info.get('year')
|
||||
# 类型
|
||||
if isinstance(info.get('media_type'), MediaType):
|
||||
mtype = info.get('media_type')
|
||||
elif info.get("type"):
|
||||
mtype = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
|
||||
else:
|
||||
meta = MetaInfo(cache_title)
|
||||
if meta.begin_season:
|
||||
mtype = MediaType.TV
|
||||
else:
|
||||
mtype = MediaType.MOVIE
|
||||
# 海报
|
||||
poster_path = info.get("pic", {}).get("large")
|
||||
if not poster_path and info.get("cover_url"):
|
||||
poster_path = info.get("cover_url")
|
||||
if not poster_path and info.get("cover"):
|
||||
poster_path = info.get("cover").get("url")
|
||||
|
||||
self._meta_data[self.__get_key(meta)] = {
|
||||
"id": info.get("id"),
|
||||
"type": mtype,
|
||||
"year": cache_year,
|
||||
"title": cache_title,
|
||||
"poster_path": poster_path,
|
||||
CACHE_EXPIRE_TIMESTAMP_STR: int(time.time()) + EXPIRE_TIMESTAMP
|
||||
}
|
||||
elif info is not None:
|
||||
# None时不缓存,此时代表网络错误,允许重复请求
|
||||
self._meta_data[self.__get_key(meta)] = {'id': "0"}
|
||||
|
||||
def save(self, force: bool = False) -> None:
|
||||
"""
|
||||
保存缓存数据到文件
|
||||
"""
|
||||
|
||||
meta_data = self.__load(self._meta_path)
|
||||
new_meta_data = {k: v for k, v in self._meta_data.items() if v.get("id")}
|
||||
|
||||
if not force \
|
||||
and not self._random_sample(new_meta_data) \
|
||||
and meta_data.keys() == new_meta_data.keys():
|
||||
return
|
||||
|
||||
with open(self._meta_path, 'wb') as f:
|
||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL)
|
||||
|
||||
def _random_sample(self, new_meta_data: dict) -> bool:
|
||||
"""
|
||||
采样分析是否需要保存
|
||||
"""
|
||||
ret = False
|
||||
if len(new_meta_data) < 25:
|
||||
keys = list(new_meta_data.keys())
|
||||
for k in keys:
|
||||
info = new_meta_data.get(k)
|
||||
expire = info.get(CACHE_EXPIRE_TIMESTAMP_STR)
|
||||
if not expire:
|
||||
ret = True
|
||||
info[CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
|
||||
elif int(time.time()) >= expire:
|
||||
ret = True
|
||||
if self._tmdb_cache_expire:
|
||||
new_meta_data.pop(k)
|
||||
else:
|
||||
count = 0
|
||||
keys = random.sample(sorted(new_meta_data.keys()), 25)
|
||||
for k in keys:
|
||||
info = new_meta_data.get(k)
|
||||
expire = info.get(CACHE_EXPIRE_TIMESTAMP_STR)
|
||||
if not expire:
|
||||
ret = True
|
||||
info[CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
|
||||
elif int(time.time()) >= expire:
|
||||
ret = True
|
||||
if self._tmdb_cache_expire:
|
||||
new_meta_data.pop(k)
|
||||
count += 1
|
||||
if count >= 5:
|
||||
ret |= self._random_sample(new_meta_data)
|
||||
return ret
|
||||
|
||||
def get_title(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
获取缓存的标题
|
||||
"""
|
||||
cache_media_info = self._meta_data.get(key)
|
||||
if not cache_media_info or not cache_media_info.get("id"):
|
||||
return None
|
||||
return cache_media_info.get("title")
|
||||
|
||||
def set_title(self, key: str, cn_title: str) -> None:
|
||||
"""
|
||||
重新设置缓存标题
|
||||
"""
|
||||
cache_media_info = self._meta_data.get(key)
|
||||
if not cache_media_info:
|
||||
return
|
||||
self._meta_data[key]['title'] = cn_title
|
||||
@@ -1,54 +1,85 @@
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from xml.dom import minidom
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.dom import DomUtils
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class DoubanScraper:
|
||||
_transfer_type = settings.TRANSFER_TYPE
|
||||
_force_nfo = False
|
||||
_force_img = False
|
||||
|
||||
def gen_scraper_files(self, meta: MetaBase, mediainfo: MediaInfo, file_path: Path):
|
||||
def gen_scraper_files(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||
file_path: Path, transfer_type: str,
|
||||
force_nfo: bool = False, force_img: bool = False):
|
||||
"""
|
||||
生成刮削文件
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param file_path: 文件路径或者目录路径
|
||||
:param transfer_type: 转输类型
|
||||
:param force_nfo: 强制生成NFO
|
||||
:param force_img: 强制生成图片
|
||||
"""
|
||||
|
||||
self._transfer_type = transfer_type
|
||||
self._force_nfo = force_nfo
|
||||
self._force_img = force_img
|
||||
|
||||
try:
|
||||
# 电影
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 强制或者不已存在时才处理
|
||||
if not file_path.with_name("movie.nfo").exists() \
|
||||
and not file_path.with_suffix(".nfo").exists():
|
||||
if self._force_nfo or (not file_path.with_name("movie.nfo").exists()
|
||||
and not file_path.with_suffix(".nfo").exists()):
|
||||
# 生成电影描述文件
|
||||
self.__gen_movie_nfo_file(mediainfo=mediainfo,
|
||||
file_path=file_path)
|
||||
# 生成电影图片
|
||||
self.__save_image(url=mediainfo.poster_path,
|
||||
file_path=file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}"))
|
||||
image_path = file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}")
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=mediainfo.poster_path,
|
||||
file_path=image_path)
|
||||
# 背景图
|
||||
if mediainfo.backdrop_path:
|
||||
image_path = file_path.with_name(f"backdrop{Path(mediainfo.backdrop_path).suffix}")
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=mediainfo.backdrop_path,
|
||||
file_path=image_path)
|
||||
# 电视剧
|
||||
else:
|
||||
# 不存在时才处理
|
||||
if not file_path.parent.with_name("tvshow.nfo").exists():
|
||||
if self._force_nfo or not file_path.parent.with_name("tvshow.nfo").exists():
|
||||
# 根目录描述文件
|
||||
self.__gen_tv_nfo_file(mediainfo=mediainfo,
|
||||
dir_path=file_path.parents[1])
|
||||
# 生成根目录图片
|
||||
self.__save_image(url=mediainfo.poster_path,
|
||||
file_path=file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}"))
|
||||
image_path = file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}")
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=mediainfo.poster_path,
|
||||
file_path=image_path)
|
||||
# 背景图
|
||||
if mediainfo.backdrop_path:
|
||||
image_path = file_path.with_name(f"backdrop{Path(mediainfo.backdrop_path).suffix}")
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=mediainfo.backdrop_path,
|
||||
file_path=image_path)
|
||||
# 季目录NFO
|
||||
if not file_path.with_name("season.nfo").exists():
|
||||
if self._force_nfo or not file_path.with_name("season.nfo").exists():
|
||||
self.__gen_tv_season_nfo_file(mediainfo=mediainfo,
|
||||
season=meta.begin_season,
|
||||
season_path=file_path.parent)
|
||||
except Exception as e:
|
||||
logger.error(f"{file_path} 刮削失败:{e}")
|
||||
logger.error(f"{file_path} 刮削失败:{str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
|
||||
@@ -154,31 +185,51 @@ class DoubanScraper:
|
||||
# 保存
|
||||
self.__save_nfo(doc, season_path.joinpath("season.nfo"))
|
||||
|
||||
@staticmethod
|
||||
def __save_image(url: str, file_path: Path):
|
||||
def __save_image(self, url: str, file_path: Path):
|
||||
"""
|
||||
下载图片并保存
|
||||
"""
|
||||
if file_path.exists():
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
# 没有后缀时,处理URL转化为jpg格式
|
||||
if not file_path.suffix:
|
||||
url = url.replace("/format/webp", "/format/jpg")
|
||||
file_path.with_suffix(".jpg")
|
||||
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
|
||||
r = RequestUtils().get_res(url=url)
|
||||
if r:
|
||||
file_path.write_bytes(r.content)
|
||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||
self.__save_remove_file(file_path, r.content)
|
||||
else:
|
||||
file_path.write_bytes(r.content)
|
||||
logger.info(f"图片已保存:{file_path}")
|
||||
else:
|
||||
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
|
||||
except Exception as err:
|
||||
logger.error(f"{file_path.stem}图片下载失败:{err}")
|
||||
logger.error(f"{file_path.stem}图片下载失败:{str(err)}")
|
||||
|
||||
@staticmethod
|
||||
def __save_nfo(doc, file_path: Path):
|
||||
def __save_nfo(self, doc, file_path: Path):
|
||||
"""
|
||||
保存NFO
|
||||
"""
|
||||
if file_path.exists():
|
||||
return
|
||||
xml_str = doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||
file_path.write_bytes(xml_str)
|
||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||
self.__save_remove_file(file_path, xml_str)
|
||||
else:
|
||||
file_path.write_bytes(xml_str)
|
||||
logger.info(f"NFO文件已保存:{file_path}")
|
||||
|
||||
def __save_remove_file(self, out_file: Path, content: Union[str, bytes]):
|
||||
"""
|
||||
保存文件到远端
|
||||
"""
|
||||
temp_file = settings.TEMP_PATH / str(out_file)[1:]
|
||||
temp_file_dir = temp_file.parent
|
||||
if not temp_file_dir.exists():
|
||||
temp_file_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file.write_bytes(content)
|
||||
if self._transfer_type == 'rclone_move':
|
||||
SystemUtils.rclone_move(temp_file, out_file)
|
||||
elif self._transfer_type == 'rclone_copy':
|
||||
SystemUtils.rclone_copy(temp_file, out_file)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user