mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-12 03:59:41 +08:00
Compare commits
722 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
441cee4ee5 | ||
|
|
ebf2f53ae1 | ||
|
|
4e7000efbb | ||
|
|
0679a32659 | ||
|
|
148984ad0e | ||
|
|
dd8804ef3e | ||
|
|
fb0018dda6 | ||
|
|
c14e529c91 | ||
|
|
f6222122c0 | ||
|
|
3a18267ec0 | ||
|
|
b04bc74550 | ||
|
|
73a3a8cf94 | ||
|
|
6d66c5b577 | ||
|
|
c3ffe38d4d | ||
|
|
5108dbbeb5 | ||
|
|
cbf56bd9b7 | ||
|
|
67965b09a6 | ||
|
|
a2678d5815 | ||
|
|
36b25e6a08 | ||
|
|
c98c8c8836 | ||
|
|
423b7cf340 | ||
|
|
02acc8bc35 | ||
|
|
664b42f050 | ||
|
|
ca491891dc | ||
|
|
89e3d16f27 | ||
|
|
a02ea64068 | ||
|
|
0f0ace5ddc | ||
|
|
04d94f3bdd | ||
|
|
7d45b68b4f | ||
|
|
ccb47c0120 | ||
|
|
6939bff790 | ||
|
|
8cd0dd4198 | ||
|
|
d6d1f6519a | ||
|
|
906325710b | ||
|
|
05bafeaedf | ||
|
|
babad5a098 | ||
|
|
fe07602a35 | ||
|
|
492533dcdb | ||
|
|
45b044cd6b | ||
|
|
fc65cc3619 | ||
|
|
c6e069331c | ||
|
|
6a8a946ec8 | ||
|
|
d96e4561e2 | ||
|
|
172bc23b2a | ||
|
|
98baf922d6 | ||
|
|
9a7cdc1e74 | ||
|
|
4e22293cda | ||
|
|
f17890b6ce | ||
|
|
66af2de416 | ||
|
|
17e1e6b49b | ||
|
|
e501154ad4 | ||
|
|
c73cf1d7e2 | ||
|
|
a3603f79c8 | ||
|
|
294b4a6bf9 | ||
|
|
f365d93316 | ||
|
|
facd20ba3c | ||
|
|
d0e596c93c | ||
|
|
e20ec4ddf5 | ||
|
|
ba0a1cb1bd | ||
|
|
17438f8c5c | ||
|
|
e0c2ae0f0c | ||
|
|
9ebb211589 | ||
|
|
8a0350c566 | ||
|
|
765d37fd6a | ||
|
|
b3d57b868e | ||
|
|
18e7099848 | ||
|
|
27cb968a18 | ||
|
|
45bf84d448 | ||
|
|
85300b0931 | ||
|
|
ac87c778f4 | ||
|
|
1ed511034c | ||
|
|
ca7f121a21 | ||
|
|
c8e73e17d3 | ||
|
|
3bfc87f1cc | ||
|
|
e0e76bf3fe | ||
|
|
6a3e3f1562 | ||
|
|
59330657b2 | ||
|
|
927d510619 | ||
|
|
80a390ac6c | ||
|
|
cae563ce53 | ||
|
|
0495936ef8 | ||
|
|
34d27fe85b | ||
|
|
0e2c4d74d6 | ||
|
|
bd137de042 | ||
|
|
4a2688b52f | ||
|
|
36acb1daaa | ||
|
|
a0c3b6b26b | ||
|
|
7c93432505 | ||
|
|
2760f25992 | ||
|
|
d199c47666 | ||
|
|
a6550a21ef | ||
|
|
26a321f119 | ||
|
|
7e8f7be905 | ||
|
|
600b6144e4 | ||
|
|
dfb11420e5 | ||
|
|
584c8a2d94 | ||
|
|
536bd9268a | ||
|
|
5ee41b87a2 | ||
|
|
89b2fe10fe | ||
|
|
c180e50164 | ||
|
|
8f7b08afae | ||
|
|
72de8a2192 | ||
|
|
40d99f1dd5 | ||
|
|
ff07841dd6 | ||
|
|
828fc08362 | ||
|
|
3fd043bb9b | ||
|
|
f51c4ebed7 | ||
|
|
9b917cd4c2 | ||
|
|
91eac50ab9 | ||
|
|
f6468ad327 | ||
|
|
fb6c3a9f36 | ||
|
|
eb751bb581 | ||
|
|
f9069bf19b | ||
|
|
ef0c88a3b6 | ||
|
|
f1f8ccb5d6 | ||
|
|
2df113ad38 | ||
|
|
fa03232321 | ||
|
|
04f50284c6 | ||
|
|
9fc950c2ed | ||
|
|
9c1aeb933e | ||
|
|
1cee20134a | ||
|
|
0ca5f5bd89 | ||
|
|
25e0c25bc6 | ||
|
|
3f8453f054 | ||
|
|
cf259af2d1 | ||
|
|
0b70f74553 | ||
|
|
f0bc5d737b | ||
|
|
181d87f68e | ||
|
|
e37ac4da6a | ||
|
|
bd7ca7fa60 | ||
|
|
96de772119 | ||
|
|
72b6556c62 | ||
|
|
e4bb182668 | ||
|
|
595d097235 | ||
|
|
9b53aad34f | ||
|
|
e92a2e1ff1 | ||
|
|
764359c3e8 | ||
|
|
abd1a51863 | ||
|
|
2f05f8dc4d | ||
|
|
23c678e71e | ||
|
|
ef67b76453 | ||
|
|
c4e7870f7b | ||
|
|
9cef50436a | ||
|
|
a15aded0a0 | ||
|
|
8ac40dc205 | ||
|
|
92a5b3d227 | ||
|
|
761f1e7a4b | ||
|
|
ad0731e1ec | ||
|
|
a451f12d86 | ||
|
|
dcde619e77 | ||
|
|
92769b27f1 | ||
|
|
fa83168b92 | ||
|
|
f96295de3a | ||
|
|
6cecb3c6a6 | ||
|
|
b6486035c4 | ||
|
|
f7c1d28c0f | ||
|
|
47c2ae1c08 | ||
|
|
c03f24dcf5 | ||
|
|
6e2f5762b4 | ||
|
|
75330a08cc | ||
|
|
3f17e371c3 | ||
|
|
a820341ec7 | ||
|
|
c1f04f5631 | ||
|
|
a121e45b94 | ||
|
|
885ee976b2 | ||
|
|
e6229beb94 | ||
|
|
f2a40e1ec3 | ||
|
|
5f80aa5b7c | ||
|
|
14ff1e9af6 | ||
|
|
49ab5ac709 | ||
|
|
74c7a1927b | ||
|
|
cbd704373c | ||
|
|
a05724f664 | ||
|
|
97d0fc046a | ||
|
|
6248e34400 | ||
|
|
a442dab85b | ||
|
|
d4514edba6 | ||
|
|
0c581565ad | ||
|
|
350def0a6f | ||
|
|
5b3027c0a7 | ||
|
|
e4b90ca8f7 | ||
|
|
d917b00055 | ||
|
|
cc94c6c367 | ||
|
|
6410051e3a | ||
|
|
aaa1b80edf | ||
|
|
f345d94009 | ||
|
|
550fe26d76 | ||
|
|
7ad498b3a3 | ||
|
|
20eb0b4635 | ||
|
|
747dc3fafe | ||
|
|
4708fbb3cb | ||
|
|
6ba40edeb4 | ||
|
|
79cb28faf9 | ||
|
|
9acf05f334 | ||
|
|
d0af1bf075 | ||
|
|
f8a95cec4a | ||
|
|
3cd672fa8d | ||
|
|
fe03638552 | ||
|
|
1ae220c654 | ||
|
|
75c7e71ee6 | ||
|
|
4619158b99 | ||
|
|
3f88907ba9 | ||
|
|
ae6440bd0a | ||
|
|
261f5fc0c6 | ||
|
|
a5d044d535 | ||
|
|
6e607ca89f | ||
|
|
06e4b9ad83 | ||
|
|
c755dc9b85 | ||
|
|
209451d5f9 | ||
|
|
60b2d30f42 | ||
|
|
399d26929d | ||
|
|
f50c2e59a9 | ||
|
|
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 |
51
.github/workflows/build.yml
vendored
51
.github/workflows/build.yml
vendored
@@ -110,7 +110,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Pyinstaller
|
- name: Pyinstaller
|
||||||
run: |
|
run: |
|
||||||
pyinstaller windows.spec
|
pyinstaller frozen.spec
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Upload Windows File
|
- name: Upload Windows File
|
||||||
@@ -119,10 +119,54 @@ jobs:
|
|||||||
name: windows
|
name: windows
|
||||||
path: dist/MoviePilot.exe
|
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:
|
Create-release:
|
||||||
permissions: write-all
|
permissions: write-all
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [ Windows-build, Docker-build ]
|
needs: [ Windows-build, Docker-build, Linux-build-amd64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
@@ -139,7 +183,8 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mkdir releases
|
mkdir releases
|
||||||
mv ./windows/MoviePilot.exe ./releases/MoviePilot_v${{ env.app_version }}.exe
|
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
|
- name: Create Release
|
||||||
id: create_release
|
id: create_release
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,7 +9,10 @@ app/helper/*.so
|
|||||||
app/helper/*.pyd
|
app/helper/*.pyd
|
||||||
app/helper/*.bin
|
app/helper/*.bin
|
||||||
app/plugins/**
|
app/plugins/**
|
||||||
|
!app/plugins/__init__.py
|
||||||
|
config/cookies/**
|
||||||
config/user.db
|
config/user.db
|
||||||
config/sites/**
|
config/sites/**
|
||||||
*.pyc
|
*.pyc
|
||||||
*.log
|
*.log
|
||||||
|
.vscode
|
||||||
17
Dockerfile
17
Dockerfile
@@ -11,8 +11,7 @@ ENV LANG="C.UTF-8" \
|
|||||||
PORT=3001 \
|
PORT=3001 \
|
||||||
NGINX_PORT=3000 \
|
NGINX_PORT=3000 \
|
||||||
PROXY_HOST="" \
|
PROXY_HOST="" \
|
||||||
MOVIEPILOT_AUTO_UPDATE=true \
|
MOVIEPILOT_AUTO_UPDATE=release \
|
||||||
MOVIEPILOT_AUTO_UPDATE_DEV=false \
|
|
||||||
AUTH_SITE="iyuu" \
|
AUTH_SITE="iyuu" \
|
||||||
IYUU_SIGN=""
|
IYUU_SIGN=""
|
||||||
WORKDIR "/app"
|
WORKDIR "/app"
|
||||||
@@ -32,6 +31,9 @@ RUN apt-get update -y \
|
|||||||
jq \
|
jq \
|
||||||
haproxy \
|
haproxy \
|
||||||
fuse3 \
|
fuse3 \
|
||||||
|
rsync \
|
||||||
|
ffmpeg \
|
||||||
|
nano \
|
||||||
&& \
|
&& \
|
||||||
if [ "$(uname -m)" = "x86_64" ]; \
|
if [ "$(uname -m)" = "x86_64" ]; \
|
||||||
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
|
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
|
||||||
@@ -77,12 +79,11 @@ RUN cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
|||||||
&& FRONTEND_VERSION=$(curl -sL "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | jq -r .tag_name) \
|
&& 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 / - \
|
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
|
||||||
&& mv /dist /public \
|
&& mv /dist /public \
|
||||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d / - \
|
&& curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
||||||
&& mv -f /MoviePilot-Plugins-main/plugins/* /app/app/plugins/ \
|
&& mv -f /tmp/MoviePilot-Plugins-main/plugins/* /app/app/plugins/ \
|
||||||
&& rm -rf /MoviePilot-Plugins-main \
|
&& curl -sL "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
||||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" | busybox unzip -d / - \
|
&& mv -f /tmp/MoviePilot-Resources-main/resources/* /app/app/helper/ \
|
||||||
&& mv -f /MoviePilot-Resources-main/resources/* /app/app/helper/ \
|
&& rm -rf /tmp/*
|
||||||
&& rm -rf /MoviePilot-Resources-main
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
VOLUME [ "/config" ]
|
VOLUME [ "/config" ]
|
||||||
ENTRYPOINT [ "/entrypoint" ]
|
ENTRYPOINT [ "/entrypoint" ]
|
||||||
|
|||||||
355
README.md
355
README.md
@@ -7,19 +7,25 @@
|
|||||||
发布频道:https://t.me/moviepilot_channel
|
发布频道: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
|
||||||
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
|
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
|
||||||
- 重新设计了用户界面,更加美观易用。
|
- 重新设计了用户界面,更加美观易用。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
|
### 注意:管理员用户不要使用弱密码!如非必要不要暴露到公网。如被盗取管理账号权限,将会导致站点Cookie等敏感数据泄露!
|
||||||
|
|
||||||
### 1. **安装CookieCloud插件**
|
### 1. **安装CookieCloud插件**
|
||||||
|
|
||||||
站点信息需要通过CookieCloud同步获取,因此需要安装CookieCloud插件,将浏览器中的站点Cookie数据同步到云端后再同步到MoviePilot使用。 插件下载地址请点击 [这里](https://github.com/easychen/CookieCloud/releases)。
|
站点信息需要通过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)。
|
通过CookieCloud可以快速同步浏览器中保存的站点数据到MoviePilot,支持以下服务方式:
|
||||||
|
|
||||||
|
- 使用公共CookieCloud远程服务器(默认):服务器地址为:https://movie-pilot.org/cookiecloud
|
||||||
|
- 使用内建的本地Cookie服务:在 `设定` - `站点` 中打开`启用本地CookieCloud服务器`后,将启用内建的CookieCloud提供服务,服务地址为:`http://localhost:${NGINX_PORT}/cookiecloud/`, Cookie数据加密保存在配置文件目录下的`cookies`文件中
|
||||||
|
- 自建服务CookieCloud服务器:参考 [CookieCloud](https://github.com/easychen/CookieCloud) 项目进行搭建,docker镜像请点击 [这里](https://hub.docker.com/r/easychen/cookiecloud)
|
||||||
|
|
||||||
**声明:** 本项目不会收集用户敏感数据,Cookie同步也是基于CookieCloud项目实现,非本项目提供的能力。技术角度上CookieCloud采用端到端加密,在个人不泄露`用户KEY`和`端对端加密密码`的情况下第三方无法窃取任何用户信息(包括服务器持有者)。如果你不放心,可以不使用公共服务或者不使用本项目,但如果使用后发生了任何信息泄露与本项目无关!
|
**声明:** 本项目不会收集用户敏感数据,Cookie同步也是基于CookieCloud项目实现,非本项目提供的能力。技术角度上CookieCloud采用端到端加密,在个人不泄露`用户KEY`和`端对端加密密码`的情况下第三方无法窃取任何用户信息(包括服务器持有者)。如果你不放心,可以不使用公共服务或者不使用本项目,但如果使用后发生了任何信息泄露与本项目无关!
|
||||||
|
|
||||||
@@ -39,244 +45,176 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
|||||||
docker pull jxxghp/moviepilot:latest
|
docker pull jxxghp/moviepilot:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
- Windows
|
- Windows
|
||||||
|
|
||||||
下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases),双击运行后自动生成配置文件目录。
|
1. 独立执行文件版本:下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases),双击运行后自动生成配置文件目录,访问:http://localhost:3000
|
||||||
|
2. 安装包版本:[Windows-MoviePilot](https://github.com/developer-wlj/Windows-MoviePilot)
|
||||||
|
|
||||||
|
- 群晖套件
|
||||||
|
|
||||||
|
添加套件源:https://spk7.imnks.com/
|
||||||
|
|
||||||
- 本地运行
|
- 本地运行
|
||||||
|
|
||||||
1) 将工程 [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins) plugins目录下的所有文件复制到`app/plugins`目录
|
1) 将工程 [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins) plugins目录下的所有文件复制到`app/plugins`目录
|
||||||
2) 将工程 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) resources目录下的`user.sites.bin`文件复制到`config/sites`目录,将其余文件复制到`app/helper`目录
|
2) 将工程 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) resources目录下的所有文件复制到`app/helper`目录
|
||||||
3) 执行命令:`pip install -r requirements.txt` 安装依赖
|
3) 执行命令:`pip install -r requirements.txt` 安装依赖
|
||||||
4) 执行命令:`python app/main.py` 启动服务
|
4) 执行命令:`PYTHONPATH=. python app/main.py` 启动服务
|
||||||
|
5) 根据前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend) 说明,启动前端服务
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
项目的所有配置均通过环境变量进行设置,支持两种配置方式:
|
大部分配置可启动后通过WEB管理界面进行配置,但仍有部分配置需要通过环境变量/配置文件进行配置。
|
||||||
- 在Docker环境变量部分或Windows系统环境变量中进行参数配置,如未自动显示配置项则需要手动增加对应环境变量。
|
|
||||||
- 下载 [app.env](https://github.com/jxxghp/MoviePilot/raw/main/config/app.env) 配置文件,修改好配置后放置到配置文件映射路径根目录,配置项可根据说明自主增减。
|
|
||||||
|
|
||||||
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件 > 默认值,**部分参数如路径映射、站点认证、权限端口、时区等必须通过环境变量进行配置**。
|
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件(或通过WEB界面配置) > 默认值。
|
||||||
|
|
||||||
> ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
|
> ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
|
||||||
|
|
||||||
### 1. **基础设置**
|
### 1. **环境变量**
|
||||||
|
|
||||||
- **❗NGINX_PORT:** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突(仅支持环境变量配置)
|
- **❗NGINX_PORT:** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突
|
||||||
- **❗PORT:** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突(仅支持环境变量配置)
|
- **❗PORT:** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突
|
||||||
- **PUID**:运行程序用户的`uid`,默认`0`(仅支持环境变量配置)
|
- **PUID**:运行程序用户的`uid`,默认`0`
|
||||||
- **PGID**:运行程序用户的`gid`,默认`0`(仅支持环境变量配置)
|
- **PGID**:运行程序用户的`gid`,默认`0`
|
||||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`(仅支持环境变量配置)
|
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
|
||||||
- **PROXY_HOST:** 网络代理,访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`、`socks5://user:pass@host:port`(仅支持环境变量配置)
|
- **PROXY_HOST:** 网络代理,访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`、`socks5://user:pass@host:port`
|
||||||
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`false`,默认`true` **注意:如果出现网络问题可以配置`PROXY_HOST`**(仅支持环境变量配置)
|
- **MOVIEPILOT_AUTO_UPDATE:** 重启时自动更新,`true`/`release`/`dev`/`false`,默认`release`,需要能正常连接Github **注意:如果出现网络问题可以配置`PROXY_HOST`**
|
||||||
- **MOVIEPILOT_AUTO_UPDATE_DEV**:重启时更新到未发布的开发版本代码,`true`/`false`,默认`false`(仅支持环境变量配置)
|
- **❗AUTH_SITE:** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
|
||||||
---
|
|
||||||
- **❗SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
|
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
|
||||||
- **❗SUPERUSER_PASSWORD:** 超级管理员初始密码,默认`password`,建议修改为复杂密码
|
认证资源`v1.2.4+`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`/`qingwa`
|
||||||
|
|
||||||
|
| 站点 | 参数 |
|
||||||
|
|:------------:|:-----------------------------------------------------:|
|
||||||
|
| 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`:密钥 |
|
||||||
|
| qingwa | `QINGWA_UID`:用户ID<br/>`QINGWA_PASSKEY`:密钥 |
|
||||||
|
|
||||||
|
|
||||||
|
### 2. **环境变量 / 配置文件**
|
||||||
|
|
||||||
|
配置文件名:`app.env`,放配置文件根目录。
|
||||||
|
|
||||||
|
- **❗SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面,**注意:启动一次后再次修改该值不会生效,除非删除数据库文件!**
|
||||||
- **❗API_TOKEN:** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
- **❗API_TOKEN:** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
||||||
- **TMDB_API_DOMAIN:** TMDB API地址,默认`api.themoviedb.org`,也可配置为`api.tmdb.org`或其它中转代理服务地址,能连通即可
|
- **BIG_MEMORY_MODE:** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快
|
||||||
|
- **DOH_ENABLE:** DNS over HTTPS开关,`true`/`false`,默认`true`,开启后会使用DOH对api.themoviedb.org等域名进行解析,以减少被DNS污染的情况,提升网络连通性
|
||||||
|
- **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`
|
- **TMDB_IMAGE_DOMAIN:** TMDB图片地址,默认`image.tmdb.org`,可配置为其它中转代理以加速TMDB图片显示,如:`static-mdb.v.geilijiasu.com`
|
||||||
- **WALLPAPER:** 登录首页电影海报,`tmdb`/`bing`,默认`tmdb`
|
- **WALLPAPER:** 登录首页电影海报,`tmdb`/`bing`,默认`tmdb`
|
||||||
---
|
- **RECOGNIZE_SOURCE:** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类
|
||||||
- **SCRAP_METADATA:** 刮削入库的媒体文件,`true`/`false`,默认`true`
|
- **FANART_ENABLE:** Fanart开关,`true`/`false`,默认`true`,关闭后刮削的图片类型会大幅减少
|
||||||
- **SCRAP_SOURCE:** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
|
- **SCRAP_SOURCE:** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
|
||||||
- **SCRAP_FOLLOW_TMDB:** 新增已入库媒体是否跟随TMDB信息变化,`true`/`false`,默认`true`
|
- **SCRAP_FOLLOW_TMDB:** 新增已入库媒体是否跟随TMDB信息变化,`true`/`false`,默认`true`,为`false`时即使TMDB信息变化了也会仍然按历史记录中已入库的信息进行刮削
|
||||||
---
|
---
|
||||||
- **❗TRANSFER_TYPE:** 整理转移方式,支持`link`/`copy`/`move`/`softlink`/`rclone_copy`/`rclone_move` **注意:在`link`和`softlink`转移方式下,转移后的文件会继承源文件的权限掩码,不受`UMASK`影响;rclone需要自行映射rclone配置目录到容器中或在容器内完成rclone配置,节点名称必须为:`MP`**
|
- **AUTO_DOWNLOAD_USER:** 远程交互搜索时自动择优下载的用户ID(消息通知渠道的用户ID),多个用户使用,分割,设置为 all 代表全部用户自动择优下载,未设置需要手动选择资源或者回复`0`才自动择优下载
|
||||||
- **❗OVERWRITE_MODE:** 转移覆盖模式,默认为`size`,支持`nerver`/`size`/`always`,分别表示`不覆盖`/`根据文件大小覆盖(大覆盖小)`/`总是覆盖`
|
|
||||||
- **❗LIBRARY_PATH:** 媒体库目录,多个目录使用`,`分隔
|
|
||||||
- **LIBRARY_MOVIE_NAME:** 电影媒体库目录名称(不是完整路径),默认`电影`
|
|
||||||
- **LIBRARY_TV_NAME:** 电视剧媒体库目录称(不是完整路径),默认`电视剧`
|
|
||||||
- **LIBRARY_ANIME_NAME:** 动漫媒体库目录称(不是完整路径),默认`电视剧/动漫`
|
|
||||||
- **LIBRARY_CATEGORY:** 媒体库二级分类开关,`true`/`false`,默认`false`,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在媒体库目录下建立二级目录分类
|
|
||||||
---
|
|
||||||
- **❗COOKIECLOUD_HOST:** CookieCloud服务器地址,格式:`http(s)://ip:port`,不配置默认使用内建服务器`https://movie-pilot.org/cookiecloud`
|
|
||||||
- **❗COOKIECLOUD_KEY:** CookieCloud用户KEY
|
|
||||||
- **❗COOKIECLOUD_PASSWORD:** CookieCloud端对端加密密码
|
|
||||||
- **❗COOKIECLOUD_INTERVAL:** CookieCloud同步间隔(分钟)
|
|
||||||
- **❗USER_AGENT:** CookieCloud保存Cookie对应的浏览器UA,建议配置,设置后可增加连接站点的成功率,同步站点后可以在管理界面中修改
|
|
||||||
---
|
|
||||||
- **SUBSCRIBE_MODE:** 订阅模式,`rss`/`spider`,默认`spider`,`rss`模式通过定时刷新RSS来匹配订阅(RSS地址会自动获取,也可手动维护),对站点压力小,同时可设置订阅刷新周期,24小时运行,但订阅和下载通知不能过滤和显示免费,推荐使用rss模式。
|
|
||||||
- **SUBSCRIBE_RSS_INTERVAL:** RSS订阅模式刷新时间间隔(分钟),默认`30`分钟,不能小于5分钟。
|
|
||||||
- **SUBSCRIBE_SEARCH:** 订阅搜索,`true`/`false`,默认`false`,开启后会每隔24小时对所有订阅进行全量搜索,以补齐缺失剧集(一般情况下正常订阅即可,订阅搜索只做为兜底,会增加站点压力,不建议开启)。
|
|
||||||
- **SEARCH_SOURCE:** 媒体信息搜索来源,`themoviedb`/`douban`,默认`themoviedb`
|
|
||||||
- **AUTO_DOWNLOAD_USER:** 远程交互搜索时自动择优下载的用户ID,多个用户使用,分割,未设置需要选择资源或者回复`0`
|
|
||||||
---
|
---
|
||||||
- **OCR_HOST:** OCR识别服务器地址,格式:`http(s)://ip:port`,用于识别站点验证码实现自动登录获取Cookie等,不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
|
- **OCR_HOST:** OCR识别服务器地址,格式:`http(s)://ip:port`,用于识别站点验证码实现自动登录获取Cookie等,不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
|
||||||
- **PLUGIN_MARKET:** 插件市场仓库地址,多个地址使用`,`分隔,保留最后的/,默认为官方插件仓库:`https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/`。
|
|
||||||
---
|
---
|
||||||
- **❗MESSAGER:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
|
||||||
|
|
||||||
- `wechat`设置项:
|
|
||||||
|
|
||||||
- **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代理服务器(后面不要加/)
|
|
||||||
|
|
||||||
- `telegram`设置项:
|
|
||||||
|
|
||||||
- **TELEGRAM_TOKEN:** Telegram Bot Token
|
|
||||||
- **TELEGRAM_CHAT_ID:** Telegram Chat ID
|
|
||||||
- **TELEGRAM_USERS:** Telegram 用户ID,多个使用,分隔,只有用户ID在列表中才可以使用Bot,如未设置则均可以使用Bot
|
|
||||||
- **TELEGRAM_ADMINS:** Telegram 管理员ID,多个使用,分隔,只有管理员才可以操作Bot菜单,如未设置则均可以操作菜单(可选)
|
|
||||||
|
|
||||||
- `slack`设置项:
|
|
||||||
|
|
||||||
- **SLACK_OAUTH_TOKEN:** Slack Bot User OAuth Token
|
|
||||||
- **SLACK_APP_TOKEN:** Slack App-Level Token
|
|
||||||
- **SLACK_CHANNEL:** Slack 频道名称,默认`全体`(可选)
|
|
||||||
|
|
||||||
- `synologychat`设置项:
|
|
||||||
|
|
||||||
- **SYNOLOGYCHAT_WEBHOOK:** 在Synology Chat中创建机器人,获取机器人`传入URL`
|
|
||||||
- **SYNOLOGYCHAT_TOKEN:** SynologyChat机器人`令牌`
|
|
||||||
|
|
||||||
---
|
|
||||||
- **❗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](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在下载目录下建立二级目录分类
|
|
||||||
- **DOWNLOAD_SUBTITLE:** 下载站点字幕,`true`/`false`,默认`true`
|
- **DOWNLOAD_SUBTITLE:** 下载站点字幕,`true`/`false`,默认`true`
|
||||||
- **DOWNLOADER_MONITOR:** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
|
|
||||||
- **TORRENT_TAG:** 下载器种子标签,默认为`MOVIEPILOT`,设置后只有MoviePilot添加的下载才会处理,留空所有下载器中的任务均会处理
|
|
||||||
- **❗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`
|
|
||||||
- **QB_SEQUENTIAL:** qbittorrent按顺序下载,`true`/`false`,默认`true`
|
|
||||||
- **QB_FORCE_RESUME:** qbittorrent忽略队列限制,强制继续,`true`/`false`,默认 `false`
|
|
||||||
|
|
||||||
- `transmission`设置项:
|
|
||||||
|
|
||||||
- **TR_HOST:** transmission地址,格式:`ip:port`,https需要添加`https://`前缀
|
|
||||||
- **TR_USER:** transmission用户名
|
|
||||||
- **TR_PASSWORD:** transmission密码
|
|
||||||
|
|
||||||
---
|
---
|
||||||
- **❗MEDIASERVER:** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
|
- **SEARCH_MULTIPLE_NAME:** 搜索时是否使用多个名称搜索,`true`/`false`,默认`false`,开启后会使用多个名称进行搜索,搜索结果会更全面,但会增加搜索时间;关闭时只要其中一个名称搜索到结果或全部名称搜索完毕即停止
|
||||||
|
---
|
||||||
|
- **MOVIE_RENAME_FORMAT:** 电影重命名格式,基于jinjia2语法
|
||||||
|
|
||||||
- `emby`设置项:
|
`MOVIE_RENAME_FORMAT`支持的配置项:
|
||||||
|
|
||||||
- **EMBY_HOST:** Emby服务器地址,格式:`ip:port`,https需要添加`https://`前缀
|
> `title`: TMDB/豆瓣中的标题
|
||||||
- **EMBY_API_KEY:** Emby Api Key,在`设置->高级->API密钥`处生成
|
> `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}}
|
||||||
|
```
|
||||||
|
|
||||||
- `jellyfin`设置项:
|
- **TV_RENAME_FORMAT:** 电视剧重命名格式,基于jinjia2语法
|
||||||
|
|
||||||
- **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`,留空则不同步
|
|
||||||
- **MEDIASERVER_SYNC_BLACKLIST:** 媒体服务器同步黑名单,多个媒体库名称使用,分割
|
|
||||||
|
|
||||||
|
|
||||||
### 2. **用户认证**
|
|
||||||
|
|
||||||
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数(**仅能通过环境变量配置**)
|
|
||||||
|
|
||||||
`AUTH_SITE`支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
|
|
||||||
|
|
||||||
- **❗AUTH_SITE:** 认证站点,支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`ptlsp`/`xingtan`
|
|
||||||
|
|
||||||
| 站点 | 参数 |
|
|
||||||
|:------------:|:-----------------------------------------------------:|
|
|
||||||
| 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`:密钥 |
|
|
||||||
| xingtan | `XINGTAN_UID`:用户ID<br/>`XINGTAN_PASSKEY`:密钥 |
|
|
||||||
|
|
||||||
|
|
||||||
### 2. **进阶配置**
|
|
||||||
|
|
||||||
- **BIG_MEMORY_MODE:** 大内存模式,默认为`false`,开启后会占用更多的内存,但响应速度会更快
|
|
||||||
|
|
||||||
- **MOVIE_RENAME_FORMAT:** 电影重命名格式
|
|
||||||
|
|
||||||
`MOVIE_RENAME_FORMAT`支持的配置项:
|
|
||||||
|
|
||||||
> `title`: 标题
|
|
||||||
> `original_name`: 原文件名
|
|
||||||
> `original_title`: 原语种标题
|
|
||||||
> `name`: 识别名称
|
|
||||||
> `year`: 年份
|
|
||||||
> `resourceType`:资源类型
|
|
||||||
> `effect`:特效
|
|
||||||
> `edition`: 版本(资源类型+特效)
|
|
||||||
> `videoFormat`: 分辨率
|
|
||||||
> `releaseGroup`: 制作组/字幕组
|
|
||||||
> `customization`: 自定义占位符
|
|
||||||
> `videoCodec`: 视频编码
|
|
||||||
> `audioCodec`: 音频编码
|
|
||||||
> `tmdbid`: TMDBID
|
|
||||||
> `imdbid`: IMDBID
|
|
||||||
> `part`:段/节
|
|
||||||
> `fileExt`:文件扩展名
|
|
||||||
> `tmdbid`:TMDB ID
|
|
||||||
> `imdbid`:IMDB ID
|
|
||||||
> `customization`:自定义占位符
|
|
||||||
|
|
||||||
`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
|
|
||||||
> `episode_title`: 集标题
|
|
||||||
|
|
||||||
`TV_RENAME_FORMAT`默认配置格式:
|
|
||||||
|
|
||||||
```
|
|
||||||
{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
`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}}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### 3. **优先级规则**
|
### 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管理界面中禁用,无法同步的站点可手动新增。
|
### 1. **WEB后台管理**
|
||||||
- 通过WEB进行管理,将WEB添加到手机桌面获得类App使用效果,管理界面端口:`3000`,后台API端口:`3001`。
|
- 通过设置的超级管理员用户登录后台管理界面(`SUPERUSER`配置项,默认用户:admin,默认端口:3000)
|
||||||
- 通过下载器监控或使用目录监控插件实现自动整理入库刮削(二选一)。
|
> ❗**注意:超级管理员用户初始密码为自动生成,需要在首次运行时的后台日志中查看!** 如首次运行日志丢失,则需要删除配置文件目录下的`user.db`文件,然后重启服务。
|
||||||
- 通过微信/Telegram/Slack/SynologyChat远程管理,其中微信/Telegram将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示);微信需要在官方页面设置回调地址,SynologyChat需要设置机器人传入地址,地址相对路径为:`/api/v1/message/`。
|
### 2. **站点维护**
|
||||||
- 设置媒体服务器Webhook,通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot`(`3001`端口),其中`moviepilot`为设置的`API_TOKEN`。
|
- 通过CookieCloud同步快速添加站点,不需要使用的站点可在WEB管理界面中禁用或删除,无法同步的站点也可手动新增。
|
||||||
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr(`API服务端口`),可使用Overseerr/Jellyseerr浏览订阅。
|
- 需要通过环境变量设置用户认证信息且认证成功后才能使用站点相关功能,未认证通过时站点相关的插件也会无法显示。
|
||||||
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`
|
### 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一样的使用体验。
|
||||||
|
|
||||||
### **注意**
|
### **注意**
|
||||||
- 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
|
- 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
|
||||||
@@ -290,6 +228,14 @@ location / {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- 反代使用ssl时,需要开启`http2`,否则会导致日志加载时间过长或不可用。以`Nginx`为例:
|
||||||
|
```nginx configuration
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
```
|
||||||
- 新建的企业微信应用需要固定公网IP的代理才能收到消息,代理添加以下代码:
|
- 新建的企业微信应用需要固定公网IP的代理才能收到消息,代理添加以下代码:
|
||||||
```nginx configuration
|
```nginx configuration
|
||||||
location /cgi-bin/gettoken {
|
location /cgi-bin/gettoken {
|
||||||
@@ -303,6 +249,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,8 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
|
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, bangumi
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
||||||
@@ -21,3 +22,6 @@ api_router.include_router(download.router, prefix="/download", tags=["download"]
|
|||||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||||
api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
|
api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
|
||||||
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
|
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
|
||||||
|
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])
|
||||||
|
api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"])
|
||||||
|
|
||||||
|
|||||||
64
app/api/endpoints/bangumi.py
Normal file
64
app/api/endpoints/bangumi.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from typing import List, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.chain.bangumi import BangumiChain
|
||||||
|
from app.core.context import MediaInfo
|
||||||
|
from app.core.security import verify_token
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calendar", summary="Bangumi每日放送", response_model=List[schemas.MediaInfo])
|
||||||
|
def calendar(page: int = 1,
|
||||||
|
count: int = 30,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
浏览Bangumi每日放送
|
||||||
|
"""
|
||||||
|
infos = BangumiChain().calendar(page=page, count=count)
|
||||||
|
if not infos:
|
||||||
|
return []
|
||||||
|
medias = [MediaInfo(bangumi_info=info) for info in infos]
|
||||||
|
return [media.to_dict() for media in medias]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.BangumiPerson])
|
||||||
|
def bangumi_credits(bangumiid: int,
|
||||||
|
page: int = 1,
|
||||||
|
count: int = 20,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询Bangumi演职员表
|
||||||
|
"""
|
||||||
|
persons = BangumiChain().bangumi_credits(bangumiid, page=page, count=count)
|
||||||
|
if not persons:
|
||||||
|
return []
|
||||||
|
return [schemas.BangumiPerson(**person) for person in persons]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recommend/{bangumiid}", summary="查询Bangumi推荐", response_model=List[schemas.MediaInfo])
|
||||||
|
def bangumi_recommend(bangumiid: int,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询Bangumi推荐
|
||||||
|
"""
|
||||||
|
infos = BangumiChain().bangumi_recommend(bangumiid)
|
||||||
|
if not infos:
|
||||||
|
return []
|
||||||
|
medias = [MediaInfo(bangumi_info=info) for info in infos]
|
||||||
|
return [media.to_dict() for media in medias]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo)
|
||||||
|
def bangumi_info(bangumiid: int,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询Bangumi详情
|
||||||
|
"""
|
||||||
|
info = BangumiChain().bangumi_info(bangumiid)
|
||||||
|
if info:
|
||||||
|
return MediaInfo(bangumi_info=info).to_dict()
|
||||||
|
else:
|
||||||
|
return schemas.MediaInfo()
|
||||||
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.dashboard import DashboardChain
|
from app.chain.dashboard import DashboardChain
|
||||||
from app.core.config import settings
|
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 import get_db
|
||||||
from app.db.models.transferhistory import TransferHistory
|
from app.db.models.transferhistory import TransferHistory
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
@@ -34,6 +34,14 @@ def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
return schemas.Statistic()
|
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)
|
@router.get("/storage", summary="存储空间", response_model=schemas.Storage)
|
||||||
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -46,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])
|
@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo])
|
||||||
def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -59,18 +75,24 @@ def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
"""
|
"""
|
||||||
查询下载器信息
|
查询下载器信息
|
||||||
"""
|
"""
|
||||||
transfer_info = DashboardChain().downloader_info()
|
downloader_info = schemas.DownloaderInfo()
|
||||||
free_space = SystemUtils.free_space(settings.SAVE_PATH)
|
transfer_infos = DashboardChain().downloader_info()
|
||||||
if transfer_info:
|
if transfer_infos:
|
||||||
return schemas.DownloaderInfo(
|
for transfer_info in transfer_infos:
|
||||||
download_speed=transfer_info.download_speed,
|
downloader_info.download_speed += transfer_info.download_speed
|
||||||
upload_speed=transfer_info.upload_speed,
|
downloader_info.upload_speed += transfer_info.upload_speed
|
||||||
download_size=transfer_info.download_size,
|
downloader_info.download_size += transfer_info.download_size
|
||||||
upload_size=transfer_info.upload_size,
|
downloader_info.upload_size += transfer_info.upload_size
|
||||||
free_space=free_space
|
downloader_info.free_space = SystemUtils.free_space(settings.SAVE_PATH)
|
||||||
)
|
return downloader_info
|
||||||
else:
|
|
||||||
return schemas.DownloaderInfo()
|
|
||||||
|
@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])
|
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
|
||||||
@@ -81,6 +103,14 @@ def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
return Scheduler().list()
|
return Scheduler().list()
|
||||||
|
|
||||||
|
|
||||||
|
@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])
|
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
|
||||||
def transfer(days: int = 7, db: Session = Depends(get_db),
|
def transfer(days: int = 7, db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
@@ -99,9 +129,25 @@ def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
return SystemUtils.cpu_usage()
|
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])
|
@router.get("/memory", summary="获取当前内存使用量和使用率", response_model=List[int])
|
||||||
def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
获取当前内存使用率
|
获取当前内存使用率
|
||||||
"""
|
"""
|
||||||
return SystemUtils.memory_usage()
|
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()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.utils.http import RequestUtils
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/img/{imgurl:path}", summary="豆瓣图片代理")
|
@router.get("/img", summary="豆瓣图片代理")
|
||||||
def douban_img(imgurl: str) -> Any:
|
def douban_img(imgurl: str) -> Any:
|
||||||
"""
|
"""
|
||||||
豆瓣图片代理
|
豆瓣图片代理
|
||||||
@@ -28,20 +28,6 @@ def douban_img(imgurl: str) -> Any:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recognize/{doubanid}", summary="豆瓣ID识别", response_model=schemas.Context)
|
|
||||||
def recognize_doubanid(doubanid: str,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
根据豆瓣ID识别媒体信息
|
|
||||||
"""
|
|
||||||
# 识别媒体信息
|
|
||||||
context = DoubanChain().recognize_by_doubanid(doubanid=doubanid)
|
|
||||||
if context:
|
|
||||||
return context.to_dict()
|
|
||||||
else:
|
|
||||||
return schemas.Context()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
|
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
|
||||||
def movie_showing(page: int = 1,
|
def movie_showing(page: int = 1,
|
||||||
count: int = 30,
|
count: int = 30,
|
||||||
@@ -104,7 +90,7 @@ def movie_top250(page: int = 1,
|
|||||||
"""
|
"""
|
||||||
浏览豆瓣剧集信息
|
浏览豆瓣剧集信息
|
||||||
"""
|
"""
|
||||||
movies = DoubanChain().movie_top250(page=page, count=count)
|
movies = DoubanChain().movie_top250(page=page, count=count) or []
|
||||||
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
|
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
|
||||||
|
|
||||||
|
|
||||||
@@ -115,7 +101,7 @@ def tv_weekly_chinese(page: int = 1,
|
|||||||
"""
|
"""
|
||||||
中国每周剧集口碑榜
|
中国每周剧集口碑榜
|
||||||
"""
|
"""
|
||||||
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
|
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count) or []
|
||||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +112,7 @@ def tv_weekly_global(page: int = 1,
|
|||||||
"""
|
"""
|
||||||
全球每周剧集口碑榜
|
全球每周剧集口碑榜
|
||||||
"""
|
"""
|
||||||
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
|
tvs = DoubanChain().tv_weekly_global(page=page, count=count) or []
|
||||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||||
|
|
||||||
|
|
||||||
@@ -137,10 +123,73 @@ def tv_animation(page: int = 1,
|
|||||||
"""
|
"""
|
||||||
热门动画剧集
|
热门动画剧集
|
||||||
"""
|
"""
|
||||||
tvs = DoubanChain().tv_animation(page=page, count=count)
|
tvs = DoubanChain().tv_animation(page=page, count=count) or []
|
||||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
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) or []
|
||||||
|
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) or []
|
||||||
|
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)
|
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
|
||||||
def douban_info(doubanid: str,
|
def douban_info(doubanid: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.db.models.user import User
|
|
||||||
from app.db.userauth import get_current_active_user
|
|
||||||
from app.chain.douban import DoubanChain
|
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.core.context import MediaInfo, Context, TorrentInfo
|
from app.core.context import MediaInfo, Context, TorrentInfo
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
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 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
||||||
def read_downloading(
|
def read(
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询正在下载的任务
|
查询正在下载的任务
|
||||||
@@ -26,11 +24,10 @@ def read_downloading(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/", summary="添加下载", response_model=schemas.Response)
|
@router.post("/", summary="添加下载", response_model=schemas.Response)
|
||||||
def add_downloading(
|
def download(
|
||||||
media_in: schemas.MediaInfo,
|
media_in: schemas.MediaInfo,
|
||||||
torrent_in: schemas.TorrentInfo,
|
torrent_in: schemas.TorrentInfo,
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user)) -> Any:
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
"""
|
||||||
添加下载任务
|
添加下载任务
|
||||||
"""
|
"""
|
||||||
@@ -54,42 +51,36 @@ def add_downloading(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[NotExistMediaInfo])
|
@router.post("/add", summary="添加下载", response_model=schemas.Response)
|
||||||
def exists(media_in: schemas.MediaInfo,
|
def add(
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
torrent_in: schemas.TorrentInfo,
|
||||||
|
current_user: User = Depends(get_current_active_user)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询缺失媒体信息
|
添加下载任
|
||||||
"""
|
"""
|
||||||
|
# 元数据
|
||||||
|
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
||||||
# 媒体信息
|
# 媒体信息
|
||||||
mediainfo = MediaInfo()
|
mediainfo = MediaChain().recognize_media(meta=metainfo)
|
||||||
meta = MetaInfo(title=media_in.title)
|
if not mediainfo:
|
||||||
if media_in.tmdb_id:
|
return schemas.Response(success=False, message="无法识别媒体信息")
|
||||||
mediainfo.from_dict(media_in.dict())
|
# 种子信息
|
||||||
elif media_in.douban_id:
|
torrentinfo = TorrentInfo()
|
||||||
context = DoubanChain().recognize_by_doubanid(doubanid=media_in.douban_id)
|
torrentinfo.from_dict(torrent_in.dict())
|
||||||
if context:
|
# 上下文
|
||||||
mediainfo = context.media_info
|
context = Context(
|
||||||
meta = context.meta_info
|
meta_info=metainfo,
|
||||||
else:
|
media_info=mediainfo,
|
||||||
context = MediaChain().recognize_by_title(title=f"{media_in.title} {media_in.year}")
|
torrent_info=torrentinfo
|
||||||
if context:
|
)
|
||||||
mediainfo = context.media_info
|
did = DownloadChain().download_single(context=context, username=current_user.name)
|
||||||
meta = context.meta_info
|
return schemas.Response(success=True if did else False, data={
|
||||||
# 查询缺失信息
|
"download_id": did
|
||||||
if not mediainfo or not mediainfo.tmdb_id:
|
})
|
||||||
raise HTTPException(status_code=404, detail="媒体信息不存在")
|
|
||||||
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(mediainfo.tmdb_id):
|
|
||||||
# 电视剧返回缺失的剧集
|
|
||||||
return list(no_exists.get(mediainfo.tmdb_id).values())
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
|
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
|
||||||
def start_downloading(
|
def start(
|
||||||
hashString: str,
|
hashString: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -100,22 +91,22 @@ def start_downloading(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
|
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
|
||||||
def stop_downloading(
|
def stop(
|
||||||
hashString: str,
|
hashString: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
控制下载任务
|
暂停下载任务
|
||||||
"""
|
"""
|
||||||
ret = DownloadChain().set_downloading(hashString, "stop")
|
ret = DownloadChain().set_downloading(hashString, "stop")
|
||||||
return schemas.Response(success=True if ret else False)
|
return schemas.Response(success=True if ret else False)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
||||||
def remove_downloading(
|
def info(
|
||||||
hashString: str,
|
hashString: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
控制下载任务
|
删除下载任务
|
||||||
"""
|
"""
|
||||||
ret = DownloadChain().remove_downloading(hashString)
|
ret = DownloadChain().remove_downloading(hashString)
|
||||||
return schemas.Response(success=True if ret else False)
|
return schemas.Response(success=True if ret else False)
|
||||||
|
|||||||
@@ -42,17 +42,26 @@ def delete_download_history(history_in: schemas.DownloadHistory,
|
|||||||
def transfer_history(title: str = None,
|
def transfer_history(title: str = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
count: int = 30,
|
count: int = 30,
|
||||||
|
status: bool = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询转移历史记录
|
查询转移历史记录
|
||||||
"""
|
"""
|
||||||
|
if title == "失败":
|
||||||
|
title = None
|
||||||
|
status = False
|
||||||
|
elif title == "成功":
|
||||||
|
title = None
|
||||||
|
status = True
|
||||||
|
|
||||||
if title:
|
if title:
|
||||||
total = TransferHistory.count_by_title(db, title)
|
total = TransferHistory.count_by_title(db, title=title, status=status)
|
||||||
result = TransferHistory.list_by_title(db, title, page, count)
|
result = TransferHistory.list_by_title(db, title=title, page=page,
|
||||||
|
count=count, status=status)
|
||||||
else:
|
else:
|
||||||
result = TransferHistory.list_by_page(db, page, count)
|
result = TransferHistory.list_by_page(db, page=page, count=count, status=status)
|
||||||
total = TransferHistory.count(db)
|
total = TransferHistory.count(db, status=status)
|
||||||
|
|
||||||
return schemas.Response(success=True,
|
return schemas.Response(success=True,
|
||||||
data={
|
data={
|
||||||
@@ -87,7 +96,8 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
|||||||
eventmanager.send_event(
|
eventmanager.send_event(
|
||||||
EventType.DownloadFileDeleted,
|
EventType.DownloadFileDeleted,
|
||||||
{
|
{
|
||||||
"src": history.src
|
"src": history.src,
|
||||||
|
"hash": history.download_hash
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# 删除记录
|
# 删除记录
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Form
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -21,37 +21,48 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
|
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
|
||||||
async def login_access_token(
|
async def login_access_token(
|
||||||
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
|
db: Session = Depends(get_db),
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
otp_password: str = Form(None)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
获取认证Token
|
获取认证Token
|
||||||
"""
|
"""
|
||||||
# 检查数据库
|
# 检查数据库
|
||||||
user = User.authenticate(
|
success, user = User.authenticate(
|
||||||
db=db,
|
db=db,
|
||||||
name=form_data.username,
|
name=form_data.username,
|
||||||
password=form_data.password
|
password=form_data.password,
|
||||||
|
otp_password=otp_password
|
||||||
)
|
)
|
||||||
if not user:
|
if not success:
|
||||||
# 请求协助认证
|
# 认证不成功
|
||||||
logger.warn("登录用户本地不匹配,尝试辅助认证 ...")
|
if not user:
|
||||||
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="用户名或密码不正确")
|
token = UserChain().user_authenticate(form_data.username, form_data.password)
|
||||||
else:
|
if not token:
|
||||||
logger.info(f"辅助认证成功,用户信息: {token}")
|
logger.warn(f"用户 {form_data.username} 登录失败!")
|
||||||
# 加入用户信息表
|
raise HTTPException(status_code=401, detail="用户名、密码、二次校验码不正确")
|
||||||
user = User.get_by_name(db=db, name=form_data.username)
|
else:
|
||||||
if not user:
|
logger.info(f"用户 {form_data.username} 辅助认证成功,用户信息: {token},以普通用户登录...")
|
||||||
logger.info(f"用户不存在,创建用户: {form_data.username}")
|
# 加入用户信息表
|
||||||
|
logger.info(f"创建用户: {form_data.username}")
|
||||||
user = User(name=form_data.username, is_active=True,
|
user = User(name=form_data.username, is_active=True,
|
||||||
is_superuser=False, hashed_password=get_password_hash(token))
|
is_superuser=False, hashed_password=get_password_hash(token))
|
||||||
user.create(db)
|
user.create(db)
|
||||||
elif not user.is_active:
|
else:
|
||||||
|
# 用户存在,但认证失败
|
||||||
|
logger.warn(f"用户 {user.name} 登录失败!")
|
||||||
|
raise HTTPException(status_code=401, detail="用户名、密码或二次校验码不正确")
|
||||||
|
elif user and not user.is_active:
|
||||||
raise HTTPException(status_code=403, detail="用户未启用")
|
raise HTTPException(status_code=403, detail="用户未启用")
|
||||||
|
logger.info(f"用户 {user.name} 登录成功!")
|
||||||
return schemas.Token(
|
return schemas.Token(
|
||||||
access_token=security.create_access_token(
|
access_token=security.create_access_token(
|
||||||
user.id,
|
userid=user.id,
|
||||||
|
username=user.name,
|
||||||
|
super_user=user.is_superuser,
|
||||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
),
|
),
|
||||||
token_type="bearer",
|
token_type="bearer",
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
|
from pathlib import Path
|
||||||
from typing import List, Any
|
from typing import List, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.douban import DoubanChain
|
|
||||||
from app.chain.media import MediaChain
|
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.context import Context
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||||
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.mediaserver_oper import MediaServerOper
|
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -25,15 +22,27 @@ def recognize(title: str,
|
|||||||
根据标题、副标题识别媒体信息
|
根据标题、副标题识别媒体信息
|
||||||
"""
|
"""
|
||||||
# 识别媒体信息
|
# 识别媒体信息
|
||||||
context = MediaChain().recognize_by_title(title=title, subtitle=subtitle)
|
metainfo = MetaInfo(title, subtitle)
|
||||||
if context:
|
mediainfo = MediaChain().recognize_by_meta(metainfo)
|
||||||
return context.to_dict()
|
if mediainfo:
|
||||||
|
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
|
||||||
return schemas.Context()
|
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)
|
@router.get("/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context)
|
||||||
def recognize(path: str,
|
def recognize_file(path: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据文件路径识别媒体信息
|
根据文件路径识别媒体信息
|
||||||
"""
|
"""
|
||||||
@@ -44,6 +53,16 @@ def recognize(path: str,
|
|||||||
return schemas.Context()
|
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])
|
@router.get("/search", summary="搜索媒体信息", response_model=List[schemas.MediaInfo])
|
||||||
def search_by_title(title: str,
|
def search_by_title(title: str,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
@@ -58,49 +77,47 @@ def search_by_title(title: str,
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/exists", summary="本地是否存在", response_model=schemas.Response)
|
@router.get("/scrape", summary="刮削媒体信息", response_model=schemas.Response)
|
||||||
def exists(title: str = None,
|
def scrape(path: str,
|
||||||
year: int = None,
|
|
||||||
mtype: str = None,
|
|
||||||
tmdbid: int = None,
|
|
||||||
season: int = None,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
判断本地是否存在
|
刮削媒体信息
|
||||||
"""
|
"""
|
||||||
meta = MetaInfo(title)
|
if not path:
|
||||||
if not season:
|
return schemas.Response(success=False, message="刮削路径无效")
|
||||||
season = meta.begin_season
|
scrape_path = Path(path)
|
||||||
exist = MediaServerOper(db).exists(
|
if not scrape_path.exists():
|
||||||
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
|
return schemas.Response(success=False, message="刮削路径不存在")
|
||||||
)
|
# 识别
|
||||||
return schemas.Response(success=True if exist else False, data={
|
chain = MediaChain()
|
||||||
"item": exist or {}
|
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)
|
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||||
def tmdb_info(mediaid: str, type_name: str,
|
def media_info(mediaid: str, type_name: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧
|
根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧
|
||||||
"""
|
"""
|
||||||
mtype = MediaType(type_name)
|
mtype = MediaType(type_name)
|
||||||
|
tmdbid, doubanid, bangumiid = None, None, None
|
||||||
if mediaid.startswith("tmdb:"):
|
if mediaid.startswith("tmdb:"):
|
||||||
result = TmdbChain().tmdb_info(int(mediaid[5:]), mtype)
|
tmdbid = int(mediaid[5:])
|
||||||
return MediaInfo(tmdb_info=result).to_dict()
|
|
||||||
elif mediaid.startswith("douban:"):
|
elif mediaid.startswith("douban:"):
|
||||||
# 查询豆瓣信息
|
doubanid = mediaid[7:]
|
||||||
doubaninfo = DoubanChain().douban_info(doubanid=mediaid[7:])
|
elif mediaid.startswith("bangumi:"):
|
||||||
if not doubaninfo:
|
bangumiid = int(mediaid[8:])
|
||||||
return schemas.MediaInfo()
|
if not tmdbid and not doubanid and not bangumiid:
|
||||||
result = DoubanChain().recognize_by_doubaninfo(doubaninfo)
|
|
||||||
if result:
|
|
||||||
# TMDB
|
|
||||||
return result.media_info.to_dict()
|
|
||||||
else:
|
|
||||||
# 豆瓣
|
|
||||||
return MediaInfo(douban_info=doubaninfo).to_dict()
|
|
||||||
else:
|
|
||||||
return schemas.MediaInfo()
|
return schemas.MediaInfo()
|
||||||
|
# 识别
|
||||||
|
mediainfo = MediaChain().recognize_media(tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, 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,17 +2,22 @@ from typing import Union, Any, List
|
|||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
from starlette.responses import PlainTextResponse
|
from starlette.responses import PlainTextResponse
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.message import MessageChain
|
from app.chain.message import MessageChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
|
from app.db import get_db
|
||||||
|
from app.db.models import User
|
||||||
|
from app.db.models.message import Message
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.db.userauth import get_current_active_superuser
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
|
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||||
from app.schemas import NotificationSwitch
|
from app.schemas import NotificationSwitch
|
||||||
from app.schemas.types import SystemConfigKey, NotificationType
|
from app.schemas.types import SystemConfigKey, NotificationType, MessageChannel
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -36,13 +41,44 @@ async def user_message(background_tasks: BackgroundTasks, request: Request):
|
|||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="微信验证")
|
@router.post("/web", summary="接收WEB消息", response_model=schemas.Response)
|
||||||
|
def web_message(text: str, current_user: User = Depends(get_current_active_superuser)):
|
||||||
|
"""
|
||||||
|
WEB消息响应
|
||||||
|
"""
|
||||||
|
MessageChain().handle_message(
|
||||||
|
channel=MessageChannel.Web,
|
||||||
|
userid=current_user.name,
|
||||||
|
username=current_user.name,
|
||||||
|
text=text
|
||||||
|
)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/web", summary="获取WEB消息", response_model=List[dict])
|
||||||
|
def get_web_message(_: schemas.TokenPayload = Depends(verify_token),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
page: int = 1,
|
||||||
|
count: int = 20):
|
||||||
|
"""
|
||||||
|
获取WEB消息列表
|
||||||
|
"""
|
||||||
|
ret_messages = []
|
||||||
|
messages = Message.list_by_page(db, page=page, count=count)
|
||||||
|
for message in messages:
|
||||||
|
try:
|
||||||
|
ret_messages.append(message.to_dict())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取WEB消息列表失败: {str(e)}")
|
||||||
|
continue
|
||||||
|
return ret_messages
|
||||||
|
|
||||||
|
|
||||||
def wechat_verify(echostr: str, msg_signature: str,
|
def wechat_verify(echostr: str, msg_signature: str,
|
||||||
timestamp: Union[str, int], nonce: str) -> Any:
|
timestamp: Union[str, int], nonce: str) -> Any:
|
||||||
"""
|
"""
|
||||||
用户消息响应
|
微信验证响应
|
||||||
"""
|
"""
|
||||||
logger.info(f"收到微信验证请求: {echostr}")
|
|
||||||
try:
|
try:
|
||||||
wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN,
|
wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN,
|
||||||
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
|
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
|
||||||
@@ -60,6 +96,28 @@ def wechat_verify(echostr: str, msg_signature: str,
|
|||||||
return PlainTextResponse(sEchoStr)
|
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])
|
@router.get("/switchs", summary="查询通知消息渠道开关", response_model=List[NotificationSwitch])
|
||||||
def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -72,7 +130,7 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
for noti in NotificationType:
|
for noti in NotificationType:
|
||||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
||||||
telegram=True, slack=True,
|
telegram=True, slack=True,
|
||||||
synologychat=True))
|
synologychat=True, vocechat=True))
|
||||||
else:
|
else:
|
||||||
for switch in switchs:
|
for switch in switchs:
|
||||||
return_list.append(NotificationSwitch(**switch))
|
return_list.append(NotificationSwitch(**switch))
|
||||||
@@ -83,7 +141,7 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
def set_switchs(switchs: List[NotificationSwitch],
|
def set_switchs(switchs: List[NotificationSwitch],
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询通知消息渠道开关
|
设置通知消息渠道开关
|
||||||
"""
|
"""
|
||||||
switch_list = []
|
switch_list = []
|
||||||
for switch in switchs:
|
for switch in switchs:
|
||||||
|
|||||||
@@ -7,50 +7,79 @@ from app.core.plugin import PluginManager
|
|||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.helper.plugin import PluginHelper
|
from app.helper.plugin import PluginHelper
|
||||||
|
from app.scheduler import Scheduler
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
@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") -> List[schemas.Plugin]:
|
||||||
"""
|
"""
|
||||||
查询所有插件清单,包括本地插件和在线插件
|
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
||||||
"""
|
"""
|
||||||
plugins = []
|
|
||||||
# 本地插件
|
# 本地插件
|
||||||
local_plugins = PluginManager().get_local_plugins()
|
local_plugins = PluginManager().get_local_plugins()
|
||||||
|
# 已安装插件
|
||||||
|
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
|
||||||
|
# 未安装的本地插件
|
||||||
|
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
|
||||||
|
if state == "installed":
|
||||||
|
return installed_plugins
|
||||||
|
|
||||||
# 在线插件
|
# 在线插件
|
||||||
online_plugins = PluginManager().get_online_plugins()
|
online_plugins = PluginManager().get_online_plugins()
|
||||||
if not online_plugins:
|
if not online_plugins:
|
||||||
|
# 没有获取在线插件
|
||||||
|
if state == "market":
|
||||||
|
# 返回未安装的本地插件
|
||||||
|
return not_installed_plugins
|
||||||
return local_plugins
|
return local_plugins
|
||||||
|
|
||||||
|
# 插件市场插件清单
|
||||||
|
market_plugins = []
|
||||||
# 已安装插件IDS
|
# 已安装插件IDS
|
||||||
installed_ids = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
_installed_ids = [plugin.id for plugin in installed_plugins]
|
||||||
# 已经安装的本地
|
|
||||||
plugins.extend([plugin for plugin in local_plugins if plugin["id"] in installed_ids])
|
|
||||||
# 未安装的线上插件或者有更新的插件
|
# 未安装的线上插件或者有更新的插件
|
||||||
for plugin in online_plugins:
|
for plugin in online_plugins:
|
||||||
if plugin["id"] not in installed_ids:
|
if plugin.id not in _installed_ids:
|
||||||
plugins.append(plugin)
|
market_plugins.append(plugin)
|
||||||
elif plugin.get("has_update"):
|
elif plugin.has_update:
|
||||||
plugin["installed"] = False
|
market_plugins.append(plugin)
|
||||||
plugins.append(plugin)
|
# 未安装的本地插件,且不在线上插件中
|
||||||
return plugins
|
_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])
|
@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:
|
||||||
"""
|
"""
|
||||||
查询用户已安装插件清单
|
查询用户已安装插件清单
|
||||||
"""
|
"""
|
||||||
return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistic", summary="插件安装统计", response_model=dict)
|
||||||
|
def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
插件安装统计
|
||||||
|
"""
|
||||||
|
return PluginHelper().get_statistic()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
||||||
def install_plugin(plugin_id: str,
|
def install(plugin_id: str,
|
||||||
repo_url: str = "",
|
repo_url: str = "",
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
安装插件
|
安装插件
|
||||||
"""
|
"""
|
||||||
@@ -62,14 +91,16 @@ def install_plugin(plugin_id: str,
|
|||||||
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
|
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
|
||||||
if not state:
|
if not state:
|
||||||
# 安装失败
|
# 安装失败
|
||||||
return schemas.Response(success=False, msg=msg)
|
return schemas.Response(success=False, message=msg)
|
||||||
# 安装插件
|
# 安装插件
|
||||||
if plugin_id not in install_plugins:
|
if plugin_id not in install_plugins:
|
||||||
install_plugins.append(plugin_id)
|
install_plugins.append(plugin_id)
|
||||||
# 保存设置
|
# 保存设置
|
||||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||||
# 重载插件管理器
|
# 加载插件到内存
|
||||||
PluginManager().init_config()
|
PluginManager().reload_plugin(plugin_id)
|
||||||
|
# 注册插件服务
|
||||||
|
Scheduler().update_plugin_job(plugin_id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,11 +120,25 @@ def plugin_form(plugin_id: str,
|
|||||||
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
||||||
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
根据插件ID获取插件配置信息
|
根据插件ID获取插件数据页面
|
||||||
"""
|
"""
|
||||||
return PluginManager().get_plugin_page(plugin_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().init_plugin(plugin_id, {})
|
||||||
|
# 注册插件服务
|
||||||
|
Scheduler().update_plugin_job(plugin_id)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{plugin_id}", summary="获取插件配置")
|
@router.get("/{plugin_id}", summary="获取插件配置")
|
||||||
def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -106,12 +151,14 @@ def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token
|
|||||||
def set_plugin_config(plugin_id: str, conf: dict,
|
def set_plugin_config(plugin_id: str, conf: dict,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据插件ID获取插件配置信息
|
更新插件配置
|
||||||
"""
|
"""
|
||||||
# 保存配置
|
# 保存配置
|
||||||
PluginManager().save_plugin_config(plugin_id, conf)
|
PluginManager().save_plugin_config(plugin_id, conf)
|
||||||
# 重新生效插件
|
# 重新生效插件
|
||||||
PluginManager().reload_plugin(plugin_id, conf)
|
PluginManager().init_plugin(plugin_id, conf)
|
||||||
|
# 注册插件服务
|
||||||
|
Scheduler().update_plugin_job(plugin_id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -129,8 +176,10 @@ def uninstall_plugin(plugin_id: str,
|
|||||||
break
|
break
|
||||||
# 保存
|
# 保存
|
||||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||||
# 重载插件管理器
|
# 移除插件
|
||||||
PluginManager().init_config()
|
PluginManager().remove_plugin(plugin_id)
|
||||||
|
# 移除插件服务
|
||||||
|
Scheduler().remove_plugin_job(plugin_id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ from typing import List, Any
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.douban import DoubanChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
|
|
||||||
@@ -20,31 +21,68 @@ async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
return [torrent.to_dict() for torrent in torrents]
|
return [torrent.to_dict() for torrent in torrents]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=List[schemas.Context])
|
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=schemas.Response)
|
||||||
def search_by_tmdbid(mediaid: str,
|
def search_by_id(mediaid: str,
|
||||||
mtype: str = None,
|
mtype: str = None,
|
||||||
area: str = "title",
|
area: str = "title",
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
season: str = None,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/
|
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:
|
||||||
"""
|
"""
|
||||||
|
if mtype:
|
||||||
|
mtype = MediaType(mtype)
|
||||||
|
if season:
|
||||||
|
season = int(season)
|
||||||
if mediaid.startswith("tmdb:"):
|
if mediaid.startswith("tmdb:"):
|
||||||
tmdbid = int(mediaid.replace("tmdb:", ""))
|
tmdbid = int(mediaid.replace("tmdb:", ""))
|
||||||
if mtype:
|
if settings.RECOGNIZE_SOURCE == "douban":
|
||||||
mtype = MediaType(mtype)
|
# 通过TMDBID识别豆瓣ID
|
||||||
torrents = SearchChain().search_by_tmdbid(tmdbid=tmdbid, mtype=mtype, area=area)
|
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, season=season)
|
||||||
|
else:
|
||||||
|
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
|
||||||
|
else:
|
||||||
|
torrents = SearchChain().search_by_id(tmdbid=tmdbid, mtype=mtype, area=area, season=season)
|
||||||
elif mediaid.startswith("douban:"):
|
elif mediaid.startswith("douban:"):
|
||||||
doubanid = mediaid.replace("douban:", "")
|
doubanid = mediaid.replace("douban:", "")
|
||||||
# 识别豆瓣信息
|
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||||
context = DoubanChain().recognize_by_doubanid(doubanid)
|
# 通过豆瓣ID识别TMDBID
|
||||||
if not context or not context.media_info or not context.media_info.tmdb_id:
|
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
|
||||||
return []
|
if tmdbinfo:
|
||||||
torrents = SearchChain().search_by_tmdbid(tmdbid=context.media_info.tmdb_id,
|
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
|
||||||
mtype=context.media_info.type,
|
mtype=mtype, area=area, season=season)
|
||||||
area=area)
|
else:
|
||||||
|
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
|
||||||
|
else:
|
||||||
|
torrents = SearchChain().search_by_id(doubanid=doubanid, mtype=mtype, area=area, season=season)
|
||||||
|
elif mediaid.startswith("bangumi:"):
|
||||||
|
bangumiid = int(mediaid.replace("bangumi:", ""))
|
||||||
|
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||||
|
# 通过BangumiID识别TMDBID
|
||||||
|
tmdbinfo = MediaChain().get_tmdbinfo_by_bangumiid(bangumiid=bangumiid)
|
||||||
|
if tmdbinfo:
|
||||||
|
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
|
||||||
|
mtype=mtype, area=area, season=season)
|
||||||
|
else:
|
||||||
|
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
|
||||||
|
else:
|
||||||
|
# 通过BangumiID识别豆瓣ID
|
||||||
|
doubaninfo = MediaChain().get_doubaninfo_by_bangumiid(bangumiid=bangumiid)
|
||||||
|
if doubaninfo:
|
||||||
|
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
|
||||||
|
mtype=mtype, area=area, season=season)
|
||||||
|
else:
|
||||||
|
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
|
||||||
else:
|
else:
|
||||||
return []
|
return schemas.Response(success=False, message="未知的媒体ID")
|
||||||
return [torrent.to_dict() for torrent in torrents]
|
|
||||||
|
if not torrents:
|
||||||
|
return schemas.Response(success=False, message="未搜索到任何资源")
|
||||||
|
else:
|
||||||
|
return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/title", summary="模糊搜索资源", response_model=List[schemas.TorrentInfo])
|
@router.get("/title", summary="模糊搜索资源", response_model=List[schemas.TorrentInfo])
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.core.security import verify_token
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.models.site import Site
|
from app.db.models.site import Site
|
||||||
from app.db.models.siteicon import SiteIcon
|
from app.db.models.siteicon import SiteIcon
|
||||||
|
from app.db.models.sitestatistic import SiteStatistic
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.helper.sites import SitesHelper
|
from app.helper.sites import SitesHelper
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
@@ -42,18 +43,28 @@ def add_site(
|
|||||||
"""
|
"""
|
||||||
if not site_in.url:
|
if not site_in.url:
|
||||||
return schemas.Response(success=False, message="站点地址不能为空")
|
return schemas.Response(success=False, message="站点地址不能为空")
|
||||||
|
if SitesHelper().auth_level < 2:
|
||||||
|
return schemas.Response(success=False, message="用户未通过认证,无法使用站点功能!")
|
||||||
domain = StringUtils.get_url_domain(site_in.url)
|
domain = StringUtils.get_url_domain(site_in.url)
|
||||||
site_info = SitesHelper().get_indexer(domain)
|
site_info = SitesHelper().get_indexer(domain)
|
||||||
if not site_info:
|
if not site_info:
|
||||||
return schemas.Response(success=False, message="该站点不支持或用户未通过认证")
|
return schemas.Response(success=False, message="该站点不支持,请检查站点域名是否正确")
|
||||||
if Site.get_by_domain(db, domain):
|
if Site.get_by_domain(db, domain):
|
||||||
return schemas.Response(success=False, message=f"{domain} 站点己存在")
|
return schemas.Response(success=False, message=f"{domain} 站点己存在")
|
||||||
# 保存站点信息
|
# 保存站点信息
|
||||||
site_in.domain = domain
|
site_in.domain = domain
|
||||||
|
# 校正地址格式
|
||||||
|
_scheme, _netloc = StringUtils.get_url_netloc(site_in.url)
|
||||||
|
site_in.url = f"{_scheme}://{_netloc}/"
|
||||||
site_in.name = site_info.get("name")
|
site_in.name = site_info.get("name")
|
||||||
site_in.id = None
|
site_in.id = None
|
||||||
|
site_in.public = 1 if site_info.get("public") else 0
|
||||||
site = Site(**site_in.dict())
|
site = Site(**site_in.dict())
|
||||||
site.create(db)
|
site.create(db)
|
||||||
|
# 通知站点更新
|
||||||
|
EventManager().send_event(EventType.SiteUpdated, {
|
||||||
|
"domain": domain
|
||||||
|
})
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,7 +81,14 @@ def update_site(
|
|||||||
site = Site.get(db, site_in.id)
|
site = Site.get(db, site_in.id)
|
||||||
if not site:
|
if not site:
|
||||||
return schemas.Response(success=False, message="站点不存在")
|
return schemas.Response(success=False, message="站点不存在")
|
||||||
|
# 校正地址格式
|
||||||
|
_scheme, _netloc = StringUtils.get_url_netloc(site_in.url)
|
||||||
|
site_in.url = f"{_scheme}://{_netloc}/"
|
||||||
site.update(db, site_in.dict())
|
site.update(db, site_in.dict())
|
||||||
|
# 通知站点更新
|
||||||
|
EventManager().send_event(EventType.SiteUpdated, {
|
||||||
|
"domain": site_in.domain
|
||||||
|
})
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -103,8 +121,8 @@ def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
|
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
|
||||||
def cookie_cloud_sync(db: Session = Depends(get_db),
|
def reset(db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
清空所有站点数据并重新同步CookieCloud站点信息
|
清空所有站点数据并重新同步CookieCloud站点信息
|
||||||
"""
|
"""
|
||||||
@@ -116,7 +134,7 @@ def cookie_cloud_sync(db: Session = Depends(get_db),
|
|||||||
# 插件站点删除
|
# 插件站点删除
|
||||||
EventManager().send_event(EventType.SiteDeleted,
|
EventManager().send_event(EventType.SiteDeleted,
|
||||||
{
|
{
|
||||||
"site_id": None
|
"site_id": "*"
|
||||||
})
|
})
|
||||||
return schemas.Response(success=True, message="站点已重置!")
|
return schemas.Response(success=True, message="站点已重置!")
|
||||||
|
|
||||||
@@ -126,6 +144,7 @@ def update_cookie(
|
|||||||
site_id: int,
|
site_id: int,
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
|
code: str = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -141,7 +160,8 @@ def update_cookie(
|
|||||||
# 更新Cookie
|
# 更新Cookie
|
||||||
state, message = SiteChain().update_cookie(site_info=site_info,
|
state, message = SiteChain().update_cookie(site_info=site_info,
|
||||||
username=username,
|
username=username,
|
||||||
password=password)
|
password=password,
|
||||||
|
two_step_code=code)
|
||||||
return schemas.Response(success=state, message=message)
|
return schemas.Response(success=state, message=message)
|
||||||
|
|
||||||
|
|
||||||
@@ -221,6 +241,22 @@ def read_site_by_domain(
|
|||||||
return site
|
return site
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistic/{site_url}", summary="站点统计信息", response_model=schemas.SiteStatistic)
|
||||||
|
def read_site_by_domain(
|
||||||
|
site_url: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
通过域名获取站点统计信息
|
||||||
|
"""
|
||||||
|
domain = StringUtils.get_url_domain(site_url)
|
||||||
|
sitestatistic = SiteStatistic.get_by_domain(db, domain)
|
||||||
|
if sitestatistic:
|
||||||
|
return sitestatistic
|
||||||
|
return schemas.SiteStatistic(domain=domain)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
|
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
|
||||||
def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
|
def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -228,10 +264,11 @@ def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
|
|||||||
"""
|
"""
|
||||||
# 选中的rss站点
|
# 选中的rss站点
|
||||||
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
||||||
|
|
||||||
# 所有站点
|
# 所有站点
|
||||||
all_site = Site.list_order_by_pri(db)
|
all_site = Site.list_order_by_pri(db)
|
||||||
if not selected_sites or not all_site:
|
if not selected_sites:
|
||||||
return []
|
return all_site
|
||||||
|
|
||||||
# 选中的rss站点
|
# 选中的rss站点
|
||||||
rss_sites = [site for site in all_site if site and site.id in selected_sites]
|
rss_sites = [site for site in all_site if site and site.id in selected_sites]
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ from sqlalchemy.orm import Session
|
|||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.subscribe import SubscribeChain
|
from app.chain.subscribe import SubscribeChain
|
||||||
from app.core.config import settings
|
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 import get_db
|
||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
|
from app.db.models.subscribehistory import SubscribeHistory
|
||||||
from app.db.models.user import User
|
from app.db.models.user import User
|
||||||
from app.db.userauth import get_current_active_user
|
from app.db.userauth import get_current_active_user
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
@@ -27,7 +29,7 @@ def start_subscribe_add(title: str, year: str,
|
|||||||
mtype=mtype, tmdbid=tmdbid, season=season, username=username)
|
mtype=mtype, tmdbid=tmdbid, season=season, username=username)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="所有订阅", response_model=List[schemas.Subscribe])
|
@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe])
|
||||||
def read_subscribes(
|
def read_subscribes(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
@@ -41,6 +43,14 @@ def read_subscribes(
|
|||||||
return 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)
|
@router.post("/", summary="新增订阅", response_model=schemas.Response)
|
||||||
def create_subscribe(
|
def create_subscribe(
|
||||||
*,
|
*,
|
||||||
@@ -55,6 +65,11 @@ def create_subscribe(
|
|||||||
mtype = MediaType(subscribe_in.type)
|
mtype = MediaType(subscribe_in.type)
|
||||||
else:
|
else:
|
||||||
mtype = None
|
mtype = None
|
||||||
|
# 豆瓣标理
|
||||||
|
if subscribe_in.doubanid or subscribe_in.bangumiid:
|
||||||
|
meta = MetaInfo(subscribe_in.name)
|
||||||
|
subscribe_in.name = meta.name
|
||||||
|
subscribe_in.season = meta.begin_season
|
||||||
# 标题转换
|
# 标题转换
|
||||||
if subscribe_in.name:
|
if subscribe_in.name:
|
||||||
title = subscribe_in.name
|
title = subscribe_in.name
|
||||||
@@ -66,12 +81,15 @@ def create_subscribe(
|
|||||||
tmdbid=subscribe_in.tmdbid,
|
tmdbid=subscribe_in.tmdbid,
|
||||||
season=subscribe_in.season,
|
season=subscribe_in.season,
|
||||||
doubanid=subscribe_in.doubanid,
|
doubanid=subscribe_in.doubanid,
|
||||||
|
bangumiid=subscribe_in.bangumiid,
|
||||||
username=current_user.name,
|
username=current_user.name,
|
||||||
best_version=subscribe_in.best_version,
|
best_version=subscribe_in.best_version,
|
||||||
|
save_path=subscribe_in.save_path,
|
||||||
|
search_imdbid=subscribe_in.search_imdbid,
|
||||||
exist_ok=True)
|
exist_ok=True)
|
||||||
return schemas.Response(success=True if sid else False, message=message, data={
|
return schemas.Response(
|
||||||
"id": sid
|
success=bool(sid), message=message, data={"id": sid}
|
||||||
})
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/", summary="更新订阅", response_model=schemas.Response)
|
@router.put("/", summary="更新订阅", response_model=schemas.Response)
|
||||||
@@ -100,6 +118,9 @@ def update_subscribe(
|
|||||||
subscribe_dict["lack_episode"] = (subscribe.lack_episode
|
subscribe_dict["lack_episode"] = (subscribe.lack_episode
|
||||||
+ (subscribe_in.total_episode
|
+ (subscribe_in.total_episode
|
||||||
- (subscribe.total_episode or 0)))
|
- (subscribe.total_episode or 0)))
|
||||||
|
# 是否手动修改过总集数
|
||||||
|
if subscribe_in.total_episode != subscribe.total_episode:
|
||||||
|
subscribe_dict["manual_total_episode"] = 1
|
||||||
subscribe.update(db, subscribe_dict)
|
subscribe.update(db, subscribe_dict)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
@@ -108,23 +129,39 @@ def update_subscribe(
|
|||||||
def subscribe_mediaid(
|
def subscribe_mediaid(
|
||||||
mediaid: str,
|
mediaid: str,
|
||||||
season: int = None,
|
season: int = None,
|
||||||
|
title: str = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据TMDBID或豆瓣ID查询订阅 tmdb:/douban:
|
根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban:
|
||||||
"""
|
"""
|
||||||
|
result = None
|
||||||
|
title_check = False
|
||||||
if mediaid.startswith("tmdb:"):
|
if mediaid.startswith("tmdb:"):
|
||||||
tmdbid = mediaid[5:]
|
tmdbid = mediaid[5:]
|
||||||
if not tmdbid or not str(tmdbid).isdigit():
|
if not tmdbid or not str(tmdbid).isdigit():
|
||||||
return Subscribe()
|
return Subscribe()
|
||||||
result = Subscribe.exists(db, int(tmdbid), season)
|
result = Subscribe.exists(db, tmdbid=int(tmdbid), season=season)
|
||||||
elif mediaid.startswith("douban:"):
|
elif mediaid.startswith("douban:"):
|
||||||
doubanid = mediaid[7:]
|
doubanid = mediaid[7:]
|
||||||
if not doubanid:
|
if not doubanid:
|
||||||
return Subscribe()
|
return Subscribe()
|
||||||
result = Subscribe.get_by_doubanid(db, doubanid)
|
result = Subscribe.get_by_doubanid(db, doubanid)
|
||||||
else:
|
if not result and title:
|
||||||
result = None
|
title_check = True
|
||||||
|
elif mediaid.startswith("bangumi:"):
|
||||||
|
bangumiid = mediaid[8:]
|
||||||
|
if not bangumiid or not str(bangumiid).isdigit():
|
||||||
|
return Subscribe()
|
||||||
|
result = Subscribe.get_by_bangumiid(db, int(bangumiid))
|
||||||
|
if not result and title:
|
||||||
|
title_check = True
|
||||||
|
# 使用名称检查订阅
|
||||||
|
if title_check 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:
|
if result and result.sites:
|
||||||
result.sites = json.loads(result.sites)
|
result.sites = json.loads(result.sites)
|
||||||
|
|
||||||
@@ -161,9 +198,11 @@ def search_subscribes(
|
|||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
Scheduler().start,
|
Scheduler().start,
|
||||||
job_id="subscribe_search",
|
job_id="subscribe_search",
|
||||||
sid=None,
|
**{
|
||||||
state='R',
|
"sid": None,
|
||||||
manual=True
|
"state": 'R',
|
||||||
|
"manual": True
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
@@ -179,29 +218,15 @@ def search_subscribe(
|
|||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
Scheduler().start,
|
Scheduler().start,
|
||||||
job_id="subscribe_search",
|
job_id="subscribe_search",
|
||||||
sid=subscribe_id,
|
**{
|
||||||
state=None,
|
"sid": subscribe_id,
|
||||||
manual=True
|
"state": None,
|
||||||
|
"manual": True
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
|
|
||||||
def read_subscribe(
|
|
||||||
subscribe_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
根据订阅编号查询订阅信息
|
|
||||||
"""
|
|
||||||
if not subscribe_id:
|
|
||||||
return Subscribe()
|
|
||||||
subscribe = Subscribe.get(db, subscribe_id)
|
|
||||||
if subscribe and subscribe.sites:
|
|
||||||
subscribe.sites = json.loads(subscribe.sites)
|
|
||||||
return subscribe
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/media/{mediaid}", summary="删除订阅", response_model=schemas.Response)
|
@router.delete("/media/{mediaid}", summary="删除订阅", response_model=schemas.Response)
|
||||||
def delete_subscribe_by_mediaid(
|
def delete_subscribe_by_mediaid(
|
||||||
mediaid: str,
|
mediaid: str,
|
||||||
@@ -226,24 +251,11 @@ def delete_subscribe_by_mediaid(
|
|||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
|
|
||||||
def delete_subscribe(
|
|
||||||
subscribe_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
删除订阅信息
|
|
||||||
"""
|
|
||||||
Subscribe.delete(db, subscribe_id)
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response)
|
@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response)
|
||||||
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||||
authorization: str = Header(None)) -> Any:
|
authorization: str = Header(None)) -> Any:
|
||||||
"""
|
"""
|
||||||
Jellyseerr/Overseerr订阅
|
Jellyseerr/Overseerr网络勾子通知订阅
|
||||||
"""
|
"""
|
||||||
if not authorization or authorization != settings.API_TOKEN:
|
if not authorization or authorization != settings.API_TOKEN:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -290,3 +302,62 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
|||||||
username=user_name)
|
username=user_name)
|
||||||
|
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe])
|
||||||
|
def read_subscribe(
|
||||||
|
mtype: str,
|
||||||
|
page: int = 1,
|
||||||
|
count: int = 30,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询电影/电视剧订阅历史
|
||||||
|
"""
|
||||||
|
historys = SubscribeHistory.list_by_type(db, mtype=mtype, page=page, count=count)
|
||||||
|
for history in historys:
|
||||||
|
if history and history.sites:
|
||||||
|
history.sites = json.loads(history.sites)
|
||||||
|
return historys
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response)
|
||||||
|
def delete_subscribe(
|
||||||
|
history_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
删除订阅历史
|
||||||
|
"""
|
||||||
|
SubscribeHistory.delete(db, history_id)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
|
||||||
|
def read_subscribe(
|
||||||
|
subscribe_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
根据订阅编号查询订阅信息
|
||||||
|
"""
|
||||||
|
if not subscribe_id:
|
||||||
|
return Subscribe()
|
||||||
|
subscribe = Subscribe.get(db, subscribe_id)
|
||||||
|
if subscribe and subscribe.sites:
|
||||||
|
subscribe.sites = json.loads(subscribe.sites)
|
||||||
|
return subscribe
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
|
||||||
|
def delete_subscribe(
|
||||||
|
subscribe_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
删除订阅信息
|
||||||
|
"""
|
||||||
|
Subscribe.delete(db, subscribe_id)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Union
|
from typing import Union, Any
|
||||||
|
|
||||||
import tailer
|
import tailer
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from dotenv import set_key
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Response
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.module import ModuleManager
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.helper.message import MessageHelper
|
from app.helper.message import MessageHelper
|
||||||
@@ -24,13 +26,29 @@ from version import APP_VERSION
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/img/{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)
|
@router.get("/env", summary="查询系统环境变量", response_model=schemas.Response)
|
||||||
def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||||
"""
|
"""
|
||||||
查询系统环境变量,包括当前版本号
|
查询系统环境变量,包括当前版本号
|
||||||
"""
|
"""
|
||||||
info = settings.dict(
|
info = settings.dict(
|
||||||
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD", "API_TOKEN"}
|
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD"}
|
||||||
)
|
)
|
||||||
info.update({
|
info.update({
|
||||||
"VERSION": APP_VERSION,
|
"VERSION": APP_VERSION,
|
||||||
@@ -41,6 +59,27 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
|||||||
data=info)
|
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="实时进度")
|
@router.get("/progress/{process_type}", summary="实时进度")
|
||||||
def get_progress(process_type: str, token: str):
|
def get_progress(process_type: str, token: str):
|
||||||
"""
|
"""
|
||||||
@@ -69,23 +108,37 @@ def get_setting(key: str,
|
|||||||
"""
|
"""
|
||||||
查询系统设置
|
查询系统设置
|
||||||
"""
|
"""
|
||||||
|
if hasattr(settings, key):
|
||||||
|
value = getattr(settings, key)
|
||||||
|
else:
|
||||||
|
value = SystemConfigOper().get(key)
|
||||||
return schemas.Response(success=True, data={
|
return schemas.Response(success=True, data={
|
||||||
"value": SystemConfigOper().get(key)
|
"value": value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
@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)):
|
_: 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)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/message", summary="实时消息")
|
@router.get("/message", summary="实时消息")
|
||||||
def get_message(token: str):
|
def get_message(token: str, role: str = "sys"):
|
||||||
"""
|
"""
|
||||||
实时获取系统消息,返回格式为SSE
|
实时获取系统消息,返回格式为SSE
|
||||||
"""
|
"""
|
||||||
@@ -99,7 +152,7 @@ def get_message(token: str):
|
|||||||
|
|
||||||
def event_generator():
|
def event_generator():
|
||||||
while True:
|
while True:
|
||||||
detail = message.get()
|
detail = message.get(role)
|
||||||
yield 'data: %s\n\n' % (detail or '')
|
yield 'data: %s\n\n' % (detail or '')
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
@@ -107,9 +160,11 @@ def get_message(token: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/logging", summary="实时日志")
|
@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):
|
if not token or not verify_token(token):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -117,45 +172,31 @@ def get_logging(token: str):
|
|||||||
detail="认证失败!",
|
detail="认证失败!",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log_path = settings.LOG_PATH / logfile
|
||||||
|
|
||||||
def log_generator():
|
def log_generator():
|
||||||
log_path = settings.LOG_PATH / 'moviepilot.log'
|
|
||||||
# 读取文件末尾50行,不使用tailer模块
|
# 读取文件末尾50行,不使用tailer模块
|
||||||
with open(log_path, 'r', encoding='utf-8') as f:
|
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
|
yield 'data: %s\n\n' % line
|
||||||
while True:
|
while True:
|
||||||
for text in tailer.follow(open(log_path, 'r', encoding='utf-8')):
|
for t in tailer.follow(open(log_path, 'r', encoding='utf-8')):
|
||||||
yield 'data: %s\n\n' % (text or '')
|
yield 'data: %s\n\n' % (t or '')
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
# 根据length参数返回不同的响应
|
||||||
|
if length == -1:
|
||||||
|
# 返回全部日志作为文本响应
|
||||||
@router.get("/nettest", summary="测试网络连通性")
|
if not log_path.exists():
|
||||||
def nettest(url: str,
|
return Response(content="日志文件不存在!", media_type="text/plain")
|
||||||
proxy: bool,
|
with open(log_path, 'r', encoding='utf-8') as file:
|
||||||
_: schemas.TokenPayload = Depends(verify_token)):
|
text = file.read()
|
||||||
"""
|
# 倒序输出
|
||||||
测试网络连通性
|
text = '\n'.join(text.split('\n')[::-1])
|
||||||
"""
|
return Response(content=text, media_type="text/plain")
|
||||||
# 记录开始的毫秒数
|
|
||||||
start_time = datetime.now()
|
|
||||||
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
|
|
||||||
result = RequestUtils(proxies=settings.PROXY if proxy else None,
|
|
||||||
ua=settings.USER_AGENT).get_res(url)
|
|
||||||
# 计时结束的毫秒数
|
|
||||||
end_time = datetime.now()
|
|
||||||
# 计算相关秒数
|
|
||||||
if result and result.status_code == 200:
|
|
||||||
return schemas.Response(success=True, data={
|
|
||||||
"time": round((end_time - start_time).microseconds / 1000)
|
|
||||||
})
|
|
||||||
elif result:
|
|
||||||
return schemas.Response(success=False, message=f"错误码:{result.status_code}", data={
|
|
||||||
"time": round((end_time - start_time).microseconds / 1000)
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
return schemas.Response(success=False, message="网络连接失败!")
|
# 返回SSE流响应
|
||||||
|
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
|
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
|
||||||
@@ -163,7 +204,8 @@ def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
|||||||
"""
|
"""
|
||||||
查询Github所有Release版本
|
查询Github所有Release版本
|
||||||
"""
|
"""
|
||||||
version_res = RequestUtils().get_res(f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
|
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||||
|
f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
|
||||||
if version_res:
|
if version_res:
|
||||||
ver_json = version_res.json()
|
ver_json = version_res.json()
|
||||||
if ver_json:
|
if ver_json:
|
||||||
@@ -202,6 +244,53 @@ def ruletest(title: str,
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/nettest", summary="测试网络连通性")
|
||||||
|
def nettest(url: str,
|
||||||
|
proxy: bool,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)):
|
||||||
|
"""
|
||||||
|
测试网络连通性
|
||||||
|
"""
|
||||||
|
# 记录开始的毫秒数
|
||||||
|
start_time = datetime.now()
|
||||||
|
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
|
||||||
|
result = RequestUtils(proxies=settings.PROXY if proxy else None,
|
||||||
|
ua=settings.USER_AGENT).get_res(url)
|
||||||
|
# 计时结束的毫秒数
|
||||||
|
end_time = datetime.now()
|
||||||
|
# 计算相关秒数
|
||||||
|
if result and result.status_code == 200:
|
||||||
|
return schemas.Response(success=True, data={
|
||||||
|
"time": round((end_time - start_time).microseconds / 1000)
|
||||||
|
})
|
||||||
|
elif result:
|
||||||
|
return schemas.Response(success=False, message=f"错误码:{result.status_code}", data={
|
||||||
|
"time": round((end_time - start_time).microseconds / 1000)
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return schemas.Response(success=False, message="网络连接失败!")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
|
||||||
|
def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
||||||
|
"""
|
||||||
|
查询已加载的模块ID列表
|
||||||
|
"""
|
||||||
|
module_ids = [module.__name__ for module in ModuleManager().get_modules("test")]
|
||||||
|
return schemas.Response(success=True, data={
|
||||||
|
"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)
|
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
|
||||||
def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
|
def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
|
||||||
"""
|
"""
|
||||||
@@ -214,6 +303,16 @@ def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
|
|||||||
return schemas.Response(success=ret, message=msg)
|
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)
|
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
||||||
def execute_command(jobid: str,
|
def execute_command(jobid: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)):
|
_: schemas.TokenPayload = Depends(verify_token)):
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.transfer import TransferChain
|
from app.chain.transfer import TransferChain
|
||||||
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 import get_db
|
||||||
from app.db.models.transferhistory import TransferHistory
|
from app.db.models.transferhistory import TransferHistory
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
@@ -37,7 +37,7 @@ def manual_transfer(path: str = None,
|
|||||||
:param type_name: 媒体类型、电影/电视剧
|
:param type_name: 媒体类型、电影/电视剧
|
||||||
:param tmdbid: tmdbid
|
:param tmdbid: tmdbid
|
||||||
:param season: 剧集季号
|
:param season: 剧集季号
|
||||||
:param transfer_type: 转移类型,move/copy
|
:param transfer_type: 转移类型,move/copy 等
|
||||||
:param episode_format: 剧集识别格式
|
:param episode_format: 剧集识别格式
|
||||||
:param episode_detail: 剧集识别详细信息
|
:param episode_detail: 剧集识别详细信息
|
||||||
:param episode_part: 剧集识别分集信息
|
:param episode_part: 剧集识别分集信息
|
||||||
@@ -47,31 +47,34 @@ def manual_transfer(path: str = None,
|
|||||||
:param _: Token校验
|
:param _: Token校验
|
||||||
"""
|
"""
|
||||||
force = False
|
force = False
|
||||||
|
target = Path(target) if target else None
|
||||||
|
transfer = TransferChain()
|
||||||
if logid:
|
if logid:
|
||||||
# 查询历史记录
|
# 查询历史记录
|
||||||
history = TransferHistory.get(db, logid)
|
history: TransferHistory = TransferHistory.get(db, logid)
|
||||||
if not history:
|
if not history:
|
||||||
return schemas.Response(success=False, message=f"历史记录不存在,ID:{logid}")
|
return schemas.Response(success=False, message=f"历史记录不存在,ID:{logid}")
|
||||||
# 强制转移
|
# 强制转移
|
||||||
force = True
|
force = True
|
||||||
# 源路径
|
if history.status and ("move" in history.mode):
|
||||||
in_path = Path(history.src)
|
# 重新整理成功的转移,则使用成功的 dest 做 in_path
|
||||||
# 目的路径
|
in_path = Path(history.dest)
|
||||||
if history.dest and str(history.dest) != "None":
|
else:
|
||||||
# 删除旧的已整理文件
|
# 源路径
|
||||||
TransferChain().delete_files(Path(history.dest))
|
in_path = Path(history.src)
|
||||||
if not target:
|
# 目的路径
|
||||||
target = history.dest
|
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:
|
elif path:
|
||||||
in_path = Path(path)
|
in_path = Path(path)
|
||||||
else:
|
else:
|
||||||
return schemas.Response(success=False, message=f"缺少参数:path/logid")
|
return schemas.Response(success=False, message=f"缺少参数:path/logid")
|
||||||
|
|
||||||
if target and target != "None":
|
|
||||||
target = Path(target)
|
|
||||||
else:
|
|
||||||
target = None
|
|
||||||
|
|
||||||
# 类型
|
# 类型
|
||||||
mtype = MediaType(type_name) if type_name else None
|
mtype = MediaType(type_name) if type_name else None
|
||||||
# 自定义格式
|
# 自定义格式
|
||||||
@@ -84,7 +87,7 @@ def manual_transfer(path: str = None,
|
|||||||
offset=episode_offset,
|
offset=episode_offset,
|
||||||
)
|
)
|
||||||
# 开始转移
|
# 开始转移
|
||||||
state, errormsg = TransferChain().manual_transfer(
|
state, errormsg = transfer.manual_transfer(
|
||||||
in_path=in_path,
|
in_path=in_path,
|
||||||
target=target,
|
target=target,
|
||||||
tmdbid=tmdbid,
|
tmdbid=tmdbid,
|
||||||
@@ -102,3 +105,12 @@ def manual_transfer(path: str = None,
|
|||||||
return schemas.Response(success=False, message=errormsg)
|
return schemas.Response(success=False, message=errormsg)
|
||||||
# 成功
|
# 成功
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response)
|
||||||
|
def now(_: str = Depends(verify_uri_token)) -> Any:
|
||||||
|
"""
|
||||||
|
立即执行下载器文件整理 API_TOKEN认证(?token=xxx)
|
||||||
|
"""
|
||||||
|
TransferChain().process()
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import base64
|
import base64
|
||||||
|
import re
|
||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
@@ -9,14 +10,15 @@ from app.core.security import get_password_hash
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.models.user import User
|
from app.db.models.user import User
|
||||||
from app.db.userauth import get_current_active_superuser, get_current_active_user
|
from app.db.userauth import get_current_active_superuser, get_current_active_user
|
||||||
|
from app.utils.otp import OtpUtils
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="所有用户", response_model=List[schemas.User])
|
@router.get("/", summary="所有用户", response_model=List[schemas.User])
|
||||||
def read_users(
|
def read_users(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_active_superuser),
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
查询用户列表
|
查询用户列表
|
||||||
@@ -27,10 +29,10 @@ def read_users(
|
|||||||
|
|
||||||
@router.post("/", summary="新增用户", response_model=schemas.Response)
|
@router.post("/", summary="新增用户", response_model=schemas.Response)
|
||||||
def create_user(
|
def create_user(
|
||||||
*,
|
*,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user_in: schemas.UserCreate,
|
user_in: schemas.UserCreate,
|
||||||
current_user: User = Depends(get_current_active_superuser),
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
新增用户
|
新增用户
|
||||||
@@ -49,16 +51,21 @@ def create_user(
|
|||||||
|
|
||||||
@router.put("/", summary="更新用户", response_model=schemas.Response)
|
@router.put("/", summary="更新用户", response_model=schemas.Response)
|
||||||
def update_user(
|
def update_user(
|
||||||
*,
|
*,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user_in: schemas.UserCreate,
|
user_in: schemas.UserCreate,
|
||||||
_: User = Depends(get_current_active_superuser),
|
_: User = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
更新用户
|
更新用户
|
||||||
"""
|
"""
|
||||||
user_info = user_in.dict()
|
user_info = user_in.dict()
|
||||||
if user_info.get("password"):
|
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["hashed_password"] = get_password_hash(user_info["password"])
|
||||||
user_info.pop("password")
|
user_info.pop("password")
|
||||||
user = User.get_by_name(db, name=user_info["name"])
|
user = User.get_by_name(db, name=user_info["name"])
|
||||||
@@ -70,7 +77,7 @@ def update_user(
|
|||||||
|
|
||||||
@router.get("/current", summary="当前登录用户信息", response_model=schemas.User)
|
@router.get("/current", summary="当前登录用户信息", response_model=schemas.User)
|
||||||
def read_current_user(
|
def read_current_user(
|
||||||
current_user: User = Depends(get_current_active_user)
|
current_user: User = Depends(get_current_active_user)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
当前登录用户信息
|
当前登录用户信息
|
||||||
@@ -96,12 +103,51 @@ async def upload_avatar(user_id: int, db: Session = Depends(get_db),
|
|||||||
return schemas.Response(success=True, message=file.filename)
|
return schemas.Response(success=True, message=file.filename)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/otp/generate', summary='生成otp验证uri', response_model=schemas.Response)
|
||||||
|
def otp_generate(
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
) -> Any:
|
||||||
|
secret, uri = OtpUtils.generate_secret_key(current_user.name)
|
||||||
|
return schemas.Response(success=secret != "", data={'secret': secret, 'uri': uri})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/otp/judge', summary='判断otp验证是否通过', response_model=schemas.Response)
|
||||||
|
def otp_judge(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
) -> Any:
|
||||||
|
uri = data.get("uri")
|
||||||
|
otp_password = data.get("otpPassword")
|
||||||
|
if not OtpUtils.is_legal(uri, otp_password):
|
||||||
|
return schemas.Response(success=False, message="验证码错误")
|
||||||
|
current_user.update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(uri))
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/otp/disable', summary='关闭当前用户的otp验证', response_model=schemas.Response)
|
||||||
|
def otp_disable(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
) -> Any:
|
||||||
|
current_user.update_otp_by_name(db, current_user.name, False, "")
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/otp/{userid}', summary='判断当前用户是否开启otp验证', response_model=schemas.Response)
|
||||||
|
def otp_enable(userid: str, db: Session = Depends(get_db)) -> Any:
|
||||||
|
user: User = User.get_by_name(db, userid)
|
||||||
|
if not user:
|
||||||
|
return schemas.Response(success=False, message="用户不存在")
|
||||||
|
return schemas.Response(success=user.is_otp)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_name}", summary="删除用户", response_model=schemas.Response)
|
@router.delete("/{user_name}", summary="删除用户", response_model=schemas.Response)
|
||||||
def delete_user(
|
def delete_user(
|
||||||
*,
|
*,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user_name: str,
|
user_name: str,
|
||||||
current_user: User = Depends(get_current_active_superuser),
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
删除用户
|
删除用户
|
||||||
@@ -115,9 +161,9 @@ def delete_user(
|
|||||||
|
|
||||||
@router.get("/{user_id}", summary="用户详情", response_model=schemas.User)
|
@router.get("/{user_id}", summary="用户详情", response_model=schemas.User)
|
||||||
def read_user_by_id(
|
def read_user_by_id(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
查询用户详情
|
查询用户详情
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Request
|
from fastapi import APIRouter, BackgroundTasks, Request, Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.webhook import WebhookChain
|
from app.chain.webhook import WebhookChain
|
||||||
from app.core.config import settings
|
from app.core.security import verify_uri_token
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -18,13 +18,12 @@ def start_webhook_chain(body: Any, form: Any, args: Any):
|
|||||||
|
|
||||||
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
|
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||||
async def webhook_message(background_tasks: BackgroundTasks,
|
async def webhook_message(background_tasks: BackgroundTasks,
|
||||||
token: str, request: Request,
|
request: Request,
|
||||||
|
_: str = Depends(verify_uri_token)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Webhook响应
|
Webhook响应
|
||||||
"""
|
"""
|
||||||
if token != settings.API_TOKEN:
|
|
||||||
return schemas.Response(success=False, message="token认证不通过")
|
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
args = request.query_params
|
args = request.query_params
|
||||||
@@ -34,12 +33,10 @@ async def webhook_message(background_tasks: BackgroundTasks,
|
|||||||
|
|
||||||
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
|
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||||
async def webhook_message(background_tasks: BackgroundTasks,
|
async def webhook_message(background_tasks: BackgroundTasks,
|
||||||
token: str, request: Request) -> Any:
|
request: Request, _: str = Depends(verify_uri_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
Webhook响应
|
Webhook响应
|
||||||
"""
|
"""
|
||||||
if token != settings.API_TOKEN:
|
|
||||||
return schemas.Response(success=False, message="token认证不通过")
|
|
||||||
args = request.query_params
|
args = request.query_params
|
||||||
background_tasks.add_task(start_webhook_chain, None, None, args)
|
background_tasks.add_task(start_webhook_chain, None, None, args)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.chain.media import MediaChain
|
|||||||
from app.chain.subscribe import SubscribeChain
|
from app.chain.subscribe import SubscribeChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
|
from app.core.security import verify_uri_apikey
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
from app.schemas import RadarrMovie, SonarrSeries
|
from app.schemas import RadarrMovie, SonarrSeries
|
||||||
@@ -18,15 +19,10 @@ arr_router = APIRouter(tags=['servarr'])
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/system/status", summary="系统状态")
|
@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系统状态
|
模拟Radarr、Sonarr系统状态
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"appName": "MoviePilot",
|
"appName": "MoviePilot",
|
||||||
"instanceName": "moviepilot",
|
"instanceName": "moviepilot",
|
||||||
@@ -77,15 +73,10 @@ def arr_system_status(apikey: str) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/qualityProfile", summary="质量配置")
|
@arr_router.get("/qualityProfile", summary="质量配置")
|
||||||
def arr_qualityProfile(apikey: str) -> Any:
|
def arr_qualityProfile(_: str = Depends(verify_uri_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr质量配置
|
模拟Radarr、Sonarr质量配置
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -123,15 +114,10 @@ def arr_qualityProfile(apikey: str) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/rootfolder", summary="根目录")
|
@arr_router.get("/rootfolder", summary="根目录")
|
||||||
def arr_rootfolder(apikey: str) -> Any:
|
def arr_rootfolder(_: str = Depends(verify_uri_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr根目录
|
模拟Radarr、Sonarr根目录
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -144,15 +130,10 @@ def arr_rootfolder(apikey: str) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/tag", summary="标签")
|
@arr_router.get("/tag", summary="标签")
|
||||||
def arr_tag(apikey: str) -> Any:
|
def arr_tag(_: str = Depends(verify_uri_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr标签
|
模拟Radarr、Sonarr标签
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -162,15 +143,10 @@ def arr_tag(apikey: str) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/languageprofile", summary="语言")
|
@arr_router.get("/languageprofile", summary="语言")
|
||||||
def arr_languageprofile(apikey: str) -> Any:
|
def arr_languageprofile(_: str = Depends(verify_uri_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr语言
|
模拟Radarr、Sonarr语言
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
return [{
|
return [{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "默认",
|
"name": "默认",
|
||||||
@@ -193,7 +169,7 @@ def arr_languageprofile(apikey: str) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie])
|
@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电影
|
查询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 = []
|
result = []
|
||||||
subscribes = Subscribe.list(db)
|
subscribes = Subscribe.list(db)
|
||||||
@@ -289,16 +260,11 @@ def arr_movies(apikey: str, db: Session = Depends(get_db)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie])
|
@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}`
|
查询Rardar电影 term: `tmdb:${id}`
|
||||||
存在和不存在均不能返回错误
|
存在和不存在均不能返回错误
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
tmdbid = term.replace("tmdb:", "")
|
tmdbid = term.replace("tmdb:", "")
|
||||||
# 查询媒体信息
|
# 查询媒体信息
|
||||||
mediainfo = MediaChain().recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid))
|
mediainfo = MediaChain().recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid))
|
||||||
@@ -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)
|
@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电影订阅
|
查询Rardar电影订阅
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
subscribe = Subscribe.get(db, mid)
|
subscribe = Subscribe.get(db, mid)
|
||||||
if subscribe:
|
if subscribe:
|
||||||
return RadarrMovie(
|
return RadarrMovie(
|
||||||
@@ -371,18 +332,13 @@ def arr_movie(apikey: str, mid: int, db: Session = Depends(get_db)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.post("/movie", summary="新增电影订阅")
|
@arr_router.post("/movie", summary="新增电影订阅")
|
||||||
def arr_add_movie(apikey: str,
|
def arr_add_movie(movie: RadarrMovie,
|
||||||
movie: RadarrMovie,
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(verify_uri_apikey)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
新增Rardar电影订阅
|
新增Rardar电影订阅
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
# 检查订阅是否已存在
|
# 检查订阅是否已存在
|
||||||
subscribe = Subscribe.get_by_tmdbid(db, movie.tmdbId)
|
subscribe = Subscribe.get_by_tmdbid(db, movie.tmdbId)
|
||||||
if subscribe:
|
if subscribe:
|
||||||
@@ -394,7 +350,7 @@ def arr_add_movie(apikey: str,
|
|||||||
year=movie.year,
|
year=movie.year,
|
||||||
mtype=MediaType.MOVIE,
|
mtype=MediaType.MOVIE,
|
||||||
tmdbid=movie.tmdbId,
|
tmdbid=movie.tmdbId,
|
||||||
userid="Seerr")
|
username="Seerr")
|
||||||
if sid:
|
if sid:
|
||||||
return {
|
return {
|
||||||
"id": sid
|
"id": sid
|
||||||
@@ -407,15 +363,10 @@ def arr_add_movie(apikey: str,
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response)
|
@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电影订阅
|
删除Rardar电影订阅
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
subscribe = Subscribe.get(db, mid)
|
subscribe = Subscribe.get(db, mid)
|
||||||
if subscribe:
|
if subscribe:
|
||||||
subscribe.delete(db, mid)
|
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])
|
@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剧集
|
查询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 = []
|
result = []
|
||||||
subscribes = Subscribe.list(db)
|
subscribes = Subscribe.list(db)
|
||||||
@@ -569,16 +515,10 @@ def arr_series(apikey: str, db: Session = Depends(get_db)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/series/lookup", summary="查询剧集")
|
@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
|
查询Sonarr剧集 term: `tvdb:${id}` title
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取TVDBID
|
# 获取TVDBID
|
||||||
if not term.startswith("tvdb:"):
|
if not term.startswith("tvdb:"):
|
||||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
|
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
|
||||||
@@ -664,15 +604,10 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/series/{tid}", summary="剧集详情")
|
@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剧集
|
查询Sonarr剧集
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
subscribe = Subscribe.get(db, tid)
|
subscribe = Subscribe.get(db, tid)
|
||||||
if subscribe:
|
if subscribe:
|
||||||
return SonarrSeries(
|
return SonarrSeries(
|
||||||
@@ -703,16 +638,12 @@ def arr_serie(apikey: str, tid: int, db: Session = Depends(get_db)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.post("/series", summary="新增剧集订阅")
|
@arr_router.post("/series", summary="新增剧集订阅")
|
||||||
def arr_add_series(apikey: str, tv: schemas.SonarrSeries,
|
def arr_add_series(tv: schemas.SonarrSeries,
|
||||||
db: Session = Depends(get_db)) -> Any:
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(verify_uri_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
新增Sonarr剧集订阅
|
新增Sonarr剧集订阅
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
# 检查订阅是否存在
|
# 检查订阅是否存在
|
||||||
left_seasons = []
|
left_seasons = []
|
||||||
for season in tv.seasons:
|
for season in tv.seasons:
|
||||||
@@ -737,7 +668,7 @@ def arr_add_series(apikey: str, tv: schemas.SonarrSeries,
|
|||||||
season=season.get("seasonNumber"),
|
season=season.get("seasonNumber"),
|
||||||
tmdbid=tv.tmdbId,
|
tmdbid=tv.tmdbId,
|
||||||
mtype=MediaType.TV,
|
mtype=MediaType.TV,
|
||||||
userid="Seerr")
|
username="Seerr")
|
||||||
|
|
||||||
if sid:
|
if sid:
|
||||||
return {
|
return {
|
||||||
@@ -751,15 +682,10 @@ def arr_add_series(apikey: str, tv: schemas.SonarrSeries,
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
|
@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剧集订阅
|
删除Sonarr剧集订阅
|
||||||
"""
|
"""
|
||||||
if not apikey or apikey != settings.API_TOKEN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
subscribe = Subscribe.get(db, tid)
|
subscribe = Subscribe.get(db, tid)
|
||||||
if subscribe:
|
if subscribe:
|
||||||
subscribe.delete(db, tid)
|
subscribe.delete(db, tid)
|
||||||
|
|||||||
137
app/api/servcookie.py
Normal file
137
app/api/servcookie.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
from hashlib import md5
|
||||||
|
from typing import Annotated, Callable
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.log import logger
|
||||||
|
from app.utils.common import decrypt
|
||||||
|
|
||||||
|
|
||||||
|
class GzipRequest(Request):
|
||||||
|
|
||||||
|
async def body(self) -> bytes:
|
||||||
|
if not hasattr(self, "_body"):
|
||||||
|
body = await super().body()
|
||||||
|
if "gzip" in self.headers.getlist("Content-Encoding"):
|
||||||
|
body = gzip.decompress(body)
|
||||||
|
self._body = body
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class GzipRoute(APIRoute):
|
||||||
|
|
||||||
|
def get_route_handler(self) -> Callable:
|
||||||
|
original_route_handler = super().get_route_handler()
|
||||||
|
|
||||||
|
async def custom_route_handler(request: Request) -> Response:
|
||||||
|
request = GzipRequest(request.scope, request.receive)
|
||||||
|
return await original_route_handler(request)
|
||||||
|
|
||||||
|
return custom_route_handler
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_server_enabled():
|
||||||
|
"""
|
||||||
|
校验CookieCloud服务路由是否打开
|
||||||
|
"""
|
||||||
|
if not settings.COOKIECLOUD_ENABLE_LOCAL:
|
||||||
|
raise HTTPException(status_code=400, detail="本地CookieCloud服务器未启用")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
cookie_router = APIRouter(route_class=GzipRoute,
|
||||||
|
tags=['servcookie'],
|
||||||
|
dependencies=[Depends(verify_server_enabled)])
|
||||||
|
|
||||||
|
|
||||||
|
@cookie_router.get("/", response_class=PlainTextResponse)
|
||||||
|
def get_root():
|
||||||
|
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
|
||||||
|
|
||||||
|
|
||||||
|
@cookie_router.post("/", response_class=PlainTextResponse)
|
||||||
|
def post_root():
|
||||||
|
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
|
||||||
|
|
||||||
|
|
||||||
|
@cookie_router.post("/update")
|
||||||
|
async def update_cookie(req: schemas.CookieData):
|
||||||
|
"""
|
||||||
|
上传Cookie数据
|
||||||
|
"""
|
||||||
|
file_path = settings.COOKIE_PATH / f"{req.uuid}.json"
|
||||||
|
content = json.dumps({"encrypted": req.encrypted})
|
||||||
|
with open(file_path, encoding="utf-8", mode="w") as file:
|
||||||
|
file.write(content)
|
||||||
|
with open(file_path, encoding="utf-8", mode="r") as file:
|
||||||
|
read_content = file.read()
|
||||||
|
if read_content == content:
|
||||||
|
return {"action": "done"}
|
||||||
|
else:
|
||||||
|
return {"action": "error"}
|
||||||
|
|
||||||
|
|
||||||
|
def load_encrypt_data(uuid: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
加载本地加密原始数据
|
||||||
|
"""
|
||||||
|
file_path = settings.COOKIE_PATH / f"{uuid}.json"
|
||||||
|
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
# 读取文件
|
||||||
|
with open(file_path, encoding="utf-8", mode="r") as file:
|
||||||
|
read_content = file.read()
|
||||||
|
data = json.loads(read_content.encode("utf-8"))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_decrypted_cookie_data(uuid: str, password: str,
|
||||||
|
encrypted: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
加载本地加密数据并解密为Cookie
|
||||||
|
"""
|
||||||
|
key_md5 = md5()
|
||||||
|
key_md5.update((uuid + '-' + password).encode('utf-8'))
|
||||||
|
aes_key = (key_md5.hexdigest()[:16]).encode('utf-8')
|
||||||
|
|
||||||
|
if encrypted:
|
||||||
|
try:
|
||||||
|
decrypted_data = decrypt(encrypted, aes_key).decode('utf-8')
|
||||||
|
decrypted_data = json.loads(decrypted_data)
|
||||||
|
if 'cookie_data' in decrypted_data:
|
||||||
|
return decrypted_data
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解密Cookie数据失败:{str(e)}")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@cookie_router.get("/get/{uuid}")
|
||||||
|
async def get_cookie(
|
||||||
|
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")]):
|
||||||
|
"""
|
||||||
|
GET 下载加密数据
|
||||||
|
"""
|
||||||
|
return load_encrypt_data(uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@cookie_router.post("/get/{uuid}")
|
||||||
|
async def post_cookie(
|
||||||
|
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
|
||||||
|
request: schemas.CookiePassword):
|
||||||
|
"""
|
||||||
|
POST 下载加密数据
|
||||||
|
"""
|
||||||
|
data = load_encrypt_data(uuid)
|
||||||
|
return get_decrypted_cookie_data(uuid, request.password, data["encrypted"])
|
||||||
@@ -15,6 +15,8 @@ from app.core.context import MediaInfo, TorrentInfo
|
|||||||
from app.core.event import EventManager
|
from app.core.event import EventManager
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.module import ModuleManager
|
from app.core.module import ModuleManager
|
||||||
|
from app.db.message_oper import MessageOper
|
||||||
|
from app.helper.message import MessageHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||||
WebhookEventInfo, TmdbEpisode
|
WebhookEventInfo, TmdbEpisode
|
||||||
@@ -33,6 +35,8 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
self.modulemanager = ModuleManager()
|
self.modulemanager = ModuleManager()
|
||||||
self.eventmanager = EventManager()
|
self.eventmanager = EventManager()
|
||||||
|
self.messageoper = MessageOper()
|
||||||
|
self.messagehelper = MessageHelper()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_cache(filename: str) -> Any:
|
def load_cache(filename: str) -> Any:
|
||||||
@@ -107,28 +111,42 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
# 中止继续执行
|
# 中止继续执行
|
||||||
break
|
break
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"运行模块 {method} 出错:{module.__class__.__name__} - {str(err)}\n{traceback.print_exc()}")
|
logger.error(
|
||||||
|
f"运行模块 {method} 出错:{module.__class__.__name__} - {str(err)}\n{traceback.format_exc()}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def recognize_media(self, meta: MetaBase = None,
|
def recognize_media(self, meta: MetaBase = None,
|
||||||
mtype: MediaType = None,
|
mtype: MediaType = None,
|
||||||
tmdbid: int = None) -> Optional[MediaInfo]:
|
tmdbid: int = None,
|
||||||
|
doubanid: str = None,
|
||||||
|
bangumiid: int = None,
|
||||||
|
cache: bool = True) -> Optional[MediaInfo]:
|
||||||
"""
|
"""
|
||||||
识别媒体信息
|
识别媒体信息
|
||||||
:param meta: 识别的元数据
|
:param meta: 识别的元数据
|
||||||
:param mtype: 识别的媒体类型,与tmdbid配套
|
:param mtype: 识别的媒体类型,与tmdbid配套
|
||||||
:param tmdbid: tmdbid
|
:param tmdbid: tmdbid
|
||||||
|
:param doubanid: 豆瓣ID
|
||||||
|
:param bangumiid: BangumiID
|
||||||
|
:param cache: 是否使用缓存
|
||||||
:return: 识别的媒体信息,包括剧集信息
|
:return: 识别的媒体信息,包括剧集信息
|
||||||
"""
|
"""
|
||||||
|
# 识别用名中含指定信息情形
|
||||||
|
if not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
|
||||||
|
mtype = meta.type
|
||||||
if not tmdbid and hasattr(meta, "tmdbid"):
|
if not tmdbid and hasattr(meta, "tmdbid"):
|
||||||
# 识别用名中含指定信息情形
|
|
||||||
tmdbid = meta.tmdbid
|
tmdbid = meta.tmdbid
|
||||||
if not mtype and meta.type in [MediaType.TV, MediaType.MOVIE]:
|
if not doubanid and hasattr(meta, "doubanid"):
|
||||||
mtype = meta.type
|
doubanid = meta.doubanid
|
||||||
return self.run_module("recognize_media", meta=meta, mtype=mtype, tmdbid=tmdbid)
|
# 有tmdbid时不使用其它ID
|
||||||
|
if tmdbid:
|
||||||
|
doubanid = None
|
||||||
|
bangumiid = None
|
||||||
|
return self.run_module("recognize_media", meta=meta, mtype=mtype,
|
||||||
|
tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, cache=cache)
|
||||||
|
|
||||||
def match_doubaninfo(self, name: str, imdbid: str = None,
|
def match_doubaninfo(self, name: str, imdbid: str = None,
|
||||||
mtype: str = None, year: str = None, season: int = None) -> Optional[dict]:
|
mtype: MediaType = None, year: str = None, season: int = None) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
搜索和匹配豆瓣信息
|
搜索和匹配豆瓣信息
|
||||||
:param name: 标题
|
:param name: 标题
|
||||||
@@ -140,6 +158,18 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
return self.run_module("match_doubaninfo", name=name, imdbid=imdbid,
|
return self.run_module("match_doubaninfo", name=name, imdbid=imdbid,
|
||||||
mtype=mtype, year=year, season=season)
|
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]:
|
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||||
"""
|
"""
|
||||||
补充抓取媒体信息图片
|
补充抓取媒体信息图片
|
||||||
@@ -164,13 +194,14 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
image_prefix=image_prefix, image_type=image_type,
|
image_prefix=image_prefix, image_type=image_type,
|
||||||
season=season, episode=episode)
|
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 doubanid: 豆瓣ID
|
||||||
|
:param mtype: 媒体类型
|
||||||
:return: 豆瓣信息
|
: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]:
|
def tvdb_info(self, tvdbid: int) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -189,6 +220,14 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype)
|
return self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype)
|
||||||
|
|
||||||
|
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
获取Bangumi信息
|
||||||
|
:param bangumiid: int
|
||||||
|
:return: Bangumi信息
|
||||||
|
"""
|
||||||
|
return self.run_module("bangumi_info", bangumiid=bangumiid)
|
||||||
|
|
||||||
def message_parser(self, body: Any, form: Any,
|
def message_parser(self, body: Any, form: Any,
|
||||||
args: Any) -> Optional[CommingMessage]:
|
args: Any) -> Optional[CommingMessage]:
|
||||||
"""
|
"""
|
||||||
@@ -261,7 +300,8 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
mediainfo=mediainfo)
|
mediainfo=mediainfo)
|
||||||
|
|
||||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
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]]:
|
) -> Optional[Tuple[Optional[str], str]]:
|
||||||
"""
|
"""
|
||||||
根据种子文件,选择并添加下载任务
|
根据种子文件,选择并添加下载任务
|
||||||
@@ -270,10 +310,12 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:param cookie: cookie
|
:param cookie: cookie
|
||||||
:param episodes: 需要下载的集数
|
:param episodes: 需要下载的集数
|
||||||
:param category: 种子分类
|
:param category: 种子分类
|
||||||
|
:param downloader: 下载器
|
||||||
:return: 种子Hash,错误信息
|
:return: 种子Hash,错误信息
|
||||||
"""
|
"""
|
||||||
return self.run_module("download", content=content, download_dir=download_dir,
|
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:
|
def download_added(self, context: Context, download_dir: Path, torrent_path: Path = None) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -287,14 +329,17 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
download_dir=download_dir)
|
download_dir=download_dir)
|
||||||
|
|
||||||
def list_torrents(self, status: TorrentStatus = None,
|
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 status: 种子状态
|
||||||
:param hashs: 种子Hash
|
:param hashs: 种子Hash
|
||||||
|
:param downloader: 下载器
|
||||||
:return: 下载器中符合状态的种子列表
|
: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,
|
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
||||||
transfer_type: str, target: Path = None,
|
transfer_type: str, target: Path = None,
|
||||||
@@ -310,48 +355,56 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:return: {path, target_path, message}
|
:return: {path, target_path, message}
|
||||||
"""
|
"""
|
||||||
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
|
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)
|
||||||
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 hashs: 种子Hash
|
||||||
:param path: 源目录
|
: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 hashs: 种子Hash
|
||||||
|
:param delete_file: 是否删除文件
|
||||||
|
:param downloader: 下载器
|
||||||
:return: bool
|
: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 hashs: 种子Hash
|
||||||
|
:param downloader: 下载器
|
||||||
:return: bool
|
: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 hashs: 种子Hash
|
||||||
|
:param downloader: 下载器
|
||||||
:return: bool
|
: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 tid: 种子Hash
|
||||||
|
:param downloader: 下载器
|
||||||
:return: 种子文件
|
: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]:
|
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||||
"""
|
"""
|
||||||
@@ -368,19 +421,27 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:param message: 消息体
|
:param message: 消息体
|
||||||
:return: 成功或失败
|
:return: 成功或失败
|
||||||
"""
|
"""
|
||||||
|
logger.info(f"发送消息:channel={message.channel},"
|
||||||
|
f"title={message.title}, "
|
||||||
|
f"text={message.text},"
|
||||||
|
f"userid={message.userid}")
|
||||||
# 发送事件
|
# 发送事件
|
||||||
self.eventmanager.send_event(etype=EventType.NoticeMessage,
|
self.eventmanager.send_event(etype=EventType.NoticeMessage,
|
||||||
data={
|
data={
|
||||||
"channel": message.channel,
|
"channel": message.channel,
|
||||||
|
"type": message.mtype,
|
||||||
"title": message.title,
|
"title": message.title,
|
||||||
"text": message.text,
|
"text": message.text,
|
||||||
"image": message.image,
|
"image": message.image,
|
||||||
"userid": message.userid,
|
"userid": message.userid,
|
||||||
})
|
})
|
||||||
logger.info(f"发送消息:channel={message.channel},"
|
# 保存消息
|
||||||
f"title={message.title}, "
|
self.messagehelper.put(message, role="user")
|
||||||
f"text={message.text},"
|
self.messageoper.add(channel=message.channel, mtype=message.mtype,
|
||||||
f"userid={message.userid}")
|
title=message.title, text=message.text,
|
||||||
|
image=message.image, link=message.link,
|
||||||
|
userid=message.userid, action=1)
|
||||||
|
# 发送
|
||||||
self.run_module("post_message", message=message)
|
self.run_module("post_message", message=message)
|
||||||
|
|
||||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
|
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
|
||||||
@@ -390,6 +451,13 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:param medias: 媒体列表
|
:param medias: 媒体列表
|
||||||
:return: 成功或失败
|
:return: 成功或失败
|
||||||
"""
|
"""
|
||||||
|
note_list = [media.to_dict() for media in medias]
|
||||||
|
self.messagehelper.put(message, role="user", note=note_list)
|
||||||
|
self.messageoper.add(channel=message.channel, mtype=message.mtype,
|
||||||
|
title=message.title, text=message.text,
|
||||||
|
image=message.image, link=message.link,
|
||||||
|
userid=message.userid, action=1,
|
||||||
|
note=note_list)
|
||||||
return self.run_module("post_medias_message", message=message, medias=medias)
|
return self.run_module("post_medias_message", message=message, medias=medias)
|
||||||
|
|
||||||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
|
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
|
||||||
@@ -399,17 +467,29 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:param torrents: 种子列表
|
:param torrents: 种子列表
|
||||||
:return: 成功或失败
|
:return: 成功或失败
|
||||||
"""
|
"""
|
||||||
|
note_list = [torrent.torrent_info.to_dict() for torrent in torrents]
|
||||||
|
self.messagehelper.put(message, role="user", note=note_list)
|
||||||
|
self.messageoper.add(channel=message.channel, mtype=message.mtype,
|
||||||
|
title=message.title, text=message.text,
|
||||||
|
image=message.image, link=message.link,
|
||||||
|
userid=message.userid, action=1,
|
||||||
|
note=note_list)
|
||||||
return self.run_module("post_torrents_message", message=message, torrents=torrents)
|
return self.run_module("post_torrents_message", message=message, torrents=torrents)
|
||||||
|
|
||||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str) -> None:
|
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
|
||||||
|
metainfo: MetaBase = None, force_nfo: bool = False, force_img: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
刮削元数据
|
刮削元数据
|
||||||
:param path: 媒体文件路径
|
:param path: 媒体文件路径
|
||||||
:param mediainfo: 识别的媒体信息
|
:param mediainfo: 识别的媒体信息
|
||||||
|
:param metainfo: 源文件的识别元数据
|
||||||
:param transfer_type: 转移模式
|
:param transfer_type: 转移模式
|
||||||
|
:param force_nfo: 强制刮削nfo
|
||||||
|
:param force_img: 强制刮削图片
|
||||||
:return: 成功或失败
|
:return: 成功或失败
|
||||||
"""
|
"""
|
||||||
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo, transfer_type=transfer_type)
|
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo, metainfo=metainfo,
|
||||||
|
transfer_type=transfer_type, force_nfo=force_nfo, force_img=force_img)
|
||||||
|
|
||||||
def register_commands(self, commands: Dict[str, dict]) -> None:
|
def register_commands(self, commands: Dict[str, dict]) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
42
app/chain/bangumi.py
Normal file
42
app/chain/bangumi.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from app.chain import ChainBase
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
|
|
||||||
|
class BangumiChain(ChainBase, metaclass=Singleton):
|
||||||
|
"""
|
||||||
|
Bangumi处理链,单例运行
|
||||||
|
"""
|
||||||
|
|
||||||
|
def calendar(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||||
|
"""
|
||||||
|
获取Bangumi每日放送
|
||||||
|
:param page: 页码
|
||||||
|
:param count: 每页数量
|
||||||
|
"""
|
||||||
|
return self.run_module("bangumi_calendar", page=page, count=count)
|
||||||
|
|
||||||
|
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
获取Bangumi信息
|
||||||
|
:param bangumiid: BangumiID
|
||||||
|
:return: Bangumi信息
|
||||||
|
"""
|
||||||
|
return self.run_module("bangumi_info", bangumiid=bangumiid)
|
||||||
|
|
||||||
|
def bangumi_credits(self, bangumiid: int, page: int = 1, count: int = 20) -> List[dict]:
|
||||||
|
"""
|
||||||
|
根据BangumiID查询电影演职员表
|
||||||
|
:param bangumiid: BangumiID
|
||||||
|
:param page: 页码
|
||||||
|
:param count: 数量
|
||||||
|
"""
|
||||||
|
return self.run_module("bangumi_credits", bangumiid=bangumiid, page=page, count=count)
|
||||||
|
|
||||||
|
def bangumi_recommend(self, bangumiid: int) -> List[dict]:
|
||||||
|
"""
|
||||||
|
根据BangumiID查询推荐电影
|
||||||
|
:param bangumiid: BangumiID
|
||||||
|
"""
|
||||||
|
return self.run_module("bangumi_recommend", bangumiid=bangumiid)
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import base64
|
|
||||||
from typing import Tuple, Optional
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from lxml import etree
|
|
||||||
|
|
||||||
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.utils.http import RequestUtils
|
|
||||||
from app.utils.site import SiteUtils
|
|
||||||
|
|
||||||
|
|
||||||
class CookieCloudChain(ChainBase):
|
|
||||||
"""
|
|
||||||
CookieCloud处理链
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.siteoper = SiteOper()
|
|
||||||
self.siteiconoper = SiteIconOper()
|
|
||||||
self.siteshelper = SitesHelper()
|
|
||||||
self.rsshelper = RssHelper()
|
|
||||||
self.sitechain = SiteChain()
|
|
||||||
self.message = MessageHelper()
|
|
||||||
self.cookiecloud = CookieCloudHelper(
|
|
||||||
server=settings.COOKIECLOUD_HOST,
|
|
||||||
key=settings.COOKIECLOUD_KEY,
|
|
||||||
password=settings.COOKIECLOUD_PASSWORD
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -15,7 +15,7 @@ class DashboardChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("media_statistic")
|
return self.run_module("media_statistic")
|
||||||
|
|
||||||
def downloader_info(self) -> schemas.DownloaderInfo:
|
def downloader_info(self) -> Optional[List[schemas.DownloaderInfo]]:
|
||||||
"""
|
"""
|
||||||
下载器信息
|
下载器信息
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.context import Context
|
from app.core.config import settings
|
||||||
from app.core.context import MediaInfo
|
|
||||||
from app.core.metainfo import MetaInfo
|
|
||||||
from app.log import logger
|
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
@@ -14,53 +11,7 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
|||||||
豆瓣处理链,单例运行
|
豆瓣处理链,单例运行
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def recognize_by_doubanid(self, doubanid: str) -> Optional[Context]:
|
def movie_top250(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||||
"""
|
|
||||||
根据豆瓣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]:
|
|
||||||
"""
|
|
||||||
根据豆瓣信息识别媒体信息
|
|
||||||
"""
|
|
||||||
# 优先使用原标题匹配
|
|
||||||
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
|
|
||||||
# 使用原标题识别媒体信息
|
|
||||||
mediainfo = self.recognize_media(meta=meta, mtype=meta.type)
|
|
||||||
if not mediainfo:
|
|
||||||
if season_meta and season_meta.name != meta.name:
|
|
||||||
# 使用主标题识别媒体信息
|
|
||||||
mediainfo = self.recognize_media(meta=season_meta, mtype=season_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]:
|
|
||||||
"""
|
"""
|
||||||
获取豆瓣电影TOP250
|
获取豆瓣电影TOP250
|
||||||
:param page: 页码
|
:param page: 页码
|
||||||
@@ -68,19 +19,19 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("movie_top250", page=page, count=count)
|
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)
|
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)
|
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]]:
|
||||||
"""
|
"""
|
||||||
获取本周全球剧集榜
|
获取本周全球剧集榜
|
||||||
"""
|
"""
|
||||||
@@ -100,8 +51,54 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
|||||||
return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags,
|
return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags,
|
||||||
page=page, count=count)
|
page=page, count=count)
|
||||||
|
|
||||||
def tv_animation(self, page: int = 1, count: int = 30) -> List[dict]:
|
def tv_animation(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||||
"""
|
"""
|
||||||
获取动画剧集
|
获取动画剧集
|
||||||
"""
|
"""
|
||||||
return self.run_module("tv_animation", page=page, count=count)
|
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)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import List, Optional, Tuple, Set, Dict, Union
|
|||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import MediaInfo, TorrentInfo, Context
|
from app.core.context import MediaInfo, TorrentInfo, Context
|
||||||
|
from app.core.event import eventmanager, Event
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||||
@@ -33,14 +34,19 @@ class DownloadChain(ChainBase):
|
|||||||
self.mediaserver = MediaServerOper()
|
self.mediaserver = MediaServerOper()
|
||||||
|
|
||||||
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
|
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
|
||||||
channel: MessageChannel = None,
|
channel: MessageChannel = None, userid: str = None, username: str = None):
|
||||||
userid: str = None):
|
|
||||||
"""
|
"""
|
||||||
发送添加下载的消息
|
发送添加下载的消息
|
||||||
|
:param meta: 元数据
|
||||||
|
:param mediainfo: 媒体信息
|
||||||
|
:param torrent: 种子信息
|
||||||
|
:param channel: 通知渠道
|
||||||
|
:param userid: 用户ID,指定时精确发送对应用户
|
||||||
|
:param username: 通知显示的下载用户信息
|
||||||
"""
|
"""
|
||||||
msg_text = ""
|
msg_text = ""
|
||||||
if userid:
|
if username:
|
||||||
msg_text = f"用户:{userid}"
|
msg_text = f"用户:{username}"
|
||||||
if torrent.site_name:
|
if torrent.site_name:
|
||||||
msg_text = f"{msg_text}\n站点:{torrent.site_name}"
|
msg_text = f"{msg_text}\n站点:{torrent.site_name}"
|
||||||
if meta.resource_term:
|
if meta.resource_term:
|
||||||
@@ -55,6 +61,8 @@ class DownloadChain(ChainBase):
|
|||||||
msg_text = f"{msg_text}\n种子:{torrent.title}"
|
msg_text = f"{msg_text}\n种子:{torrent.title}"
|
||||||
if torrent.pubdate:
|
if torrent.pubdate:
|
||||||
msg_text = f"{msg_text}\n发布时间:{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:
|
if torrent.seeders:
|
||||||
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
|
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
|
||||||
if torrent.uploadvolumefactor and torrent.downloadvolumefactor:
|
if torrent.uploadvolumefactor and torrent.downloadvolumefactor:
|
||||||
@@ -70,6 +78,7 @@ class DownloadChain(ChainBase):
|
|||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
mtype=NotificationType.Download,
|
mtype=NotificationType.Download,
|
||||||
|
userid=userid,
|
||||||
title=f"{mediainfo.title_year} "
|
title=f"{mediainfo.title_year} "
|
||||||
f"{meta.season_episode} 开始下载",
|
f"{meta.season_episode} 开始下载",
|
||||||
text=msg_text,
|
text=msg_text,
|
||||||
@@ -100,17 +109,27 @@ class DownloadChain(ChainBase):
|
|||||||
# 解码参数
|
# 解码参数
|
||||||
req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8')
|
req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8')
|
||||||
req_params: Dict[str, dict] = json.loads(req_str)
|
req_params: Dict[str, dict] = json.loads(req_str)
|
||||||
|
# 是否使用cookie
|
||||||
|
if not req_params.get('cookie'):
|
||||||
|
cookie = None
|
||||||
|
# 请求头
|
||||||
|
if req_params.get('header'):
|
||||||
|
headers = req_params.get('header')
|
||||||
|
else:
|
||||||
|
headers = None
|
||||||
if req_params.get('method') == 'get':
|
if req_params.get('method') == 'get':
|
||||||
# GET请求
|
# GET请求
|
||||||
res = RequestUtils(
|
res = RequestUtils(
|
||||||
ua=ua,
|
ua=ua,
|
||||||
cookies=cookie
|
cookies=cookie,
|
||||||
|
headers=headers
|
||||||
).get_res(url, params=req_params.get('params'))
|
).get_res(url, params=req_params.get('params'))
|
||||||
else:
|
else:
|
||||||
# POST请求
|
# POST请求
|
||||||
res = RequestUtils(
|
res = RequestUtils(
|
||||||
ua=ua,
|
ua=ua,
|
||||||
cookies=cookie
|
cookies=cookie,
|
||||||
|
headers=headers
|
||||||
).post_res(url, params=req_params.get('params'))
|
).post_res(url, params=req_params.get('params'))
|
||||||
if not res:
|
if not res:
|
||||||
return None
|
return None
|
||||||
@@ -131,12 +150,15 @@ class DownloadChain(ChainBase):
|
|||||||
return None, "", []
|
return None, "", []
|
||||||
if torrent.enclosure.startswith("magnet:"):
|
if torrent.enclosure.startswith("magnet:"):
|
||||||
return torrent.enclosure, "", []
|
return torrent.enclosure, "", []
|
||||||
|
# Cookie
|
||||||
|
site_cookie = torrent.site_cookie
|
||||||
if torrent.enclosure.startswith("["):
|
if torrent.enclosure.startswith("["):
|
||||||
# 需要解码获取下载地址
|
# 需要解码获取下载地址
|
||||||
torrent_url = __get_redict_url(url=torrent.enclosure,
|
torrent_url = __get_redict_url(url=torrent.enclosure,
|
||||||
ua=torrent.site_ua,
|
ua=torrent.site_ua,
|
||||||
cookie=torrent.site_cookie)
|
cookie=site_cookie)
|
||||||
|
# 涉及解析地址的不使用Cookie下载种子,否则MT会出错
|
||||||
|
site_cookie = None
|
||||||
else:
|
else:
|
||||||
torrent_url = torrent.enclosure
|
torrent_url = torrent.enclosure
|
||||||
if not torrent_url:
|
if not torrent_url:
|
||||||
@@ -145,7 +167,7 @@ class DownloadChain(ChainBase):
|
|||||||
# 下载种子文件
|
# 下载种子文件
|
||||||
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
|
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
|
||||||
url=torrent_url,
|
url=torrent_url,
|
||||||
cookie=torrent.site_cookie,
|
cookie=site_cookie,
|
||||||
ua=torrent.site_ua,
|
ua=torrent.site_ua,
|
||||||
proxy=torrent.site_proxy)
|
proxy=torrent.site_proxy)
|
||||||
|
|
||||||
@@ -192,7 +214,7 @@ class DownloadChain(ChainBase):
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
userid=userid)
|
userid=userid)
|
||||||
if not content:
|
if not content:
|
||||||
return
|
return None
|
||||||
else:
|
else:
|
||||||
content = torrent_file
|
content = torrent_file
|
||||||
# 获取种子文件的文件夹名和文件清单
|
# 获取种子文件的文件夹名和文件清单
|
||||||
@@ -209,7 +231,7 @@ class DownloadChain(ChainBase):
|
|||||||
if _media.genre_ids \
|
if _media.genre_ids \
|
||||||
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||||
# 动漫
|
# 动漫
|
||||||
download_dir = settings.SAVE_ANIME_PATH
|
download_dir = settings.SAVE_ANIME_PATH / _media.category
|
||||||
else:
|
else:
|
||||||
# 电视剧
|
# 电视剧
|
||||||
download_dir = settings.SAVE_TV_PATH / _media.category
|
download_dir = settings.SAVE_TV_PATH / _media.category
|
||||||
@@ -283,9 +305,13 @@ class DownloadChain(ChainBase):
|
|||||||
if not file_meta.begin_episode \
|
if not file_meta.begin_episode \
|
||||||
or file_meta.begin_episode not in episodes:
|
or file_meta.begin_episode not in episodes:
|
||||||
continue
|
continue
|
||||||
|
# 只处理视频格式
|
||||||
|
if not Path(file).suffix \
|
||||||
|
or Path(file).suffix not in settings.RMT_MEDIAEXT:
|
||||||
|
continue
|
||||||
files_to_add.append({
|
files_to_add.append({
|
||||||
"download_hash": _hash,
|
"download_hash": _hash,
|
||||||
"downloader": settings.DOWNLOADER,
|
"downloader": settings.DEFAULT_DOWNLOADER,
|
||||||
"fullpath": str(download_dir / _folder_name / file),
|
"fullpath": str(download_dir / _folder_name / file),
|
||||||
"savepath": str(download_dir / _folder_name),
|
"savepath": str(download_dir / _folder_name),
|
||||||
"filepath": file,
|
"filepath": file,
|
||||||
@@ -294,19 +320,21 @@ class DownloadChain(ChainBase):
|
|||||||
if files_to_add:
|
if files_to_add:
|
||||||
self.downloadhis.add_files(files_to_add)
|
self.downloadhis.add_files(files_to_add)
|
||||||
|
|
||||||
# 发送消息
|
# 发送消息(群发,不带channel和userid)
|
||||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, channel=channel, userid=userid)
|
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, username=username)
|
||||||
# 下载成功后处理
|
# 下载成功后处理
|
||||||
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
||||||
# 广播事件
|
# 广播事件
|
||||||
self.eventmanager.send_event(EventType.DownloadAdded, {
|
self.eventmanager.send_event(EventType.DownloadAdded, {
|
||||||
"hash": _hash,
|
"hash": _hash,
|
||||||
"context": context
|
"context": context,
|
||||||
|
"username": username
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# 下载失败
|
# 下载失败
|
||||||
logger.error(f"{_media.title_year} 添加下载任务失败:"
|
logger.error(f"{_media.title_year} 添加下载任务失败:"
|
||||||
f"{_torrent.title} - {_torrent.enclosure},{error_msg}")
|
f"{_torrent.title} - {_torrent.enclosure},{error_msg}")
|
||||||
|
# 只发送给对应渠道和用户
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
mtype=NotificationType.Manual,
|
mtype=NotificationType.Manual,
|
||||||
@@ -321,11 +349,12 @@ class DownloadChain(ChainBase):
|
|||||||
|
|
||||||
def batch_download(self,
|
def batch_download(self,
|
||||||
contexts: List[Context],
|
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,
|
save_path: str = None,
|
||||||
channel: MessageChannel = None,
|
channel: MessageChannel = None,
|
||||||
userid: str = None,
|
userid: str = None,
|
||||||
username: str = None) -> Tuple[List[Context], Dict[int, Dict[int, NotExistMediaInfo]]]:
|
username: str = None
|
||||||
|
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||||
"""
|
"""
|
||||||
根据缺失数据,自动种子列表中组合择优下载
|
根据缺失数据,自动种子列表中组合择优下载
|
||||||
:param contexts: 资源上下文列表
|
:param contexts: 资源上下文列表
|
||||||
@@ -334,34 +363,35 @@ class DownloadChain(ChainBase):
|
|||||||
:param channel: 通知渠道
|
:param channel: 通知渠道
|
||||||
:param userid: 用户ID
|
:param userid: 用户ID
|
||||||
:param username: 调用下载的用户名/插件名
|
:param username: 调用下载的用户名/插件名
|
||||||
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id] = {season: NotExistMediaInfo}
|
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}
|
||||||
"""
|
"""
|
||||||
# 已下载的项目
|
# 已下载的项目
|
||||||
downloaded_list: List[Context] = []
|
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季数,返回剩余季数
|
更新need_tvs季数,返回剩余季数
|
||||||
:param _tmdbid: TMDBID
|
:param _mid: TMDBID
|
||||||
:param _need: 需要下载的季数
|
:param _need: 需要下载的季数
|
||||||
:param _current: 已经下载的季数
|
:param _current: 已经下载的季数
|
||||||
"""
|
"""
|
||||||
# 剩余季数
|
# 剩余季数
|
||||||
need = list(set(_need).difference(set(_current)))
|
need = list(set(_need).difference(set(_current)))
|
||||||
# 清除已下载的季信息
|
# 清除已下载的季信息
|
||||||
seas = copy.deepcopy(no_exists.get(_tmdbid))
|
seas = copy.deepcopy(no_exists.get(_mid))
|
||||||
for _sea in list(seas):
|
if seas:
|
||||||
if _sea not in need:
|
for _sea in list(seas):
|
||||||
no_exists[_tmdbid].pop(_sea)
|
if _sea not in need:
|
||||||
if not no_exists.get(_tmdbid) and no_exists.get(_tmdbid) is not None:
|
no_exists[_mid].pop(_sea)
|
||||||
no_exists.pop(_tmdbid)
|
if not no_exists.get(_mid) and no_exists.get(_mid) is not None:
|
||||||
break
|
no_exists.pop(_mid)
|
||||||
|
break
|
||||||
return need
|
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集数,返回剩余集数
|
更新need_tvs集数,返回剩余集数
|
||||||
:param _tmdbid: TMDBID
|
:param _mid: TMDBID
|
||||||
:param _sea: 季数
|
:param _sea: 季数
|
||||||
:param _need: 需要下载的集数
|
:param _need: 需要下载的集数
|
||||||
:param _current: 已经下载的集数
|
:param _current: 已经下载的集数
|
||||||
@@ -369,26 +399,26 @@ class DownloadChain(ChainBase):
|
|||||||
# 剩余集数
|
# 剩余集数
|
||||||
need = list(set(_need).difference(set(_current)))
|
need = list(set(_need).difference(set(_current)))
|
||||||
if need:
|
if need:
|
||||||
not_exist = no_exists[_tmdbid][_sea]
|
not_exist = no_exists[_mid][_sea]
|
||||||
no_exists[_tmdbid][_sea] = NotExistMediaInfo(
|
no_exists[_mid][_sea] = NotExistMediaInfo(
|
||||||
season=not_exist.season,
|
season=not_exist.season,
|
||||||
episodes=need,
|
episodes=need,
|
||||||
total_episode=not_exist.total_episode,
|
total_episode=not_exist.total_episode,
|
||||||
start_episode=not_exist.start_episode
|
start_episode=not_exist.start_episode
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
no_exists[_tmdbid].pop(_sea)
|
no_exists[_mid].pop(_sea)
|
||||||
if not no_exists.get(_tmdbid) and no_exists.get(_tmdbid) is not None:
|
if not no_exists.get(_mid) and no_exists.get(_mid) is not None:
|
||||||
no_exists.pop(_tmdbid)
|
no_exists.pop(_mid)
|
||||||
return need
|
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
|
return 9999
|
||||||
no_exist = no_exists.get(tmdbid)
|
no_exist = no_exists.get(_mid)
|
||||||
if not no_exist.get(season):
|
if not no_exist.get(season):
|
||||||
return 9999
|
return 9999
|
||||||
return no_exist[season].total_episode
|
return no_exist[season].total_episode
|
||||||
@@ -399,8 +429,8 @@ class DownloadChain(ChainBase):
|
|||||||
# 如果是电影,直接下载
|
# 如果是电影,直接下载
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
if context.media_info.type == MediaType.MOVIE:
|
if context.media_info.type == MediaType.MOVIE:
|
||||||
if self.download_single(context, save_path=save_path,
|
if self.download_single(context, save_path=save_path, channel=channel,
|
||||||
channel=channel, userid=userid, username=username):
|
userid=userid, username=username):
|
||||||
# 下载成功
|
# 下载成功
|
||||||
downloaded_list.append(context)
|
downloaded_list.append(context)
|
||||||
|
|
||||||
@@ -408,17 +438,17 @@ class DownloadChain(ChainBase):
|
|||||||
if no_exists:
|
if no_exists:
|
||||||
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
|
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
|
||||||
need_seasons: Dict[int, list] = {}
|
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():
|
for tv in need_tv.values():
|
||||||
if not tv:
|
if not tv:
|
||||||
continue
|
continue
|
||||||
# 季列表为空的,代表全季缺失
|
# 季列表为空的,代表全季缺失
|
||||||
if not tv.episodes:
|
if not tv.episodes:
|
||||||
if not need_seasons.get(need_tmdbid):
|
if not need_seasons.get(need_mid):
|
||||||
need_seasons[need_tmdbid] = []
|
need_seasons[need_mid] = []
|
||||||
need_seasons[need_tmdbid].append(tv.season or 1)
|
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:
|
for context in contexts:
|
||||||
# 媒体信息
|
# 媒体信息
|
||||||
@@ -436,7 +466,7 @@ class DownloadChain(ChainBase):
|
|||||||
if meta.episode_list:
|
if meta.episode_list:
|
||||||
continue
|
continue
|
||||||
# 匹配TMDBID
|
# 匹配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 set(torrent_season).issubset(set(need_season)):
|
||||||
if len(torrent_season) == 1:
|
if len(torrent_season) == 1:
|
||||||
@@ -456,7 +486,7 @@ class DownloadChain(ChainBase):
|
|||||||
end_ep = max(torrent_episodes)
|
end_ep = max(torrent_episodes)
|
||||||
meta.set_episodes(begin=begin_ep, end=end_ep)
|
meta.set_episodes(begin=begin_ep, end=end_ep)
|
||||||
# 需要总集数
|
# 需要总集数
|
||||||
need_total = __get_season_episodes(need_tmdbid, torrent_season[0])
|
need_total = __get_season_episodes(need_mid, torrent_season[0])
|
||||||
if len(torrent_episodes) < need_total:
|
if len(torrent_episodes) < need_total:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{meta.org_string} 解析文件集数发现不是完整合集")
|
f"{meta.org_string} 解析文件集数发现不是完整合集")
|
||||||
@@ -473,26 +503,30 @@ class DownloadChain(ChainBase):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 下载
|
# 下载
|
||||||
download_id = self.download_single(context, save_path=save_path,
|
download_id = self.download_single(context,
|
||||||
channel=channel, userid=userid, username=username)
|
save_path=save_path, channel=channel,
|
||||||
|
userid=userid, username=username)
|
||||||
|
|
||||||
if download_id:
|
if download_id:
|
||||||
# 下载成功
|
# 下载成功
|
||||||
downloaded_list.append(context)
|
downloaded_list.append(context)
|
||||||
# 更新仍需季集
|
# 更新仍需季集
|
||||||
need_season = __update_seasons(_tmdbid=need_tmdbid,
|
need_season = __update_seasons(_mid=need_mid,
|
||||||
_need=need_season,
|
_need=need_season,
|
||||||
_current=torrent_season)
|
_current=torrent_season)
|
||||||
|
if not need_season:
|
||||||
|
# 全部下载完成
|
||||||
|
break
|
||||||
# 电视剧季内的集匹配
|
# 电视剧季内的集匹配
|
||||||
if no_exists:
|
if no_exists:
|
||||||
# TMDBID列表
|
# TMDBID列表
|
||||||
need_tv_list = list(no_exists)
|
need_tv_list = list(no_exists)
|
||||||
for need_tmdbid in need_tv_list:
|
for need_mid in need_tv_list:
|
||||||
# dict[season, [NotExistMediaInfo]]
|
# dict[season, [NotExistMediaInfo]]
|
||||||
need_tv = no_exists.get(need_tmdbid)
|
need_tv = no_exists.get(need_mid)
|
||||||
if not need_tv:
|
if not need_tv:
|
||||||
continue
|
continue
|
||||||
need_tv_copy = copy.deepcopy(no_exists.get(need_tmdbid))
|
need_tv_copy = copy.deepcopy(no_exists.get(need_mid))
|
||||||
# 循环每一季
|
# 循环每一季
|
||||||
for sea, tv in need_tv_copy.items():
|
for sea, tv in need_tv_copy.items():
|
||||||
# 当前需要季
|
# 当前需要季
|
||||||
@@ -505,7 +539,7 @@ class DownloadChain(ChainBase):
|
|||||||
start_episode = tv.start_episode or 1
|
start_episode = tv.start_episode or 1
|
||||||
# 缺失整季的转化为缺失集进行比较
|
# 缺失整季的转化为缺失集进行比较
|
||||||
if not need_episodes:
|
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:
|
for context in contexts:
|
||||||
# 媒体信息
|
# 媒体信息
|
||||||
@@ -516,7 +550,7 @@ class DownloadChain(ChainBase):
|
|||||||
if media.type != MediaType.TV:
|
if media.type != MediaType.TV:
|
||||||
continue
|
continue
|
||||||
# 匹配TMDB
|
# 匹配TMDB
|
||||||
if media.tmdb_id == need_tmdbid:
|
if media.tmdb_id == need_mid or media.douban_id == need_mid:
|
||||||
# 不重复添加
|
# 不重复添加
|
||||||
if context in downloaded_list:
|
if context in downloaded_list:
|
||||||
continue
|
continue
|
||||||
@@ -533,13 +567,14 @@ class DownloadChain(ChainBase):
|
|||||||
# 为需要集的子集则下载
|
# 为需要集的子集则下载
|
||||||
if torrent_episodes.issubset(set(need_episodes)):
|
if torrent_episodes.issubset(set(need_episodes)):
|
||||||
# 下载
|
# 下载
|
||||||
download_id = self.download_single(context, save_path=save_path,
|
download_id = self.download_single(context,
|
||||||
channel=channel, userid=userid, username=username)
|
save_path=save_path, channel=channel,
|
||||||
|
userid=userid, username=username)
|
||||||
if download_id:
|
if download_id:
|
||||||
# 下载成功
|
# 下载成功
|
||||||
downloaded_list.append(context)
|
downloaded_list.append(context)
|
||||||
# 更新仍需集数
|
# 更新仍需集数
|
||||||
need_episodes = __update_episodes(_tmdbid=need_tmdbid,
|
need_episodes = __update_episodes(_mid=need_mid,
|
||||||
_need=need_episodes,
|
_need=need_episodes,
|
||||||
_sea=need_season,
|
_sea=need_season,
|
||||||
_current=torrent_episodes)
|
_current=torrent_episodes)
|
||||||
@@ -548,9 +583,9 @@ class DownloadChain(ChainBase):
|
|||||||
if no_exists:
|
if no_exists:
|
||||||
# TMDBID列表
|
# TMDBID列表
|
||||||
no_exists_list = list(no_exists)
|
no_exists_list = list(no_exists)
|
||||||
for need_tmdbid in no_exists_list:
|
for need_mid in no_exists_list:
|
||||||
# dict[season, [NotExistMediaInfo]]
|
# dict[season, [NotExistMediaInfo]]
|
||||||
need_tv = no_exists.get(need_tmdbid)
|
need_tv = no_exists.get(need_mid)
|
||||||
if not need_tv:
|
if not need_tv:
|
||||||
continue
|
continue
|
||||||
# 需要季列表
|
# 需要季列表
|
||||||
@@ -584,7 +619,7 @@ class DownloadChain(ChainBase):
|
|||||||
if not need_episodes:
|
if not need_episodes:
|
||||||
break
|
break
|
||||||
# 选中一个单季整季的或单季包括需要的所有集的
|
# 选中一个单季整季的或单季包括需要的所有集的
|
||||||
if media.tmdb_id == need_tmdbid \
|
if (media.tmdb_id == need_mid or media.douban_id == need_mid) \
|
||||||
and (not meta.episode_list
|
and (not meta.episode_list
|
||||||
or set(meta.episode_list).intersection(set(need_episodes))) \
|
or set(meta.episode_list).intersection(set(need_episodes))) \
|
||||||
and len(meta.season_list) == 1 \
|
and len(meta.season_list) == 1 \
|
||||||
@@ -624,7 +659,7 @@ class DownloadChain(ChainBase):
|
|||||||
end_ep = max(torrent_episodes)
|
end_ep = max(torrent_episodes)
|
||||||
meta.set_episodes(begin=begin_ep, end=end_ep)
|
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,
|
_need=need_episodes,
|
||||||
_sea=need_season,
|
_sea=need_season,
|
||||||
_current=selected_episodes)
|
_current=selected_episodes)
|
||||||
@@ -636,7 +671,7 @@ class DownloadChain(ChainBase):
|
|||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo,
|
||||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||||
totals: Dict[int, int] = None
|
totals: Dict[int, int] = None
|
||||||
) -> Tuple[bool, Dict[int, Dict[int, NotExistMediaInfo]]]:
|
) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||||
"""
|
"""
|
||||||
检查媒体库,查询是否存在,对于剧集同时返回不存在的季集信息
|
检查媒体库,查询是否存在,对于剧集同时返回不存在的季集信息
|
||||||
:param meta: 元数据
|
:param meta: 元数据
|
||||||
@@ -656,8 +691,9 @@ class DownloadChain(ChainBase):
|
|||||||
"start_episode": int
|
"start_episode": int
|
||||||
]}
|
]}
|
||||||
"""
|
"""
|
||||||
if not no_exists.get(mediainfo.tmdb_id):
|
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||||
no_exists[mediainfo.tmdb_id] = {
|
if not no_exists.get(mediakey):
|
||||||
|
no_exists[mediakey] = {
|
||||||
_season: NotExistMediaInfo(
|
_season: NotExistMediaInfo(
|
||||||
season=_season,
|
season=_season,
|
||||||
episodes=_episodes,
|
episodes=_episodes,
|
||||||
@@ -666,7 +702,7 @@ class DownloadChain(ChainBase):
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
no_exists[mediainfo.tmdb_id][_season] = NotExistMediaInfo(
|
no_exists[mediakey][_season] = NotExistMediaInfo(
|
||||||
season=_season,
|
season=_season,
|
||||||
episodes=_episodes,
|
episodes=_episodes,
|
||||||
total_episode=_total,
|
total_episode=_total,
|
||||||
@@ -682,6 +718,7 @@ class DownloadChain(ChainBase):
|
|||||||
if mediainfo.type == MediaType.MOVIE:
|
if mediainfo.type == MediaType.MOVIE:
|
||||||
# 电影
|
# 电影
|
||||||
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||||
|
title=mediainfo.title,
|
||||||
tmdbid=mediainfo.tmdb_id)
|
tmdbid=mediainfo.tmdb_id)
|
||||||
exists_movies: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid)
|
exists_movies: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid)
|
||||||
if exists_movies:
|
if exists_movies:
|
||||||
@@ -692,7 +729,8 @@ class DownloadChain(ChainBase):
|
|||||||
if not mediainfo.seasons:
|
if not mediainfo.seasons:
|
||||||
# 补充媒体信息
|
# 补充媒体信息
|
||||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||||
tmdbid=mediainfo.tmdb_id)
|
tmdbid=mediainfo.tmdb_id,
|
||||||
|
doubanid=mediainfo.douban_id)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
logger.error(f"媒体信息识别失败!")
|
logger.error(f"媒体信息识别失败!")
|
||||||
return False, {}
|
return False, {}
|
||||||
@@ -701,6 +739,7 @@ class DownloadChain(ChainBase):
|
|||||||
return False, {}
|
return False, {}
|
||||||
# 电视剧
|
# 电视剧
|
||||||
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||||
|
title=mediainfo.title,
|
||||||
tmdbid=mediainfo.tmdb_id,
|
tmdbid=mediainfo.tmdb_id,
|
||||||
season=mediainfo.season)
|
season=mediainfo.season)
|
||||||
# 媒体库已存在的剧集
|
# 媒体库已存在的剧集
|
||||||
@@ -711,7 +750,7 @@ class DownloadChain(ChainBase):
|
|||||||
if not episodes:
|
if not episodes:
|
||||||
continue
|
continue
|
||||||
# 全季不存在
|
# 全季不存在
|
||||||
if meta.season_list \
|
if meta.sea \
|
||||||
and season not in meta.season_list:
|
and season not in meta.season_list:
|
||||||
continue
|
continue
|
||||||
# 总集数
|
# 总集数
|
||||||
@@ -722,7 +761,7 @@ class DownloadChain(ChainBase):
|
|||||||
else:
|
else:
|
||||||
# 存在一些,检查每季缺失的季集
|
# 存在一些,检查每季缺失的季集
|
||||||
for season, episodes in mediainfo.seasons.items():
|
for season, episodes in mediainfo.seasons.items():
|
||||||
if meta.begin_season \
|
if meta.sea \
|
||||||
and season not in meta.season_list:
|
and season not in meta.season_list:
|
||||||
continue
|
continue
|
||||||
if not episodes:
|
if not episodes:
|
||||||
@@ -805,6 +844,7 @@ class DownloadChain(ChainBase):
|
|||||||
}
|
}
|
||||||
# 下载用户
|
# 下载用户
|
||||||
torrent.userid = history.userid
|
torrent.userid = history.userid
|
||||||
|
torrent.username = history.username
|
||||||
ret_torrents.append(torrent)
|
ret_torrents.append(torrent)
|
||||||
return ret_torrents
|
return ret_torrents
|
||||||
|
|
||||||
@@ -823,3 +863,16 @@ class DownloadChain(ChainBase):
|
|||||||
删除下载任务
|
删除下载任务
|
||||||
"""
|
"""
|
||||||
return self.remove_torrents(hashs=[hash_str])
|
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)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from app.schemas.types import EventType, MediaType
|
|||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
|
|
||||||
recognize_lock = Lock()
|
recognize_lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
@@ -27,13 +26,11 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
# 临时识别结果 {title, name, year, season, episode}
|
# 临时识别结果 {title, name, year, season, episode}
|
||||||
recognize_temp: Optional[dict] = None
|
recognize_temp: Optional[dict] = None
|
||||||
|
|
||||||
def recognize_by_title(self, title: str, subtitle: str = None) -> Optional[Context]:
|
def recognize_by_meta(self, metainfo: MetaBase) -> Optional[MediaInfo]:
|
||||||
"""
|
"""
|
||||||
根据主副标题识别媒体信息
|
根据主副标题识别媒体信息
|
||||||
"""
|
"""
|
||||||
logger.info(f'开始识别媒体信息,标题:{title},副标题:{subtitle} ...')
|
title = metainfo.title
|
||||||
# 识别元数据
|
|
||||||
metainfo = MetaInfo(title, subtitle)
|
|
||||||
# 识别媒体信息
|
# 识别媒体信息
|
||||||
mediainfo: MediaInfo = self.recognize_media(meta=metainfo)
|
mediainfo: MediaInfo = self.recognize_media(meta=metainfo)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
@@ -43,13 +40,13 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
mediainfo = self.recognize_help(title=title, org_meta=metainfo)
|
mediainfo = self.recognize_help(title=title, org_meta=metainfo)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
logger.warn(f'{title} 未识别到媒体信息')
|
logger.warn(f'{title} 未识别到媒体信息')
|
||||||
return Context(meta_info=metainfo)
|
return None
|
||||||
# 识别成功
|
# 识别成功
|
||||||
logger.info(f'{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}')
|
logger.info(f'{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}')
|
||||||
# 更新媒体图片
|
# 更新媒体图片
|
||||||
self.obtain_images(mediainfo=mediainfo)
|
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]:
|
def recognize_help(self, title: str, org_meta: MetaBase) -> Optional[MediaInfo]:
|
||||||
"""
|
"""
|
||||||
@@ -69,7 +66,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
# 每0.5秒循环一次,等待结果,直到10秒后超时
|
# 每0.5秒循环一次,等待结果,直到10秒后超时
|
||||||
for i in range(10):
|
for i in range(20):
|
||||||
if self.recognize_temp is not None:
|
if self.recognize_temp is not None:
|
||||||
break
|
break
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
@@ -170,8 +167,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
# 识别
|
# 识别
|
||||||
meta = MetaInfo(content)
|
meta = MetaInfo(content)
|
||||||
if not meta.name:
|
if not meta.name:
|
||||||
logger.warn(f'{title} 未识别到元数据!')
|
meta.cn_name = content
|
||||||
return meta, []
|
|
||||||
# 合并信息
|
# 合并信息
|
||||||
if mtype:
|
if mtype:
|
||||||
meta.type = mtype
|
meta.type = mtype
|
||||||
@@ -190,3 +186,130 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
logger.info(f"{content} 搜索到 {len(medias)} 条相关媒体信息")
|
logger.info(f"{content} 搜索到 {len(medias)} 条相关媒体信息")
|
||||||
# 识别的元数据,媒体信息列表
|
# 识别的元数据,媒体信息列表
|
||||||
return meta, 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:
|
||||||
|
# 优先使用原标题匹配
|
||||||
|
if doubaninfo.get("original_title"):
|
||||||
|
meta = MetaInfo(title=doubaninfo.get("title"))
|
||||||
|
meta_org = MetaInfo(title=doubaninfo.get("original_title"))
|
||||||
|
else:
|
||||||
|
meta_org = 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信息
|
||||||
|
meta_names = list(dict.fromkeys([k for k in [meta_org.name,
|
||||||
|
meta.cn_name,
|
||||||
|
meta.en_name] if k]))
|
||||||
|
for name in meta_names:
|
||||||
|
tmdbinfo = self.match_tmdbinfo(
|
||||||
|
name=name,
|
||||||
|
year=meta.year,
|
||||||
|
mtype=mtype or meta.type,
|
||||||
|
season=meta.begin_season
|
||||||
|
)
|
||||||
|
if tmdbinfo:
|
||||||
|
break
|
||||||
|
return tmdbinfo
|
||||||
|
|
||||||
|
def get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
根据BangumiID获取TMDB信息
|
||||||
|
"""
|
||||||
|
bangumiinfo = self.bangumi_info(bangumiid=bangumiid)
|
||||||
|
if bangumiinfo:
|
||||||
|
# 优先使用原标题匹配
|
||||||
|
if bangumiinfo.get("name_cn"):
|
||||||
|
meta = MetaInfo(title=bangumiinfo.get("name"))
|
||||||
|
meta_cn = MetaInfo(title=bangumiinfo.get("name_cn"))
|
||||||
|
else:
|
||||||
|
meta_cn = meta = MetaInfo(title=bangumiinfo.get("name"))
|
||||||
|
# 年份
|
||||||
|
release_date = bangumiinfo.get("date") or bangumiinfo.get("air_date")
|
||||||
|
if release_date:
|
||||||
|
year = release_date[:4]
|
||||||
|
else:
|
||||||
|
year = None
|
||||||
|
# 识别TMDB媒体信息
|
||||||
|
meta_names = list(dict.fromkeys([k for k in [meta_cn.name,
|
||||||
|
meta.name] if k]))
|
||||||
|
for name in meta_names:
|
||||||
|
tmdbinfo = self.match_tmdbinfo(
|
||||||
|
name=name,
|
||||||
|
year=year,
|
||||||
|
mtype=MediaType.TV,
|
||||||
|
season=meta.begin_season
|
||||||
|
)
|
||||||
|
if tmdbinfo:
|
||||||
|
return tmdbinfo
|
||||||
|
return None
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def get_doubaninfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
根据BangumiID获取豆瓣信息
|
||||||
|
"""
|
||||||
|
bangumiinfo = self.bangumi_info(bangumiid=bangumiid)
|
||||||
|
if bangumiinfo:
|
||||||
|
# 优先使用中文标题匹配
|
||||||
|
if bangumiinfo.get("name_cn"):
|
||||||
|
meta = MetaInfo(title=bangumiinfo.get("name_cn"))
|
||||||
|
else:
|
||||||
|
meta = MetaInfo(title=bangumiinfo.get("name"))
|
||||||
|
# 年份
|
||||||
|
release_date = bangumiinfo.get("date") or bangumiinfo.get("air_date")
|
||||||
|
if release_date:
|
||||||
|
year = release_date[:4]
|
||||||
|
else:
|
||||||
|
year = None
|
||||||
|
# 使用名称识别豆瓣媒体信息
|
||||||
|
return self.match_doubaninfo(
|
||||||
|
name=meta.name,
|
||||||
|
year=year,
|
||||||
|
mtype=MediaType.TV,
|
||||||
|
season=meta.begin_season
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
from typing import List, Union
|
from typing import List, Union, Optional
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
@@ -20,11 +20,11 @@ class MediaServerChain(ChainBase):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.dboper = MediaServerOper()
|
self.dboper = MediaServerOper()
|
||||||
|
|
||||||
def librarys(self, server: str) -> List[schemas.MediaServerLibrary]:
|
def librarys(self, server: str = None, username: str = None) -> List[schemas.MediaServerLibrary]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器所有媒体库
|
获取媒体服务器所有媒体库
|
||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_librarys", server=server)
|
return self.run_module("mediaserver_librarys", server=server, username=username)
|
||||||
|
|
||||||
def items(self, server: str, library_id: Union[str, int]) -> List[schemas.MediaServerItem]:
|
def items(self, server: str, library_id: Union[str, int]) -> List[schemas.MediaServerItem]:
|
||||||
"""
|
"""
|
||||||
@@ -44,22 +44,40 @@ class MediaServerChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
|
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
|
||||||
|
|
||||||
|
def playing(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||||
|
"""
|
||||||
|
获取媒体服务器正在播放信息
|
||||||
|
"""
|
||||||
|
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):
|
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:
|
with lock:
|
||||||
# 汇总统计
|
# 汇总统计
|
||||||
total_count = 0
|
total_count = 0
|
||||||
# 清空登记薄
|
# 清空登记薄
|
||||||
self.dboper.empty(server=settings.MEDIASERVER)
|
self.dboper.empty()
|
||||||
# 同步黑名单
|
|
||||||
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
|
|
||||||
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
|
|
||||||
# 设置的媒体服务器
|
|
||||||
if not settings.MEDIASERVER:
|
|
||||||
return
|
|
||||||
mediaservers = settings.MEDIASERVER.split(",")
|
|
||||||
# 遍历媒体服务器
|
# 遍历媒体服务器
|
||||||
for mediaserver in mediaservers:
|
for mediaserver in mediaservers:
|
||||||
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")
|
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import copy
|
import copy
|
||||||
from typing import Any
|
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.media import MediaChain
|
||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
from app.chain.subscribe import SubscribeChain
|
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.event import EventManager
|
||||||
|
from app.core.meta import MetaBase
|
||||||
|
from app.db.message_oper import MessageOper
|
||||||
|
from app.helper.message import MessageHelper
|
||||||
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import Notification
|
from app.schemas import Notification, NotExistMediaInfo, CommingMessage
|
||||||
from app.schemas.types import EventType, MessageChannel
|
from app.schemas.types import EventType, MessageChannel, MediaType
|
||||||
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
# 当前页面
|
# 当前页面
|
||||||
_current_page: int = 0
|
_current_page: int = 0
|
||||||
@@ -33,17 +42,70 @@ class MessageChain(ChainBase):
|
|||||||
self.downloadchain = DownloadChain()
|
self.downloadchain = DownloadChain()
|
||||||
self.subscribechain = SubscribeChain()
|
self.subscribechain = SubscribeChain()
|
||||||
self.searchchain = SearchChain()
|
self.searchchain = SearchChain()
|
||||||
self.medtachain = MediaChain()
|
self.mediachain = MediaChain()
|
||||||
self.torrent = TorrentHelper()
|
|
||||||
self.eventmanager = EventManager()
|
self.eventmanager = EventManager()
|
||||||
self.torrenthelper = TorrentHelper()
|
self.torrenthelper = TorrentHelper()
|
||||||
|
self.messagehelper = MessageHelper()
|
||||||
|
self.messageoper = MessageOper()
|
||||||
|
|
||||||
|
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:
|
def process(self, body: Any, form: Any, args: Any) -> None:
|
||||||
"""
|
"""
|
||||||
识别消息内容,执行操作
|
调用模块识别消息内容
|
||||||
"""
|
"""
|
||||||
# 申明全局变量
|
|
||||||
global _current_page, _current_meta, _current_media
|
|
||||||
# 获取消息内容
|
# 获取消息内容
|
||||||
info = self.message_parser(body=body, form=form, args=args)
|
info = self.message_parser(body=body, form=form, args=args)
|
||||||
if not info:
|
if not info:
|
||||||
@@ -53,7 +115,7 @@ class MessageChain(ChainBase):
|
|||||||
# 用户ID
|
# 用户ID
|
||||||
userid = info.userid
|
userid = info.userid
|
||||||
# 用户名
|
# 用户名
|
||||||
username = info.username
|
username = info.username or userid
|
||||||
if not userid:
|
if not userid:
|
||||||
logger.debug(f'未识别到用户ID:{body}{form}{args}')
|
logger.debug(f'未识别到用户ID:{body}{form}{args}')
|
||||||
return
|
return
|
||||||
@@ -62,10 +124,34 @@ class MessageChain(ChainBase):
|
|||||||
if not text:
|
if not text:
|
||||||
logger.debug(f'未识别到消息内容::{body}{form}{args}')
|
logger.debug(f'未识别到消息内容::{body}{form}{args}')
|
||||||
return
|
return
|
||||||
|
# 处理消息
|
||||||
|
self.handle_message(channel=channel, userid=userid, username=username, text=text)
|
||||||
|
|
||||||
|
def handle_message(self, channel: MessageChannel, userid: Union[str, int], username: str, text: str) -> None:
|
||||||
|
"""
|
||||||
|
识别消息内容,执行操作
|
||||||
|
"""
|
||||||
|
# 申明全局变量
|
||||||
|
global _current_page, _current_meta, _current_media
|
||||||
# 加载缓存
|
# 加载缓存
|
||||||
user_cache: Dict[str, dict] = self.load_cache(self._cache_file) or {}
|
user_cache: Dict[str, dict] = self.load_cache(self._cache_file) or {}
|
||||||
# 处理消息
|
# 处理消息
|
||||||
logger.info(f'收到用户消息内容,用户:{userid},内容:{text}')
|
logger.info(f'收到用户消息内容,用户:{userid},内容:{text}')
|
||||||
|
# 保存消息
|
||||||
|
self.messagehelper.put(
|
||||||
|
CommingMessage(
|
||||||
|
userid=userid,
|
||||||
|
username=username,
|
||||||
|
channel=channel,
|
||||||
|
text=text
|
||||||
|
), role="user")
|
||||||
|
self.messageoper.add(
|
||||||
|
channel=channel,
|
||||||
|
userid=username or userid,
|
||||||
|
text=text,
|
||||||
|
action=0
|
||||||
|
)
|
||||||
|
# 处理消息
|
||||||
if text.startswith('/'):
|
if text.startswith('/'):
|
||||||
# 执行命令
|
# 执行命令
|
||||||
self.eventmanager.send_event(
|
self.eventmanager.send_event(
|
||||||
@@ -78,6 +164,7 @@ class MessageChain(ChainBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif text.isdigit():
|
elif text.isdigit():
|
||||||
|
# 用户选择了具体的条目
|
||||||
# 缓存
|
# 缓存
|
||||||
cache_data: dict = user_cache.get(userid)
|
cache_data: dict = user_cache.get(userid)
|
||||||
# 选择项目
|
# 选择项目
|
||||||
@@ -94,30 +181,44 @@ class MessageChain(ChainBase):
|
|||||||
# 缓存列表
|
# 缓存列表
|
||||||
cache_list: list = copy.deepcopy(cache_data.get('items'))
|
cache_list: list = copy.deepcopy(cache_data.get('items'))
|
||||||
# 选择
|
# 选择
|
||||||
if cache_type == "Search":
|
if cache_type in ["Search", "ReSearch"]:
|
||||||
|
# 当前媒体信息
|
||||||
mediainfo: MediaInfo = cache_list[_choice]
|
mediainfo: MediaInfo = cache_list[_choice]
|
||||||
_current_media = mediainfo
|
_current_media = mediainfo
|
||||||
# 查询缺失的媒体信息
|
# 查询缺失的媒体信息
|
||||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
||||||
mediainfo=_current_media)
|
mediainfo=_current_media)
|
||||||
if exist_flag:
|
if exist_flag and cache_type == "Search":
|
||||||
|
# 媒体库中已存在
|
||||||
self.post_message(
|
self.post_message(
|
||||||
Notification(channel=channel,
|
Notification(channel=channel,
|
||||||
title=f"{_current_media.title_year}"
|
title=f"【{_current_media.title_year}"
|
||||||
f"{_current_meta.sea} 媒体库中已存在",
|
f"{_current_meta.sea} 媒体库中已存在,如需重新下载请发送:搜索 名称 或 下载 名称】",
|
||||||
userid=userid))
|
userid=userid))
|
||||||
return
|
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 = [
|
messages = [
|
||||||
f"第 {sea} 季缺失 {StringUtils.str_series(no_exist.episodes) if no_exist.episodes else no_exist.total_episode} 集"
|
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,
|
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))
|
userid=userid))
|
||||||
# 搜索种子,过滤掉不需要的剧集,以便选择
|
# 搜索种子,过滤掉不需要的剧集,以便选择
|
||||||
logger.info(f"{mediainfo.title_year} 媒体库中不存在,开始搜索 ...")
|
logger.info(f"开始搜索 {mediainfo.title_year} ...")
|
||||||
self.post_message(
|
self.post_message(
|
||||||
Notification(channel=channel,
|
Notification(channel=channel,
|
||||||
title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...",
|
title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...",
|
||||||
@@ -137,13 +238,16 @@ class MessageChain(ChainBase):
|
|||||||
# 判断是否设置自动下载
|
# 判断是否设置自动下载
|
||||||
auto_download_user = settings.AUTO_DOWNLOAD_USER
|
auto_download_user = settings.AUTO_DOWNLOAD_USER
|
||||||
# 匹配到自动下载用户
|
# 匹配到自动下载用户
|
||||||
if auto_download_user and any(userid == user for user in auto_download_user.split(",")):
|
if auto_download_user \
|
||||||
logger.info(f"用户 {userid} 在自动下载用户中,开始自动择优下载")
|
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,
|
self.__auto_download(channel=channel,
|
||||||
cache_list=contexts,
|
cache_list=contexts,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username)
|
username=username,
|
||||||
|
no_exists=no_exists)
|
||||||
else:
|
else:
|
||||||
# 更新缓存
|
# 更新缓存
|
||||||
user_cache[userid] = {
|
user_cache[userid] = {
|
||||||
@@ -158,19 +262,24 @@ class MessageChain(ChainBase):
|
|||||||
userid=userid,
|
userid=userid,
|
||||||
total=len(contexts))
|
total=len(contexts))
|
||||||
|
|
||||||
elif cache_type == "Subscribe":
|
elif cache_type in ["Subscribe", "ReSubscribe"]:
|
||||||
# 订阅媒体
|
# 订阅或洗版媒体
|
||||||
mediainfo: MediaInfo = cache_list[_choice]
|
mediainfo: MediaInfo = cache_list[_choice]
|
||||||
|
# 洗版标识
|
||||||
|
best_version = False
|
||||||
# 查询缺失的媒体信息
|
# 查询缺失的媒体信息
|
||||||
exist_flag, _ = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
if cache_type == "Subscribe":
|
||||||
mediainfo=mediainfo)
|
exist_flag, _ = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
||||||
if exist_flag:
|
mediainfo=mediainfo)
|
||||||
self.post_message(Notification(
|
if exist_flag:
|
||||||
channel=channel,
|
self.post_message(Notification(
|
||||||
title=f"{mediainfo.title_year}"
|
channel=channel,
|
||||||
f"{_current_meta.sea} 媒体库中已存在",
|
title=f"【{mediainfo.title_year}"
|
||||||
userid=userid))
|
f"{_current_meta.sea} 媒体库中已存在,如需洗版请发送:洗版 XXX】",
|
||||||
return
|
userid=userid))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
best_version = True
|
||||||
# 添加订阅,状态为N
|
# 添加订阅,状态为N
|
||||||
self.subscribechain.add(title=mediainfo.title,
|
self.subscribechain.add(title=mediainfo.title,
|
||||||
year=mediainfo.year,
|
year=mediainfo.year,
|
||||||
@@ -179,10 +288,11 @@ class MessageChain(ChainBase):
|
|||||||
season=_current_meta.begin_season,
|
season=_current_meta.begin_season,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username)
|
username=username,
|
||||||
|
best_version=best_version)
|
||||||
elif cache_type == "Torrent":
|
elif cache_type == "Torrent":
|
||||||
if int(text) == 0:
|
if int(text) == 0:
|
||||||
# 自动选择下载
|
# 自动选择下载,强制下载模式
|
||||||
self.__auto_download(channel=channel,
|
self.__auto_download(channel=channel,
|
||||||
cache_list=cache_list,
|
cache_list=cache_list,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
@@ -191,7 +301,8 @@ class MessageChain(ChainBase):
|
|||||||
# 下载种子
|
# 下载种子
|
||||||
context: Context = cache_list[_choice]
|
context: Context = cache_list[_choice]
|
||||||
# 下载
|
# 下载
|
||||||
self.downloadchain.download_single(context, userid=userid, channel=channel, username=username)
|
self.downloadchain.download_single(context, channel=channel,
|
||||||
|
userid=userid, username=username)
|
||||||
|
|
||||||
elif text.lower() == "p":
|
elif text.lower() == "p":
|
||||||
# 上一页
|
# 上一页
|
||||||
@@ -273,6 +384,14 @@ class MessageChain(ChainBase):
|
|||||||
# 订阅
|
# 订阅
|
||||||
content = re.sub(r"订阅[::\s]*", "", text)
|
content = re.sub(r"订阅[::\s]*", "", text)
|
||||||
action = "Subscribe"
|
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("#") \
|
elif text.startswith("#") \
|
||||||
or re.search(r"^请[问帮你]", text) \
|
or re.search(r"^请[问帮你]", text) \
|
||||||
or re.search(r"[??]$", text) \
|
or re.search(r"[??]$", text) \
|
||||||
@@ -283,12 +402,12 @@ class MessageChain(ChainBase):
|
|||||||
action = "chat"
|
action = "chat"
|
||||||
else:
|
else:
|
||||||
# 搜索
|
# 搜索
|
||||||
content = re.sub(r"(搜索|下载)[::\s]*", "", text)
|
content = text
|
||||||
action = "Search"
|
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:
|
if not meta.name:
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
@@ -327,20 +446,22 @@ class MessageChain(ChainBase):
|
|||||||
# 保存缓存
|
# 保存缓存
|
||||||
self.save_cache(user_cache, self._cache_file)
|
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):
|
||||||
"""
|
"""
|
||||||
自动择优下载
|
自动择优下载
|
||||||
"""
|
"""
|
||||||
# 查询缺失的媒体信息
|
if no_exists is None:
|
||||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
# 查询缺失的媒体信息
|
||||||
mediainfo=_current_media)
|
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
|
||||||
if exist_flag:
|
meta=_current_meta,
|
||||||
self.post_message(Notification(
|
mediainfo=_current_media
|
||||||
channel=channel,
|
)
|
||||||
title=f"{_current_media.title_year}"
|
if exist_flag:
|
||||||
f"{_current_meta.sea} 媒体库中已存在",
|
# 媒体库中已存在,查询全量
|
||||||
userid=userid))
|
no_exists = self.__get_noexits_info(_current_meta, _current_media)
|
||||||
return
|
|
||||||
# 批量下载
|
# 批量下载
|
||||||
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
|
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
|
||||||
no_exists=no_exists,
|
no_exists=no_exists,
|
||||||
@@ -353,6 +474,13 @@ class MessageChain(ChainBase):
|
|||||||
else:
|
else:
|
||||||
# 未完成下载
|
# 未完成下载
|
||||||
logger.info(f'{_current_media.title_year} 未下载未完整,添加订阅 ...')
|
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
|
# 添加订阅,状态为R
|
||||||
self.subscribechain.add(title=_current_media.title,
|
self.subscribechain.add(title=_current_media.title,
|
||||||
year=_current_media.year,
|
year=_current_media.year,
|
||||||
@@ -362,7 +490,8 @@ class MessageChain(ChainBase):
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
state="R")
|
state="R",
|
||||||
|
note=note)
|
||||||
|
|
||||||
def __post_medias_message(self, channel: MessageChannel,
|
def __post_medias_message(self, channel: MessageChannel,
|
||||||
title: str, items: list, userid: str, total: int):
|
title: str, items: list, userid: str, total: int):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import pickle
|
import pickle
|
||||||
import re
|
import traceback
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
@@ -8,6 +8,7 @@ from typing import List, Optional
|
|||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.context import Context
|
from app.core.context import Context
|
||||||
from app.core.context import MediaInfo, TorrentInfo
|
from app.core.context import MediaInfo, TorrentInfo
|
||||||
|
from app.core.event import eventmanager, Event
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.helper.progress import ProgressHelper
|
from app.helper.progress import ProgressHelper
|
||||||
@@ -15,8 +16,7 @@ from app.helper.sites import SitesHelper
|
|||||||
from app.helper.torrent import TorrentHelper
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import NotExistMediaInfo
|
from app.schemas import NotExistMediaInfo
|
||||||
from app.schemas.types import MediaType, ProgressKey, SystemConfigKey
|
from app.schemas.types import MediaType, ProgressKey, SystemConfigKey, EventType
|
||||||
from app.utils.string import StringUtils
|
|
||||||
|
|
||||||
|
|
||||||
class SearchChain(ChainBase):
|
class SearchChain(ChainBase):
|
||||||
@@ -31,18 +31,28 @@ class SearchChain(ChainBase):
|
|||||||
self.systemconfig = SystemConfigOper()
|
self.systemconfig = SystemConfigOper()
|
||||||
self.torrenthelper = TorrentHelper()
|
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", season: int = None) -> List[Context]:
|
||||||
"""
|
"""
|
||||||
根据TMDB ID搜索资源,精确匹配,但不不过滤本地存在的资源
|
根据TMDBID/豆瓣ID搜索资源,精确匹配,但不不过滤本地存在的资源
|
||||||
:param tmdbid: TMDB ID
|
:param tmdbid: TMDB ID
|
||||||
|
:param doubanid: 豆瓣 ID
|
||||||
:param mtype: 媒体,电影 or 电视剧
|
:param mtype: 媒体,电影 or 电视剧
|
||||||
:param area: 搜索范围,title or imdbid
|
:param area: 搜索范围,title or imdbid
|
||||||
|
:param season: 季数
|
||||||
"""
|
"""
|
||||||
mediainfo = self.recognize_media(tmdbid=tmdbid, mtype=mtype)
|
mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||||
return []
|
return []
|
||||||
results = self.process(mediainfo=mediainfo, area=area)
|
no_exists = None
|
||||||
|
if season:
|
||||||
|
no_exists = {
|
||||||
|
tmdbid or doubanid: {
|
||||||
|
season: NotExistMediaInfo(episodes=[])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = self.process(mediainfo=mediainfo, area=area, no_exists=no_exists)
|
||||||
# 保存眲结果
|
# 保存眲结果
|
||||||
bytes_results = pickle.dumps(results)
|
bytes_results = pickle.dumps(results)
|
||||||
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
|
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
|
||||||
@@ -72,7 +82,7 @@ class SearchChain(ChainBase):
|
|||||||
try:
|
try:
|
||||||
return pickle.loads(results)
|
return pickle.loads(results)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(str(e))
|
logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def process(self, mediainfo: MediaInfo,
|
def process(self, mediainfo: MediaInfo,
|
||||||
@@ -92,28 +102,41 @@ class SearchChain(ChainBase):
|
|||||||
:param filter_rule: 过滤规则,为空是使用默认过滤规则
|
:param filter_rule: 过滤规则,为空是使用默认过滤规则
|
||||||
:param area: 搜索范围,title or imdbid
|
: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} ...')
|
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||||
# 补充媒体信息
|
# 补充媒体信息
|
||||||
if not mediainfo.names:
|
if not mediainfo.names:
|
||||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||||
tmdbid=mediainfo.tmdb_id)
|
tmdbid=mediainfo.tmdb_id,
|
||||||
|
doubanid=mediainfo.douban_id)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
logger.error(f'媒体信息识别失败!')
|
logger.error(f'媒体信息识别失败!')
|
||||||
return []
|
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
|
season_episodes = {sea: info.episodes
|
||||||
for sea, info in no_exists[mediainfo.tmdb_id].items()}
|
for sea, info in no_exists[mediainfo.tmdb_id].items()}
|
||||||
|
elif mediainfo.season:
|
||||||
|
# 豆瓣只搜索当前季
|
||||||
|
season_episodes = {mediainfo.season: []}
|
||||||
else:
|
else:
|
||||||
season_episodes = None
|
season_episodes = None
|
||||||
# 搜索关键词
|
# 搜索关键词
|
||||||
if keyword:
|
if keyword:
|
||||||
keywords = [keyword]
|
keywords = [keyword]
|
||||||
elif mediainfo.original_title and mediainfo.title != mediainfo.original_title:
|
|
||||||
keywords = [mediainfo.title, mediainfo.original_title]
|
|
||||||
else:
|
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] = self.__search_all_sites(
|
torrents: List[TorrentInfo] = self.__search_all_sites(
|
||||||
mediainfo=mediainfo,
|
mediainfo=mediainfo,
|
||||||
@@ -124,27 +147,8 @@ class SearchChain(ChainBase):
|
|||||||
if not torrents:
|
if not torrents:
|
||||||
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||||
return []
|
return []
|
||||||
# 过滤种子
|
# 开始新进度
|
||||||
if priority_rule is None:
|
self.progress.start(ProgressKey.Search)
|
||||||
# 取搜索优先级规则
|
|
||||||
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=torrents,
|
|
||||||
season_episodes=season_episodes,
|
|
||||||
mediainfo=mediainfo)
|
|
||||||
if result is not None:
|
|
||||||
torrents = result
|
|
||||||
if not torrents:
|
|
||||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
|
||||||
return []
|
|
||||||
# 使用过滤规则再次过滤
|
|
||||||
torrents = self.filter_torrents_by_rule(torrents=torrents,
|
|
||||||
filter_rule=filter_rule)
|
|
||||||
if not torrents:
|
|
||||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
|
||||||
return []
|
|
||||||
# 匹配的资源
|
# 匹配的资源
|
||||||
_match_torrents = []
|
_match_torrents = []
|
||||||
# 总数
|
# 总数
|
||||||
@@ -152,86 +156,95 @@ class SearchChain(ChainBase):
|
|||||||
# 已处理数
|
# 已处理数
|
||||||
_count = 0
|
_count = 0
|
||||||
if mediainfo:
|
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)
|
self.progress.update(value=0, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
|
||||||
for torrent in torrents:
|
for torrent in torrents:
|
||||||
_count += 1
|
_count += 1
|
||||||
self.progress.update(value=(_count / _total) * 100,
|
self.progress.update(value=(_count / _total) * 96,
|
||||||
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
|
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
|
||||||
key=ProgressKey.Search)
|
key=ProgressKey.Search)
|
||||||
|
if not torrent.title:
|
||||||
|
continue
|
||||||
# 比对IMDBID
|
# 比对IMDBID
|
||||||
if torrent.imdbid \
|
if torrent.imdbid \
|
||||||
and mediainfo.imdb_id \
|
and mediainfo.imdb_id \
|
||||||
and torrent.imdbid == 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)
|
_match_torrents.append(torrent)
|
||||||
continue
|
continue
|
||||||
# 识别
|
# 识别
|
||||||
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||||
# 比对类型
|
if torrent.title != torrent_meta.org_string:
|
||||||
if (torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV) \
|
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}")
|
||||||
or (torrent_meta.type != MediaType.TV and mediainfo.type == MediaType.TV):
|
# 比对词条指定的tmdbid
|
||||||
logger.warn(f'{torrent.site_name} - {torrent.title} 类型不匹配')
|
if torrent_meta.tmdbid or torrent_meta.doubanid:
|
||||||
continue
|
if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id:
|
||||||
# 比对年份
|
logger.info(f'{mediainfo.title} 通过词表指定TMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||||
if mediainfo.year:
|
|
||||||
if mediainfo.type == MediaType.TV:
|
|
||||||
# 剧集年份,每季的年份可能不同
|
|
||||||
if torrent_meta.year and torrent_meta.year not in [year for year in
|
|
||||||
mediainfo.season_years.values()]:
|
|
||||||
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配')
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# 电影年份,上下浮动1年
|
|
||||||
if torrent_meta.year not in [str(int(mediainfo.year) - 1),
|
|
||||||
mediainfo.year,
|
|
||||||
str(int(mediainfo.year) + 1)]:
|
|
||||||
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配')
|
|
||||||
continue
|
|
||||||
# 比对标题和原语种标题
|
|
||||||
meta_name = StringUtils.clear_upper(torrent_meta.name)
|
|
||||||
if meta_name in [
|
|
||||||
StringUtils.clear_upper(mediainfo.title),
|
|
||||||
StringUtils.clear_upper(mediainfo.original_title)
|
|
||||||
]:
|
|
||||||
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
|
|
||||||
_match_torrents.append(torrent)
|
|
||||||
continue
|
|
||||||
# 在副标题中判断是否存在标题与原语种标题
|
|
||||||
if torrent.description:
|
|
||||||
subtitle = torrent.description.split()
|
|
||||||
if (StringUtils.is_chinese(mediainfo.title)
|
|
||||||
and str(mediainfo.title) in subtitle) \
|
|
||||||
or (StringUtils.is_chinese(mediainfo.original_title)
|
|
||||||
and str(mediainfo.original_title) in subtitle):
|
|
||||||
logger.info(f'{mediainfo.title} 通过副标题匹配到资源:{torrent.site_name} - {torrent.title},'
|
|
||||||
f'副标题:{torrent.description}')
|
|
||||||
_match_torrents.append(torrent)
|
_match_torrents.append(torrent)
|
||||||
continue
|
continue
|
||||||
# 比对别名和译名
|
if torrent_meta.doubanid and torrent_meta.doubanid == mediainfo.douban_id:
|
||||||
for name in mediainfo.names:
|
logger.info(f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||||
if StringUtils.clear_upper(name) == meta_name:
|
|
||||||
logger.info(f'{mediainfo.title} 通过别名或译名匹配到资源:{torrent.site_name} - {torrent.title}')
|
|
||||||
_match_torrents.append(torrent)
|
_match_torrents.append(torrent)
|
||||||
break
|
continue
|
||||||
else:
|
|
||||||
logger.warn(f'{torrent.site_name} - {torrent.title} 标题不匹配')
|
# 比对种子
|
||||||
self.progress.update(value=100,
|
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||||
|
torrent_meta=torrent_meta,
|
||||||
|
torrent=torrent):
|
||||||
|
# 匹配成功
|
||||||
|
_match_torrents.append(torrent)
|
||||||
|
continue
|
||||||
|
# 匹配完成
|
||||||
|
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
||||||
|
self.progress.update(value=97,
|
||||||
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
|
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
|
||||||
key=ProgressKey.Search)
|
key=ProgressKey.Search)
|
||||||
self.progress.end(ProgressKey.Search)
|
|
||||||
else:
|
else:
|
||||||
_match_torrents = torrents
|
_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 _match_torrents:
|
||||||
|
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中多余的数据
|
||||||
mediainfo.clear()
|
mediainfo.clear()
|
||||||
# 组装上下文
|
# 组装上下文
|
||||||
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||||
media_info=mediainfo,
|
media_info=mediainfo,
|
||||||
torrent_info=torrent) for torrent in _match_torrents]
|
torrent_info=torrent) for torrent in _match_torrents]
|
||||||
|
|
||||||
|
logger.info(f"过滤完成,剩余 {len(contexts)} 个资源")
|
||||||
|
self.progress.update(value=99, text=f'过滤完成,剩余 {len(contexts)} 个资源', key=ProgressKey.Search)
|
||||||
# 排序
|
# 排序
|
||||||
|
self.progress.update(value=100,
|
||||||
|
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
|
||||||
|
key=ProgressKey.Search)
|
||||||
contexts = self.torrenthelper.sort_torrents(contexts)
|
contexts = self.torrenthelper.sort_torrents(contexts)
|
||||||
|
# 结束进度
|
||||||
|
self.progress.end(ProgressKey.Search)
|
||||||
# 返回
|
# 返回
|
||||||
return contexts
|
return contexts
|
||||||
|
|
||||||
@@ -323,12 +336,14 @@ class SearchChain(ChainBase):
|
|||||||
|
|
||||||
def filter_torrents_by_rule(self,
|
def filter_torrents_by_rule(self,
|
||||||
torrents: List[TorrentInfo],
|
torrents: List[TorrentInfo],
|
||||||
filter_rule: Dict[str, str] = None
|
mediainfo: MediaInfo,
|
||||||
|
filter_rule: Dict[str, str] = None,
|
||||||
) -> List[TorrentInfo]:
|
) -> List[TorrentInfo]:
|
||||||
"""
|
"""
|
||||||
使用过滤规则过滤种子
|
使用过滤规则过滤种子
|
||||||
:param torrents: 种子列表
|
:param torrents: 种子列表
|
||||||
:param filter_rule: 过滤规则
|
:param filter_rule: 过滤规则
|
||||||
|
:param mediainfo: 媒体信息
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not filter_rule:
|
if not filter_rule:
|
||||||
@@ -336,52 +351,34 @@ class SearchChain(ChainBase):
|
|||||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules)
|
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules)
|
||||||
if not filter_rule:
|
if not filter_rule:
|
||||||
return torrents
|
return torrents
|
||||||
# 包含
|
|
||||||
include = filter_rule.get("include")
|
|
||||||
# 排除
|
|
||||||
exclude = filter_rule.get("exclude")
|
|
||||||
# 质量
|
|
||||||
quality = filter_rule.get("quality")
|
|
||||||
# 分辨率
|
|
||||||
resolution = filter_rule.get("resolution")
|
|
||||||
# 特效
|
|
||||||
effect = filter_rule.get("effect")
|
|
||||||
|
|
||||||
def __filter_torrent(t: TorrentInfo) -> bool:
|
|
||||||
"""
|
|
||||||
过滤种子
|
|
||||||
"""
|
|
||||||
# 包含
|
|
||||||
if include:
|
|
||||||
if not re.search(r"%s" % include,
|
|
||||||
f"{t.title} {t.description}", re.I):
|
|
||||||
logger.info(f"{t.title} 不匹配包含规则 {include}")
|
|
||||||
return False
|
|
||||||
# 排除
|
|
||||||
if exclude:
|
|
||||||
if re.search(r"%s" % exclude,
|
|
||||||
f"{t.title} {t.description}", re.I):
|
|
||||||
logger.info(f"{t.title} 匹配排除规则 {exclude}")
|
|
||||||
return False
|
|
||||||
# 质量
|
|
||||||
if quality:
|
|
||||||
if not re.search(r"%s" % quality, t.title, re.I):
|
|
||||||
logger.info(f"{t.title} 不匹配质量规则 {quality}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 分辨率
|
|
||||||
if resolution:
|
|
||||||
if not re.search(r"%s" % resolution, t.title, re.I):
|
|
||||||
logger.info(f"{t.title} 不匹配分辨率规则 {resolution}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 特效
|
|
||||||
if effect:
|
|
||||||
if not re.search(r"%s" % effect, t.title, re.I):
|
|
||||||
logger.info(f"{t.title} 不匹配特效规则 {effect}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 使用默认过滤规则再次过滤
|
# 使用默认过滤规则再次过滤
|
||||||
return list(filter(lambda t: __filter_torrent(t), torrents))
|
return list(filter(
|
||||||
|
lambda t: self.torrenthelper.filter_torrent(
|
||||||
|
torrent_info=t,
|
||||||
|
filter_rule=filter_rule,
|
||||||
|
mediainfo=mediainfo
|
||||||
|
),
|
||||||
|
torrents
|
||||||
|
))
|
||||||
|
|
||||||
|
@eventmanager.register(EventType.SiteDeleted)
|
||||||
|
def remove_site(self, event: Event):
|
||||||
|
"""
|
||||||
|
从搜索站点中移除与已删除站点相关的设置
|
||||||
|
"""
|
||||||
|
if not event:
|
||||||
|
return
|
||||||
|
event_data = event.event_data or {}
|
||||||
|
site_id = event_data.get("site_id")
|
||||||
|
if not site_id:
|
||||||
|
return
|
||||||
|
if site_id == "*":
|
||||||
|
# 清空搜索站点
|
||||||
|
SystemConfigOper().set(SystemConfigKey.IndexerSites, [])
|
||||||
|
return
|
||||||
|
# 从选中的rss站点中移除
|
||||||
|
selected_sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
|
||||||
|
if site_id in selected_sites:
|
||||||
|
selected_sites.remove(site_id)
|
||||||
|
SystemConfigOper().set(SystemConfigKey.IndexerSites, selected_sites)
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
|
import base64
|
||||||
import re
|
import re
|
||||||
from typing import Union, Tuple
|
from datetime import datetime
|
||||||
|
from typing import Tuple, Optional
|
||||||
|
from typing import Union
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.event import eventmanager, Event, EventManager
|
||||||
from app.db.models.site import Site
|
from app.db.models.site import Site
|
||||||
from app.db.site_oper import SiteOper
|
from app.db.site_oper import SiteOper
|
||||||
|
from app.db.siteicon_oper import SiteIconOper
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.db.sytestatistic_oper import SiteStatisticOper
|
||||||
from app.helper.browser import PlaywrightHelper
|
from app.helper.browser import PlaywrightHelper
|
||||||
from app.helper.cloudflare import under_challenge
|
from app.helper.cloudflare import under_challenge
|
||||||
from app.helper.cookie import CookieHelper
|
from app.helper.cookie import CookieHelper
|
||||||
|
from app.helper.cookiecloud import CookieCloudHelper
|
||||||
from app.helper.message import MessageHelper
|
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.log import logger
|
||||||
from app.schemas import MessageChannel, Notification
|
from app.schemas import MessageChannel, Notification
|
||||||
|
from app.schemas.types import EventType
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.site import SiteUtils
|
from app.utils.site import SiteUtils
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
@@ -24,15 +38,29 @@ class SiteChain(ChainBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.siteoper = SiteOper()
|
self.siteoper = SiteOper()
|
||||||
|
self.siteiconoper = SiteIconOper()
|
||||||
|
self.siteshelper = SitesHelper()
|
||||||
|
self.rsshelper = RssHelper()
|
||||||
self.cookiehelper = CookieHelper()
|
self.cookiehelper = CookieHelper()
|
||||||
self.message = MessageHelper()
|
self.message = MessageHelper()
|
||||||
|
self.cookiecloud = CookieCloudHelper()
|
||||||
|
self.systemconfig = SystemConfigOper()
|
||||||
|
self.sitestatistic = SiteStatisticOper()
|
||||||
|
|
||||||
# 特殊站点登录验证
|
# 特殊站点登录验证
|
||||||
self.special_site_test = {
|
self.special_site_test = {
|
||||||
"zhuque.in": self.__zhuque_test,
|
"zhuque.in": self.__zhuque_test,
|
||||||
# "m-team.io": self.__mteam_test,
|
"m-team.io": self.__mteam_test,
|
||||||
|
"m-team.cc": self.__mteam_test,
|
||||||
|
"ptlsp.com": self.__ptlsp_test,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def is_special_site(self, domain: str) -> bool:
|
||||||
|
"""
|
||||||
|
判断是否特殊站点
|
||||||
|
"""
|
||||||
|
return domain in self.special_site_test
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __zhuque_test(site: Site) -> Tuple[bool, str]:
|
def __zhuque_test(site: Site) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
@@ -40,8 +68,9 @@ class SiteChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
# 获取token
|
# 获取token
|
||||||
token = None
|
token = None
|
||||||
|
user_agent = site.ua or settings.USER_AGENT
|
||||||
res = RequestUtils(
|
res = RequestUtils(
|
||||||
ua=site.ua,
|
ua=user_agent,
|
||||||
cookies=site.cookie,
|
cookies=site.cookie,
|
||||||
proxies=settings.PROXY if site.proxy else None,
|
proxies=settings.PROXY if site.proxy else None,
|
||||||
timeout=15
|
timeout=15
|
||||||
@@ -57,7 +86,7 @@ class SiteChain(ChainBase):
|
|||||||
headers={
|
headers={
|
||||||
'X-CSRF-TOKEN': token,
|
'X-CSRF-TOKEN': token,
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
"User-Agent": f"{site.ua}"
|
"User-Agent": f"{user_agent}"
|
||||||
},
|
},
|
||||||
cookies=site.cookie,
|
cookies=site.cookie,
|
||||||
proxies=settings.PROXY if site.proxy else None,
|
proxies=settings.PROXY if site.proxy else None,
|
||||||
@@ -74,9 +103,10 @@ class SiteChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
判断站点是否已经登陆:m-team
|
判断站点是否已经登陆:m-team
|
||||||
"""
|
"""
|
||||||
|
user_agent = site.ua or settings.USER_AGENT
|
||||||
url = f"{site.url}api/member/profile"
|
url = f"{site.url}api/member/profile"
|
||||||
res = RequestUtils(
|
res = RequestUtils(
|
||||||
ua=site.ua,
|
ua=user_agent,
|
||||||
cookies=site.cookie,
|
cookies=site.cookie,
|
||||||
proxies=settings.PROXY if site.proxy else None,
|
proxies=settings.PROXY if site.proxy else None,
|
||||||
timeout=15
|
timeout=15
|
||||||
@@ -84,9 +114,231 @@ class SiteChain(ChainBase):
|
|||||||
if res and res.status_code == 200:
|
if res and res.status_code == 200:
|
||||||
user_info = res.json()
|
user_info = res.json()
|
||||||
if user_info and user_info.get("data"):
|
if user_info and user_info.get("data"):
|
||||||
return True, "连接成功"
|
# 更新最后访问时间
|
||||||
|
res = RequestUtils(cookies=site.cookie,
|
||||||
|
ua=user_agent,
|
||||||
|
timeout=60,
|
||||||
|
proxies=settings.PROXY if site.proxy else None,
|
||||||
|
referer=f"{site.url}index"
|
||||||
|
).post_res(url=urljoin(url, "api/member/updateLastBrowse"))
|
||||||
|
if res:
|
||||||
|
return True, "连接成功"
|
||||||
|
else:
|
||||||
|
return True, f"连接成功,但更新状态失败"
|
||||||
return False, "Cookie已失效"
|
return False, "Cookie已失效"
|
||||||
|
|
||||||
|
def __ptlsp_test(self, site: Site) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
判断站点是否已经登陆:ptlsp
|
||||||
|
"""
|
||||||
|
site.url = f"{site.url}index.php"
|
||||||
|
return self.__test(site)
|
||||||
|
|
||||||
|
@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=site_info.ua or 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.SiteUpdated, {
|
||||||
|
"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.SiteUpdated)
|
||||||
|
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')} 图标失败")
|
||||||
|
|
||||||
|
@eventmanager.register(EventType.SiteUpdated)
|
||||||
|
def clear_site_data(self, event: Event):
|
||||||
|
"""
|
||||||
|
清理站点数据
|
||||||
|
"""
|
||||||
|
if not event:
|
||||||
|
return
|
||||||
|
event_data = event.event_data or {}
|
||||||
|
# 主域名
|
||||||
|
domain = event_data.get("domain")
|
||||||
|
if not domain:
|
||||||
|
return
|
||||||
|
# 获取主域名中间那段
|
||||||
|
domain_host = StringUtils.get_url_host(domain)
|
||||||
|
# 查询以"site.domain_host"开头的配置项,并清除
|
||||||
|
site_keys = self.systemconfig.all().keys()
|
||||||
|
for key in site_keys:
|
||||||
|
if key.startswith(f"site.{domain_host}"):
|
||||||
|
logger.info(f"清理站点配置:{key}")
|
||||||
|
self.systemconfig.delete(key)
|
||||||
|
|
||||||
def test(self, url: str) -> Tuple[bool, str]:
|
def test(self, url: str) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
测试站点是否可用
|
测试站点是否可用
|
||||||
@@ -99,53 +351,70 @@ class SiteChain(ChainBase):
|
|||||||
if not site_info:
|
if not site_info:
|
||||||
return False, f"站点【{url}】不存在"
|
return False, f"站点【{url}】不存在"
|
||||||
|
|
||||||
# 特殊站点测试
|
# 模拟登录
|
||||||
if self.special_site_test.get(domain):
|
try:
|
||||||
return self.special_site_test[domain](site_info)
|
# 开始记时
|
||||||
|
start_time = datetime.now()
|
||||||
|
# 特殊站点测试
|
||||||
|
if self.special_site_test.get(domain):
|
||||||
|
state, message = self.special_site_test[domain](site_info)
|
||||||
|
else:
|
||||||
|
# 通用站点测试
|
||||||
|
state, message = self.__test(site_info)
|
||||||
|
# 统计
|
||||||
|
seconds = (datetime.now() - start_time).seconds
|
||||||
|
if state:
|
||||||
|
self.sitestatistic.success(domain=domain, seconds=seconds)
|
||||||
|
else:
|
||||||
|
self.sitestatistic.fail(domain)
|
||||||
|
return state, message
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"{str(e)}!"
|
||||||
|
|
||||||
# 通用站点测试
|
@staticmethod
|
||||||
|
def __test(site_info: Site) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
通用站点测试
|
||||||
|
"""
|
||||||
site_url = site_info.url
|
site_url = site_info.url
|
||||||
site_cookie = site_info.cookie
|
site_cookie = site_info.cookie
|
||||||
ua = site_info.ua
|
ua = site_info.ua or settings.USER_AGENT
|
||||||
render = site_info.render
|
render = site_info.render
|
||||||
public = site_info.public
|
public = site_info.public
|
||||||
proxies = settings.PROXY if site_info.proxy else None
|
proxies = settings.PROXY if site_info.proxy else None
|
||||||
proxy_server = settings.PROXY_SERVER if site_info.proxy else None
|
proxy_server = settings.PROXY_SERVER if site_info.proxy else None
|
||||||
# 模拟登录
|
|
||||||
try:
|
# 访问链接
|
||||||
# 访问链接
|
if render:
|
||||||
if render:
|
page_source = PlaywrightHelper().get_page_source(url=site_url,
|
||||||
page_source = PlaywrightHelper().get_page_source(url=site_url,
|
cookies=site_cookie,
|
||||||
cookies=site_cookie,
|
ua=ua,
|
||||||
ua=ua,
|
proxies=proxy_server)
|
||||||
proxies=proxy_server)
|
if not public and not SiteUtils.is_logged_in(page_source):
|
||||||
if not public and not SiteUtils.is_logged_in(page_source):
|
if under_challenge(page_source):
|
||||||
if under_challenge(page_source):
|
return False, f"无法通过Cloudflare!"
|
||||||
return False, f"无法通过Cloudflare!"
|
return False, f"仿真登录失败,Cookie已失效!"
|
||||||
return False, f"仿真登录失败,Cookie已失效!"
|
else:
|
||||||
else:
|
res = RequestUtils(cookies=site_cookie,
|
||||||
res = RequestUtils(cookies=site_cookie,
|
ua=ua,
|
||||||
ua=ua,
|
proxies=proxies
|
||||||
proxies=proxies
|
).get_res(url=site_url)
|
||||||
).get_res(url=site_url)
|
# 判断登录状态
|
||||||
# 判断登录状态
|
if res and res.status_code in [200, 500, 403]:
|
||||||
if res and res.status_code in [200, 500, 403]:
|
if not public and not SiteUtils.is_logged_in(res.text):
|
||||||
if not public and not SiteUtils.is_logged_in(res.text):
|
if under_challenge(res.text):
|
||||||
if under_challenge(res.text):
|
msg = "站点被Cloudflare防护,请打开站点浏览器仿真"
|
||||||
msg = "站点被Cloudflare防护,请打开站点浏览器仿真"
|
elif res.status_code == 200:
|
||||||
elif res.status_code == 200:
|
msg = "Cookie已失效"
|
||||||
msg = "Cookie已失效"
|
else:
|
||||||
else:
|
msg = f"状态码:{res.status_code}"
|
||||||
msg = f"状态码:{res.status_code}"
|
return False, f"{msg}!"
|
||||||
return False, f"{msg}!"
|
elif public and res.status_code != 200:
|
||||||
elif public and res.status_code != 200:
|
|
||||||
return False, f"状态码:{res.status_code}!"
|
|
||||||
elif res is not None:
|
|
||||||
return False, f"状态码:{res.status_code}!"
|
return False, f"状态码:{res.status_code}!"
|
||||||
else:
|
elif res is not None:
|
||||||
return False, f"无法打开网站!"
|
return False, f"状态码:{res.status_code}!"
|
||||||
except Exception as e:
|
else:
|
||||||
return False, f"{str(e)}!"
|
return False, f"无法打开网站!"
|
||||||
return True, "连接成功"
|
return True, "连接成功"
|
||||||
|
|
||||||
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
|
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||||
@@ -161,7 +430,7 @@ class SiteChain(ChainBase):
|
|||||||
title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \
|
title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \
|
||||||
f"\n- 禁用站点:/site_disable [id]" \
|
f"\n- 禁用站点:/site_disable [id]" \
|
||||||
f"\n- 启用站点:/site_enable [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 = []
|
messages = []
|
||||||
for site in site_list:
|
for site in site_list:
|
||||||
if site.render:
|
if site.render:
|
||||||
@@ -169,9 +438,9 @@ class SiteChain(ChainBase):
|
|||||||
else:
|
else:
|
||||||
render_str = ""
|
render_str = ""
|
||||||
if site.is_active:
|
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:
|
else:
|
||||||
messages.append(f"{site.id}. {site.name}")
|
messages.append(f"{site.id}. {site.name} ⚠️")
|
||||||
# 发送列表
|
# 发送列表
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
@@ -227,12 +496,13 @@ class SiteChain(ChainBase):
|
|||||||
self.remote_list(channel, userid)
|
self.remote_list(channel, userid)
|
||||||
|
|
||||||
def update_cookie(self, site_info: Site,
|
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
|
根据用户名密码更新站点Cookie
|
||||||
:param site_info: 站点信息
|
:param site_info: 站点信息
|
||||||
:param username: 用户名
|
:param username: 用户名
|
||||||
:param password: 密码
|
:param password: 密码
|
||||||
|
:param two_step_code: 二步验证码或密钥
|
||||||
:return: (是否成功, 错误信息)
|
:return: (是否成功, 错误信息)
|
||||||
"""
|
"""
|
||||||
# 更新站点Cookie
|
# 更新站点Cookie
|
||||||
@@ -240,6 +510,7 @@ class SiteChain(ChainBase):
|
|||||||
url=site_info.url,
|
url=site_info.url,
|
||||||
username=username,
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
|
two_step_code=two_step_code,
|
||||||
proxies=settings.PROXY_HOST if site_info.proxy else None
|
proxies=settings.PROXY_HOST if site_info.proxy else None
|
||||||
)
|
)
|
||||||
if result:
|
if result:
|
||||||
@@ -257,8 +528,8 @@ class SiteChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
使用用户名密码更新站点Cookie
|
使用用户名密码更新站点Cookie
|
||||||
"""
|
"""
|
||||||
err_title = "请输入正确的命令格式:/site_cookie [id] [username] [password]," \
|
err_title = "请输入正确的命令格式:/site_cookie [id] [username] [password] [2fa_code/secret]," \
|
||||||
"[id]为站点编号,[uername]为站点用户名,[password]为站点密码"
|
"[id]为站点编号,[uername]为站点用户名,[password]为站点密码,[2fa_code/secret]为站点二步验证码或密钥"
|
||||||
if not arg_str:
|
if not arg_str:
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
@@ -266,7 +537,11 @@ class SiteChain(ChainBase):
|
|||||||
return
|
return
|
||||||
arg_str = str(arg_str).strip()
|
arg_str = str(arg_str).strip()
|
||||||
args = arg_str.split()
|
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(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
title=err_title, userid=userid))
|
title=err_title, userid=userid))
|
||||||
@@ -296,7 +571,8 @@ class SiteChain(ChainBase):
|
|||||||
# 更新Cookie
|
# 更新Cookie
|
||||||
status, msg = self.update_cookie(site_info=site_info,
|
status, msg = self.update_cookie(site_info=site_info,
|
||||||
username=username,
|
username=username,
|
||||||
password=password)
|
password=password,
|
||||||
|
two_step_code=two_step_code)
|
||||||
if not status:
|
if not status:
|
||||||
logger.error(msg)
|
logger.error(msg)
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional, Union, Tuple
|
from typing import Dict, List, Optional, Union, Tuple
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.chain.douban import DoubanChain
|
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
|
from app.chain.media import MediaChain
|
||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
from app.chain.torrents import TorrentsChain
|
from app.chain.torrents import TorrentsChain
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.context import TorrentInfo, Context, MediaInfo
|
from app.core.context import TorrentInfo, Context, MediaInfo
|
||||||
|
from app.core.event import eventmanager, Event, EventManager
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
from app.db.subscribe_oper import SubscribeOper
|
from app.db.subscribe_oper import SubscribeOper
|
||||||
|
from app.db.subscribehistory_oper import SubscribeHistoryOper
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.helper.message import MessageHelper
|
from app.helper.message import MessageHelper
|
||||||
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import NotExistMediaInfo, Notification
|
from app.schemas import NotExistMediaInfo, Notification
|
||||||
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType
|
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType
|
||||||
|
|
||||||
|
|
||||||
class SubscribeChain(ChainBase):
|
class SubscribeChain(ChainBase):
|
||||||
@@ -32,14 +35,18 @@ class SubscribeChain(ChainBase):
|
|||||||
self.downloadchain = DownloadChain()
|
self.downloadchain = DownloadChain()
|
||||||
self.searchchain = SearchChain()
|
self.searchchain = SearchChain()
|
||||||
self.subscribeoper = SubscribeOper()
|
self.subscribeoper = SubscribeOper()
|
||||||
|
self.subscribehistoryoper = SubscribeHistoryOper()
|
||||||
self.torrentschain = TorrentsChain()
|
self.torrentschain = TorrentsChain()
|
||||||
|
self.mediachain = MediaChain()
|
||||||
self.message = MessageHelper()
|
self.message = MessageHelper()
|
||||||
self.systemconfig = SystemConfigOper()
|
self.systemconfig = SystemConfigOper()
|
||||||
|
self.torrenthelper = TorrentHelper()
|
||||||
|
|
||||||
def add(self, title: str, year: str,
|
def add(self, title: str, year: str,
|
||||||
mtype: MediaType = None,
|
mtype: MediaType = None,
|
||||||
tmdbid: int = None,
|
tmdbid: int = None,
|
||||||
doubanid: str = None,
|
doubanid: str = None,
|
||||||
|
bangumiid: int = None,
|
||||||
season: int = None,
|
season: int = None,
|
||||||
channel: MessageChannel = None,
|
channel: MessageChannel = None,
|
||||||
userid: str = None,
|
userid: str = None,
|
||||||
@@ -51,32 +58,39 @@ class SubscribeChain(ChainBase):
|
|||||||
识别媒体信息并添加订阅
|
识别媒体信息并添加订阅
|
||||||
"""
|
"""
|
||||||
logger.info(f'开始添加订阅,标题:{title} ...')
|
logger.info(f'开始添加订阅,标题:{title} ...')
|
||||||
metainfo = None
|
|
||||||
mediainfo = None
|
mediainfo = None
|
||||||
if not tmdbid and doubanid:
|
metainfo = MetaInfo(title)
|
||||||
# 将豆瓣信息转换为TMDB信息
|
if year:
|
||||||
context = DoubanChain().recognize_by_doubanid(doubanid)
|
metainfo.year = year
|
||||||
if context:
|
if mtype:
|
||||||
metainfo = context.meta_info
|
metainfo.type = mtype
|
||||||
mediainfo = context.media_info
|
if season:
|
||||||
|
metainfo.type = MediaType.TV
|
||||||
|
metainfo.begin_season = season
|
||||||
|
# 识别媒体信息
|
||||||
|
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||||
|
# TMDB识别模式
|
||||||
|
if not tmdbid and doubanid:
|
||||||
|
# 将豆瓣信息转换为TMDB信息
|
||||||
|
tmdbinfo = self.mediachain.get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
|
||||||
|
if tmdbinfo:
|
||||||
|
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
|
||||||
|
else:
|
||||||
|
# 识别TMDB信息,不使用缓存
|
||||||
|
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid, cache=False)
|
||||||
else:
|
else:
|
||||||
# 识别元数据
|
# 豆瓣识别模式,不使用缓存
|
||||||
metainfo = MetaInfo(title)
|
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
|
||||||
if year:
|
if mediainfo:
|
||||||
metainfo.year = year
|
# 豆瓣标题处理
|
||||||
if mtype:
|
meta = MetaInfo(mediainfo.title)
|
||||||
metainfo.type = mtype
|
mediainfo.title = meta.name
|
||||||
if season:
|
if not season:
|
||||||
metainfo.type = MediaType.TV
|
season = meta.begin_season
|
||||||
metainfo.begin_season = season
|
|
||||||
# 识别媒体信息
|
|
||||||
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid)
|
|
||||||
# 识别失败
|
# 识别失败
|
||||||
if not mediainfo or not metainfo or not mediainfo.tmdb_id:
|
if not mediainfo:
|
||||||
logger.warn(f'未识别到媒体信息,标题:{title},tmdbid:{tmdbid}')
|
logger.warn(f'未识别到媒体信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}')
|
||||||
return None, "未识别到媒体信息"
|
return None, "未识别到媒体信息"
|
||||||
# 更新媒体图片
|
|
||||||
self.obtain_images(mediainfo=mediainfo)
|
|
||||||
# 总集数
|
# 总集数
|
||||||
if mediainfo.type == MediaType.TV:
|
if mediainfo.type == MediaType.TV:
|
||||||
if not season:
|
if not season:
|
||||||
@@ -86,16 +100,19 @@ class SubscribeChain(ChainBase):
|
|||||||
if not mediainfo.seasons:
|
if not mediainfo.seasons:
|
||||||
# 补充媒体信息
|
# 补充媒体信息
|
||||||
mediainfo = self.recognize_media(mtype=mediainfo.type,
|
mediainfo = self.recognize_media(mtype=mediainfo.type,
|
||||||
tmdbid=mediainfo.tmdb_id)
|
tmdbid=mediainfo.tmdb_id,
|
||||||
|
doubanid=mediainfo.douban_id,
|
||||||
|
bangumiid=mediainfo.bangumi_id,
|
||||||
|
cache=False)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
logger.error(f"媒体信息识别失败!")
|
logger.error(f"媒体信息识别失败!")
|
||||||
return None, "媒体信息识别失败"
|
return None, "媒体信息识别失败"
|
||||||
if not mediainfo.seasons:
|
if not mediainfo.seasons:
|
||||||
logger.error(f"媒体信息中没有季集信息,标题:{title},tmdbid:{tmdbid}")
|
logger.error(f"媒体信息中没有季集信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}")
|
||||||
return None, "媒体信息中没有季集信息"
|
return None, "媒体信息中没有季集信息"
|
||||||
total_episode = len(mediainfo.seasons.get(season) or [])
|
total_episode = len(mediainfo.seasons.get(season) or [])
|
||||||
if not total_episode:
|
if not total_episode:
|
||||||
logger.error(f'未获取到总集数,标题:{title},tmdbid:{tmdbid}')
|
logger.error(f'未获取到总集数,标题:{title},tmdbid:{tmdbid}, doubanid:{doubanid}')
|
||||||
return None, f"未获取到第 {season} 季的总集数"
|
return None, f"未获取到第 {season} 季的总集数"
|
||||||
kwargs.update({
|
kwargs.update({
|
||||||
'total_episode': total_episode
|
'total_episode': total_episode
|
||||||
@@ -105,9 +122,26 @@ class SubscribeChain(ChainBase):
|
|||||||
kwargs.update({
|
kwargs.update({
|
||||||
'lack_episode': kwargs.get('total_episode')
|
'lack_episode': kwargs.get('total_episode')
|
||||||
})
|
})
|
||||||
|
# 更新媒体图片
|
||||||
|
self.obtain_images(mediainfo=mediainfo)
|
||||||
|
# 合并信息
|
||||||
|
if doubanid:
|
||||||
|
mediainfo.douban_id = doubanid
|
||||||
|
if bangumiid:
|
||||||
|
mediainfo.bangumi_id = bangumiid
|
||||||
# 添加订阅
|
# 添加订阅
|
||||||
sid, err_msg = self.subscribeoper.add(mediainfo, doubanid=doubanid,
|
kwargs.update({
|
||||||
season=season, username=username, **kwargs)
|
'quality': self.__get_default_subscribe_config(mediainfo.type, "quality"),
|
||||||
|
'resolution': self.__get_default_subscribe_config(mediainfo.type, "resolution"),
|
||||||
|
'effect': self.__get_default_subscribe_config(mediainfo.type, "effect"),
|
||||||
|
'include': self.__get_default_subscribe_config(mediainfo.type, "include"),
|
||||||
|
'exclude': self.__get_default_subscribe_config(mediainfo.type, "exclude"),
|
||||||
|
'best_version': self.__get_default_subscribe_config(mediainfo.type, "best_version"),
|
||||||
|
'search_imdbid': self.__get_default_subscribe_config(mediainfo.type, "search_imdbid"),
|
||||||
|
'sites': self.__get_default_subscribe_config(mediainfo.type, "sites") or None,
|
||||||
|
'save_path': self.__get_default_subscribe_config(mediainfo.type, "save_path"),
|
||||||
|
})
|
||||||
|
sid, err_msg = self.subscribeoper.add(mediainfo=mediainfo, season=season, username=username, **kwargs)
|
||||||
if not sid:
|
if not sid:
|
||||||
logger.error(f'{mediainfo.title_year} {err_msg}')
|
logger.error(f'{mediainfo.title_year} {err_msg}')
|
||||||
if not exist_ok and message:
|
if not exist_ok and message:
|
||||||
@@ -121,16 +155,21 @@ class SubscribeChain(ChainBase):
|
|||||||
userid=userid))
|
userid=userid))
|
||||||
elif message:
|
elif message:
|
||||||
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
|
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
|
||||||
if username or userid:
|
if username:
|
||||||
text = f"评分:{mediainfo.vote_average},来自用户:{username or userid}"
|
text = f"评分:{mediainfo.vote_average},来自用户:{username}"
|
||||||
else:
|
else:
|
||||||
text = f"评分:{mediainfo.vote_average}"
|
text = f"评分:{mediainfo.vote_average}"
|
||||||
# 广而告之
|
# 群发
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||||
mtype=NotificationType.Subscribe,
|
|
||||||
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
||||||
text=text,
|
text=text,
|
||||||
image=mediainfo.get_message_image()))
|
image=mediainfo.get_message_image()))
|
||||||
|
# 发送事件
|
||||||
|
EventManager().send_event(EventType.SubscribeAdded, {
|
||||||
|
"subscribe_id": sid,
|
||||||
|
"username": username,
|
||||||
|
"mediainfo": mediainfo.to_dict(),
|
||||||
|
})
|
||||||
# 返回结果
|
# 返回结果
|
||||||
return sid, ""
|
return sid, ""
|
||||||
|
|
||||||
@@ -139,6 +178,7 @@ class SubscribeChain(ChainBase):
|
|||||||
判断订阅是否已存在
|
判断订阅是否已存在
|
||||||
"""
|
"""
|
||||||
if self.subscribeoper.exists(tmdbid=mediainfo.tmdb_id,
|
if self.subscribeoper.exists(tmdbid=mediainfo.tmdb_id,
|
||||||
|
doubanid=mediainfo.douban_id,
|
||||||
season=meta.begin_season if meta else None):
|
season=meta.begin_season if meta else None):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -157,6 +197,7 @@ class SubscribeChain(ChainBase):
|
|||||||
subscribes = self.subscribeoper.list(state)
|
subscribes = self.subscribeoper.list(state)
|
||||||
# 遍历订阅
|
# 遍历订阅
|
||||||
for subscribe in subscribes:
|
for subscribe in subscribes:
|
||||||
|
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||||
# 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间
|
# 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间
|
||||||
if subscribe.date:
|
if subscribe.date:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
@@ -179,9 +220,13 @@ class SubscribeChain(ChainBase):
|
|||||||
meta.begin_season = subscribe.season or None
|
meta.begin_season = subscribe.season or None
|
||||||
meta.type = MediaType(subscribe.type)
|
meta.type = MediaType(subscribe.type)
|
||||||
# 识别媒体信息
|
# 识别媒体信息
|
||||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, tmdbid=subscribe.tmdbid)
|
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||||
|
tmdbid=subscribe.tmdbid,
|
||||||
|
doubanid=subscribe.doubanid,
|
||||||
|
cache=False)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
logger.warn(f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid}')
|
logger.warn(
|
||||||
|
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 非洗版状态
|
# 非洗版状态
|
||||||
@@ -203,7 +248,7 @@ class SubscribeChain(ChainBase):
|
|||||||
exist_flag = False
|
exist_flag = False
|
||||||
if meta.type == MediaType.TV:
|
if meta.type == MediaType.TV:
|
||||||
no_exists = {
|
no_exists = {
|
||||||
subscribe.tmdbid: {
|
mediakey: {
|
||||||
subscribe.season: NotExistMediaInfo(
|
subscribe.season: NotExistMediaInfo(
|
||||||
season=subscribe.season,
|
season=subscribe.season,
|
||||||
episodes=[],
|
episodes=[],
|
||||||
@@ -217,31 +262,28 @@ class SubscribeChain(ChainBase):
|
|||||||
# 已存在
|
# 已存在
|
||||||
if exist_flag:
|
if exist_flag:
|
||||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
||||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 电视剧订阅处理缺失集
|
# 电视剧订阅处理缺失集
|
||||||
if meta.type == MediaType.TV:
|
if meta.type == MediaType.TV:
|
||||||
# 使用订阅的总集数和开始集数替换no_exists
|
# 实际缺失集与订阅开始结束集范围进行整合
|
||||||
no_exists = self.__get_subscribe_no_exits(
|
no_exists = self.__get_subscribe_no_exits(
|
||||||
no_exists=no_exists,
|
no_exists=no_exists,
|
||||||
tmdb_id=mediainfo.tmdb_id,
|
mediakey=mediakey,
|
||||||
begin_season=meta.begin_season,
|
begin_season=meta.begin_season,
|
||||||
total_episode=subscribe.total_episode,
|
total_episode=subscribe.total_episode,
|
||||||
start_episode=subscribe.start_episode,
|
start_episode=subscribe.start_episode,
|
||||||
|
|
||||||
)
|
)
|
||||||
# 打印缺失集信息
|
# 打印汇总缺失集信息
|
||||||
if no_exists and no_exists.get(subscribe.tmdbid):
|
if no_exists and no_exists.get(mediakey):
|
||||||
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
|
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
|
||||||
if no_exists_info:
|
if no_exists_info:
|
||||||
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
||||||
|
|
||||||
# 站点范围
|
# 站点范围
|
||||||
if subscribe.sites:
|
sites = self.get_sub_sites(subscribe)
|
||||||
sites = json.loads(subscribe.sites)
|
|
||||||
else:
|
|
||||||
sites = None
|
|
||||||
|
|
||||||
# 优先级过滤规则
|
# 优先级过滤规则
|
||||||
if subscribe.best_version:
|
if subscribe.best_version:
|
||||||
@@ -258,16 +300,15 @@ class SubscribeChain(ChainBase):
|
|||||||
no_exists=no_exists,
|
no_exists=no_exists,
|
||||||
sites=sites,
|
sites=sites,
|
||||||
priority_rule=priority_rule,
|
priority_rule=priority_rule,
|
||||||
filter_rule=filter_rule)
|
filter_rule=filter_rule,
|
||||||
|
area="imdbid" if subscribe.search_imdbid else "title")
|
||||||
if not contexts:
|
if not contexts:
|
||||||
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||||
if meta.type == MediaType.TV:
|
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||||
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
mediainfo=mediainfo, lefts=no_exists)
|
||||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe,
|
|
||||||
meta=meta, mediainfo=mediainfo)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 过滤
|
# 过滤搜索结果
|
||||||
matched_contexts = []
|
matched_contexts = []
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
torrent_meta = context.meta_info
|
torrent_meta = context.meta_info
|
||||||
@@ -286,41 +327,31 @@ class SubscribeChain(ChainBase):
|
|||||||
if torrent_meta.episode_list:
|
if torrent_meta.episode_list:
|
||||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||||
continue
|
continue
|
||||||
# 优先级小于已下载优先级的不要
|
# 洗版时,优先级小于已下载优先级的不要
|
||||||
if subscribe.current_priority \
|
if subscribe.current_priority \
|
||||||
and torrent_info.pri_order < subscribe.current_priority:
|
and torrent_info.pri_order < subscribe.current_priority:
|
||||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于已下载优先级')
|
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于已下载优先级')
|
||||||
continue
|
continue
|
||||||
matched_contexts.append(context)
|
matched_contexts.append(context)
|
||||||
|
|
||||||
if not matched_contexts:
|
if not matched_contexts:
|
||||||
logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源')
|
logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源')
|
||||||
# 非洗版未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
mediainfo=mediainfo, lefts=no_exists)
|
||||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe,
|
|
||||||
meta=meta, mediainfo=mediainfo)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 自动下载
|
# 自动下载
|
||||||
downloads, lefts = self.downloadchain.batch_download(contexts=matched_contexts,
|
downloads, lefts = self.downloadchain.batch_download(
|
||||||
no_exists=no_exists, username=subscribe.username)
|
contexts=matched_contexts,
|
||||||
# 更新已经下载的集数
|
no_exists=no_exists,
|
||||||
if downloads \
|
userid=subscribe.username,
|
||||||
and meta.type == MediaType.TV \
|
username=subscribe.username,
|
||||||
and not subscribe.best_version:
|
save_path=subscribe.save_path
|
||||||
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
|
)
|
||||||
|
|
||||||
if downloads and not lefts:
|
# 判断是否应完成订阅
|
||||||
# 判断是否应完成订阅
|
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
downloads=downloads, lefts=lefts)
|
||||||
mediainfo=mediainfo, downloads=downloads)
|
|
||||||
else:
|
|
||||||
# 未完成下载
|
|
||||||
logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...')
|
|
||||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
|
||||||
# 更新订阅剩余集数和时间
|
|
||||||
update_date = True if downloads else False
|
|
||||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, meta=meta,
|
|
||||||
mediainfo=mediainfo, update_date=update_date)
|
|
||||||
|
|
||||||
# 手动触发时发送系统消息
|
# 手动触发时发送系统消息
|
||||||
if manual:
|
if manual:
|
||||||
@@ -329,35 +360,90 @@ class SubscribeChain(ChainBase):
|
|||||||
else:
|
else:
|
||||||
self.message.put('所有订阅搜索完成!')
|
self.message.put('所有订阅搜索完成!')
|
||||||
|
|
||||||
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaInfo,
|
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaInfo,
|
||||||
mediainfo: MediaInfo, downloads: List[Context] = None):
|
mediainfo: MediaInfo, downloads: List[Context]):
|
||||||
"""
|
"""
|
||||||
判断是否应完成订阅
|
更新订阅已下载资源的优先级
|
||||||
"""
|
"""
|
||||||
|
if not downloads:
|
||||||
|
return
|
||||||
if not subscribe.best_version:
|
if not subscribe.best_version:
|
||||||
# 全部下载完成
|
return
|
||||||
logger.info(f'{mediainfo.title_year} 完成订阅')
|
# 当前下载资源的优先级
|
||||||
|
priority = max([item.torrent_info.pri_order for item in downloads])
|
||||||
|
if priority == 100:
|
||||||
|
logger.info(f'{mediainfo.title_year} 洗版完成,删除订阅')
|
||||||
|
# 新增订阅历史
|
||||||
|
self.subscribehistoryoper.add(**subscribe.to_dict())
|
||||||
|
# 删除订阅
|
||||||
self.subscribeoper.delete(subscribe.id)
|
self.subscribeoper.delete(subscribe.id)
|
||||||
# 发送通知
|
# 发送通知
|
||||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||||
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
|
title=f'{mediainfo.title_year} {meta.season} 已洗版完成',
|
||||||
image=mediainfo.get_message_image()))
|
image=mediainfo.get_message_image()))
|
||||||
elif downloads:
|
# 发送事件
|
||||||
# 当前下载资源的优先级
|
EventManager().send_event(EventType.SubscribeComplete, {
|
||||||
priority = max([item.torrent_info.pri_order for item in downloads])
|
"subscribe_id": subscribe.id,
|
||||||
if priority == 100:
|
"mediainfo": mediainfo.to_dict(),
|
||||||
logger.info(f'{mediainfo.title_year} 洗版完成,删除订阅')
|
})
|
||||||
|
else:
|
||||||
|
# 正在洗版,更新资源优先级
|
||||||
|
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级为 {priority}')
|
||||||
|
self.subscribeoper.update(subscribe.id, {
|
||||||
|
"current_priority": priority
|
||||||
|
})
|
||||||
|
|
||||||
|
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaInfo, mediainfo: MediaInfo,
|
||||||
|
downloads: List[Context] = None,
|
||||||
|
lefts: Dict[Union[int | str], Dict[int, NotExistMediaInfo]] = None,
|
||||||
|
force: bool = False):
|
||||||
|
"""
|
||||||
|
判断是否应完成订阅
|
||||||
|
"""
|
||||||
|
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||||
|
# 是否有剩余集
|
||||||
|
no_lefts = not lefts or not lefts.get(mediakey)
|
||||||
|
# 是否完成订阅
|
||||||
|
if not subscribe.best_version:
|
||||||
|
# 非洗板
|
||||||
|
if ((no_lefts and meta.type == MediaType.TV)
|
||||||
|
or (downloads and meta.type == MediaType.MOVIE)
|
||||||
|
or force):
|
||||||
|
# 全部下载完成
|
||||||
|
logger.info(f'{mediainfo.title_year} 完成订阅')
|
||||||
|
# 新增订阅历史
|
||||||
|
self.subscribehistoryoper.add(**subscribe.to_dict())
|
||||||
|
# 删除订阅
|
||||||
self.subscribeoper.delete(subscribe.id)
|
self.subscribeoper.delete(subscribe.id)
|
||||||
# 发送通知
|
# 发送通知
|
||||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||||
title=f'{mediainfo.title_year} {meta.season} 已洗版完成',
|
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
|
||||||
image=mediainfo.get_message_image()))
|
image=mediainfo.get_message_image()))
|
||||||
else:
|
# 发送事件
|
||||||
# 正在洗版,更新资源优先级
|
EventManager().send_event(EventType.SubscribeComplete, {
|
||||||
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级')
|
"subscribe_id": subscribe.id,
|
||||||
self.subscribeoper.update(subscribe.id, {
|
"mediainfo": mediainfo.to_dict(),
|
||||||
"current_priority": priority
|
|
||||||
})
|
})
|
||||||
|
elif downloads and meta.type == MediaType.TV:
|
||||||
|
# 电视剧更新已下载集数
|
||||||
|
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
|
||||||
|
# 更新订阅剩余集数和时间
|
||||||
|
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||||
|
mediainfo=mediainfo, update_date=True)
|
||||||
|
else:
|
||||||
|
# 未下载到内容且不完整
|
||||||
|
logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...')
|
||||||
|
if meta.type == MediaType.TV:
|
||||||
|
# 更新订阅剩余集数
|
||||||
|
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||||
|
mediainfo=mediainfo, update_date=False)
|
||||||
|
elif downloads:
|
||||||
|
# 洗板,下载到了内容,更新资源优先级
|
||||||
|
self.update_subscribe_priority(subscribe=subscribe, meta=meta,
|
||||||
|
mediainfo=mediainfo, downloads=downloads)
|
||||||
|
else:
|
||||||
|
# 洗版,未下载到内容
|
||||||
|
logger.info(f'{mediainfo.title_year} 继续洗版 ...')
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
"""
|
"""
|
||||||
@@ -371,6 +457,15 @@ class SubscribeChain(ChainBase):
|
|||||||
self.torrentschain.refresh(sites=sites)
|
self.torrentschain.refresh(sites=sites)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_sub_sites(self, subscribe: Subscribe) -> List[int]:
|
||||||
|
"""
|
||||||
|
获取订阅中涉及的站点清单
|
||||||
|
"""
|
||||||
|
if subscribe.sites:
|
||||||
|
return json.loads(subscribe.sites)
|
||||||
|
# 默认站点
|
||||||
|
return self.systemconfig.get(SystemConfigKey.RssSites) or []
|
||||||
|
|
||||||
def get_subscribed_sites(self) -> Optional[List[int]]:
|
def get_subscribed_sites(self) -> Optional[List[int]]:
|
||||||
"""
|
"""
|
||||||
获取订阅中涉及的所有站点清单(节约资源)
|
获取订阅中涉及的所有站点清单(节约资源)
|
||||||
@@ -383,13 +478,8 @@ class SubscribeChain(ChainBase):
|
|||||||
ret_sites = []
|
ret_sites = []
|
||||||
# 刷新订阅选中的Rss站点
|
# 刷新订阅选中的Rss站点
|
||||||
for subscribe in subscribes:
|
for subscribe in subscribes:
|
||||||
# 如果有一个订阅没有选择站点,则刷新所有订阅站点
|
|
||||||
if not subscribe.sites:
|
|
||||||
return []
|
|
||||||
# 刷新选中的站点
|
# 刷新选中的站点
|
||||||
sub_sites = json.loads(subscribe.sites)
|
ret_sites.extend(self.get_sub_sites(subscribe))
|
||||||
if sub_sites:
|
|
||||||
ret_sites.extend(sub_sites)
|
|
||||||
# 去重
|
# 去重
|
||||||
if ret_sites:
|
if ret_sites:
|
||||||
ret_sites = list(set(ret_sites))
|
ret_sites = list(set(ret_sites))
|
||||||
@@ -398,64 +488,20 @@ class SubscribeChain(ChainBase):
|
|||||||
|
|
||||||
def get_filter_rule(self, subscribe: Subscribe):
|
def get_filter_rule(self, subscribe: Subscribe):
|
||||||
"""
|
"""
|
||||||
获取订阅过滤规则,没有则返回默认规则
|
获取订阅过滤规则,同时组合默认规则
|
||||||
"""
|
"""
|
||||||
# 默认过滤规则
|
# 默认过滤规则
|
||||||
if (subscribe.include
|
default_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
||||||
or subscribe.exclude
|
return {
|
||||||
or subscribe.quality
|
"include": subscribe.include or default_rule.get("include"),
|
||||||
or subscribe.resolution
|
"exclude": subscribe.exclude or default_rule.get("exclude"),
|
||||||
or subscribe.effect):
|
"quality": subscribe.quality or default_rule.get("quality"),
|
||||||
return {
|
"resolution": subscribe.resolution or default_rule.get("resolution"),
|
||||||
"include": subscribe.include,
|
"effect": subscribe.effect or default_rule.get("effect"),
|
||||||
"exclude": subscribe.exclude,
|
"tv_size": default_rule.get("tv_size"),
|
||||||
"quality": subscribe.quality,
|
"movie_size": default_rule.get("movie_size"),
|
||||||
"resolution": subscribe.resolution,
|
"min_seeders": default_rule.get("min_seeders"),
|
||||||
"effect": subscribe.effect,
|
}
|
||||||
}
|
|
||||||
# 订阅默认过滤规则
|
|
||||||
return self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_filter_rule(torrent_info: TorrentInfo, filter_rule: Dict[str, str]) -> bool:
|
|
||||||
"""
|
|
||||||
检查种子是否匹配订阅过滤规则
|
|
||||||
"""
|
|
||||||
if not filter_rule:
|
|
||||||
return True
|
|
||||||
# 包含
|
|
||||||
include = filter_rule.get("include")
|
|
||||||
if include:
|
|
||||||
if not re.search(r"%s" % include,
|
|
||||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
|
||||||
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
|
|
||||||
return False
|
|
||||||
# 排除
|
|
||||||
exclude = filter_rule.get("exclude")
|
|
||||||
if exclude:
|
|
||||||
if re.search(r"%s" % exclude,
|
|
||||||
f"{torrent_info.title} {torrent_info.description}", 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
|
|
||||||
return True
|
|
||||||
|
|
||||||
def match(self, torrents: Dict[str, List[Context]]):
|
def match(self, torrents: Dict[str, List[Context]]):
|
||||||
"""
|
"""
|
||||||
@@ -469,15 +515,20 @@ class SubscribeChain(ChainBase):
|
|||||||
# 遍历订阅
|
# 遍历订阅
|
||||||
for subscribe in subscribes:
|
for subscribe in subscribes:
|
||||||
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
|
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
|
||||||
|
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||||
# 生成元数据
|
# 生成元数据
|
||||||
meta = MetaInfo(subscribe.name)
|
meta = MetaInfo(subscribe.name)
|
||||||
meta.year = subscribe.year
|
meta.year = subscribe.year
|
||||||
meta.begin_season = subscribe.season or None
|
meta.begin_season = subscribe.season or None
|
||||||
meta.type = MediaType(subscribe.type)
|
meta.type = MediaType(subscribe.type)
|
||||||
# 识别媒体信息
|
# 识别媒体信息
|
||||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, tmdbid=subscribe.tmdbid)
|
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||||
|
tmdbid=subscribe.tmdbid,
|
||||||
|
doubanid=subscribe.doubanid,
|
||||||
|
cache=False)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
logger.warn(f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid}')
|
logger.warn(
|
||||||
|
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||||
continue
|
continue
|
||||||
# 非洗版
|
# 非洗版
|
||||||
if not subscribe.best_version:
|
if not subscribe.best_version:
|
||||||
@@ -498,7 +549,7 @@ class SubscribeChain(ChainBase):
|
|||||||
exist_flag = False
|
exist_flag = False
|
||||||
if meta.type == MediaType.TV:
|
if meta.type == MediaType.TV:
|
||||||
no_exists = {
|
no_exists = {
|
||||||
subscribe.tmdbid: {
|
mediakey: {
|
||||||
subscribe.season: NotExistMediaInfo(
|
subscribe.season: NotExistMediaInfo(
|
||||||
season=subscribe.season,
|
season=subscribe.season,
|
||||||
episodes=[],
|
episodes=[],
|
||||||
@@ -512,23 +563,23 @@ class SubscribeChain(ChainBase):
|
|||||||
# 已存在
|
# 已存在
|
||||||
if exist_flag:
|
if exist_flag:
|
||||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
||||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 电视剧订阅
|
# 电视剧订阅
|
||||||
if meta.type == MediaType.TV:
|
if meta.type == MediaType.TV:
|
||||||
# 使用订阅的总集数和开始集数替换no_exists
|
# 整合实际缺失集与订阅开始集结束集
|
||||||
no_exists = self.__get_subscribe_no_exits(
|
no_exists = self.__get_subscribe_no_exits(
|
||||||
no_exists=no_exists,
|
no_exists=no_exists,
|
||||||
tmdb_id=mediainfo.tmdb_id,
|
mediakey=mediakey,
|
||||||
begin_season=meta.begin_season,
|
begin_season=meta.begin_season,
|
||||||
total_episode=subscribe.total_episode,
|
total_episode=subscribe.total_episode,
|
||||||
start_episode=subscribe.start_episode,
|
start_episode=subscribe.start_episode,
|
||||||
|
|
||||||
)
|
)
|
||||||
# 打印缺失集信息
|
# 打印汇总缺失集信息
|
||||||
if no_exists and no_exists.get(subscribe.tmdbid):
|
if no_exists and no_exists.get(mediakey):
|
||||||
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
|
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
|
||||||
if no_exists_info:
|
if no_exists_info:
|
||||||
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
||||||
|
|
||||||
@@ -538,15 +589,42 @@ class SubscribeChain(ChainBase):
|
|||||||
# 遍历缓存种子
|
# 遍历缓存种子
|
||||||
_match_context = []
|
_match_context = []
|
||||||
for domain, contexts in torrents.items():
|
for domain, contexts in torrents.items():
|
||||||
|
logger.info(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
# 检查是否匹配
|
# 检查是否匹配
|
||||||
torrent_meta = context.meta_info
|
torrent_meta = context.meta_info
|
||||||
torrent_mediainfo = context.media_info
|
torrent_mediainfo = context.media_info
|
||||||
torrent_info = context.torrent_info
|
torrent_info = context.torrent_info
|
||||||
# 比对TMDBID和类型
|
# 如果识别了媒体信息,则比对TMDBID和类型
|
||||||
if torrent_mediainfo.tmdb_id != mediainfo.tmdb_id \
|
if torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id:
|
||||||
or torrent_mediainfo.type != mediainfo.type:
|
# 直接比对媒体信息
|
||||||
continue
|
if torrent_mediainfo.type != mediainfo.type:
|
||||||
|
continue
|
||||||
|
if torrent_mediainfo.tmdb_id \
|
||||||
|
and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id:
|
||||||
|
continue
|
||||||
|
if torrent_mediainfo.douban_id \
|
||||||
|
and torrent_mediainfo.douban_id != mediainfo.douban_id:
|
||||||
|
continue
|
||||||
|
logger.info(
|
||||||
|
f'{mediainfo.title_year} 通过媒体信ID匹配到资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||||
|
else:
|
||||||
|
# 没有torrent_mediainfo媒体信息,按标题匹配
|
||||||
|
manual_match = False
|
||||||
|
# 比对词条指定的tmdbid
|
||||||
|
if torrent_meta.tmdbid or torrent_meta.doubanid:
|
||||||
|
if torrent_meta.tmdbid and torrent_meta.tmdbid != mediainfo.tmdb_id:
|
||||||
|
continue
|
||||||
|
if torrent_meta.doubanid and torrent_meta.doubanid != mediainfo.douban_id:
|
||||||
|
continue
|
||||||
|
manual_match = True
|
||||||
|
if not manual_match:
|
||||||
|
# 没有指定tmdbid,按标题匹配
|
||||||
|
if not self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||||
|
torrent_meta=torrent_meta,
|
||||||
|
torrent=torrent_info,
|
||||||
|
logerror=False):
|
||||||
|
continue
|
||||||
# 优先级过滤规则
|
# 优先级过滤规则
|
||||||
if subscribe.best_version:
|
if subscribe.best_version:
|
||||||
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
||||||
@@ -560,12 +638,13 @@ class SubscribeChain(ChainBase):
|
|||||||
# 不符合过滤规则
|
# 不符合过滤规则
|
||||||
logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
|
logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 不在订阅站点范围的不处理
|
# 不在订阅站点范围的不处理
|
||||||
if subscribe.sites:
|
sub_sites = self.get_sub_sites(subscribe)
|
||||||
sub_sites = json.loads(subscribe.sites)
|
if sub_sites and torrent_info.site not in sub_sites:
|
||||||
if sub_sites and torrent_info.site not in sub_sites:
|
logger.info(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
|
||||||
logger.info(f"{torrent_info.title} 不符合 {torrent_mediainfo.title_year} 订阅站点要求")
|
continue
|
||||||
continue
|
|
||||||
# 如果是电视剧
|
# 如果是电视剧
|
||||||
if torrent_mediainfo.type == MediaType.TV:
|
if torrent_mediainfo.type == MediaType.TV:
|
||||||
# 有多季的不要
|
# 有多季的不要
|
||||||
@@ -583,9 +662,9 @@ class SubscribeChain(ChainBase):
|
|||||||
# 非洗版
|
# 非洗版
|
||||||
if not subscribe.best_version:
|
if not subscribe.best_version:
|
||||||
# 不是缺失的剧集不要
|
# 不是缺失的剧集不要
|
||||||
if no_exists and no_exists.get(subscribe.tmdbid):
|
if no_exists and no_exists.get(mediakey):
|
||||||
# 缺失集
|
# 缺失集
|
||||||
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
|
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
|
||||||
if no_exists_info:
|
if no_exists_info:
|
||||||
# 是否有交集
|
# 是否有交集
|
||||||
if no_exists_info.episodes and \
|
if no_exists_info.episodes and \
|
||||||
@@ -609,39 +688,39 @@ class SubscribeChain(ChainBase):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 过滤规则
|
# 过滤规则
|
||||||
if not self.check_filter_rule(torrent_info=torrent_info,
|
if not self.torrenthelper.filter_torrent(torrent_info=torrent_info,
|
||||||
filter_rule=filter_rule):
|
filter_rule=filter_rule,
|
||||||
|
mediainfo=torrent_mediainfo):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 洗版时,优先级小于已下载优先级的不要
|
||||||
|
if subscribe.best_version:
|
||||||
|
if subscribe.current_priority \
|
||||||
|
and torrent_info.pri_order < subscribe.current_priority:
|
||||||
|
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于已下载优先级')
|
||||||
|
continue
|
||||||
|
|
||||||
# 匹配成功
|
# 匹配成功
|
||||||
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
|
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
|
||||||
_match_context.append(context)
|
_match_context.append(context)
|
||||||
|
|
||||||
# 开始下载
|
if not _match_context:
|
||||||
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
|
# 未匹配到资源
|
||||||
if _match_context:
|
logger.info(f'{mediainfo.title_year} 未匹配到符合条件的资源')
|
||||||
# 批量择优下载
|
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||||
downloads, lefts = self.downloadchain.batch_download(contexts=_match_context, no_exists=no_exists,
|
mediainfo=mediainfo, lefts=no_exists)
|
||||||
username=subscribe.username)
|
continue
|
||||||
# 更新已经下载的集数
|
|
||||||
if downloads and meta.type == MediaType.TV:
|
|
||||||
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
|
|
||||||
|
|
||||||
if downloads and not lefts:
|
# 开始批量择优下载
|
||||||
# 判断是否要完成订阅
|
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
|
||||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
downloads, lefts = self.downloadchain.batch_download(contexts=_match_context,
|
||||||
mediainfo=mediainfo, downloads=downloads)
|
no_exists=no_exists,
|
||||||
else:
|
userid=subscribe.username,
|
||||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
username=subscribe.username,
|
||||||
update_date = True if downloads else False
|
save_path=subscribe.save_path)
|
||||||
# 未完成下载,计算剩余集数
|
# 判断是否要完成订阅
|
||||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, meta=meta,
|
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||||
mediainfo=mediainfo, update_date=update_date)
|
downloads=downloads, lefts=lefts)
|
||||||
else:
|
|
||||||
if meta.type == MediaType.TV:
|
|
||||||
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
|
||||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe,
|
|
||||||
meta=meta, mediainfo=mediainfo)
|
|
||||||
|
|
||||||
def check(self):
|
def check(self):
|
||||||
"""
|
"""
|
||||||
@@ -654,20 +733,24 @@ class SubscribeChain(ChainBase):
|
|||||||
return
|
return
|
||||||
# 遍历订阅
|
# 遍历订阅
|
||||||
for subscribe in subscribes:
|
for subscribe in subscribes:
|
||||||
logger.info(f'开始检查订阅:{subscribe.name} ...')
|
logger.info(f'开始更新订阅元数据:{subscribe.name} ...')
|
||||||
# 生成元数据
|
# 生成元数据
|
||||||
meta = MetaInfo(subscribe.name)
|
meta = MetaInfo(subscribe.name)
|
||||||
meta.year = subscribe.year
|
meta.year = subscribe.year
|
||||||
meta.begin_season = subscribe.season or None
|
meta.begin_season = subscribe.season or None
|
||||||
meta.type = MediaType(subscribe.type)
|
meta.type = MediaType(subscribe.type)
|
||||||
# 识别媒体信息
|
# 识别媒体信息
|
||||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, tmdbid=subscribe.tmdbid)
|
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||||
|
tmdbid=subscribe.tmdbid,
|
||||||
|
doubanid=subscribe.doubanid,
|
||||||
|
cache=False)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
logger.warn(f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid}')
|
logger.warn(
|
||||||
|
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||||
continue
|
continue
|
||||||
# 对于电视剧,获取当前季的总集数
|
# 对于电视剧,获取当前季的总集数
|
||||||
episodes = mediainfo.seasons.get(subscribe.season) or []
|
episodes = mediainfo.seasons.get(subscribe.season) or []
|
||||||
if len(episodes) > (subscribe.total_episode or 0):
|
if not subscribe.manual_total_episode and len(episodes):
|
||||||
total_episode = len(episodes)
|
total_episode = len(episodes)
|
||||||
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
|
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -688,7 +771,7 @@ class SubscribeChain(ChainBase):
|
|||||||
"total_episode": total_episode,
|
"total_episode": total_episode,
|
||||||
"lack_episode": lack_episode
|
"lack_episode": lack_episode
|
||||||
})
|
})
|
||||||
logger.info(f'订阅 {subscribe.name} 更新完成')
|
logger.info(f'{subscribe.name} 订阅元数据更新完成')
|
||||||
|
|
||||||
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
|
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
|
||||||
"""
|
"""
|
||||||
@@ -705,7 +788,11 @@ class SubscribeChain(ChainBase):
|
|||||||
mediainfo = context.media_info
|
mediainfo = context.media_info
|
||||||
if mediainfo.type != MediaType.TV:
|
if mediainfo.type != MediaType.TV:
|
||||||
continue
|
continue
|
||||||
if mediainfo.tmdb_id != subscribe.tmdbid:
|
if subscribe.tmdbid and mediainfo.tmdb_id \
|
||||||
|
and mediainfo.tmdb_id != subscribe.tmdbid:
|
||||||
|
continue
|
||||||
|
if subscribe.doubanid and mediainfo.douban_id \
|
||||||
|
and mediainfo.douban_id != subscribe.doubanid:
|
||||||
continue
|
continue
|
||||||
episodes = meta.episode_list
|
episodes = meta.episode_list
|
||||||
if not episodes:
|
if not episodes:
|
||||||
@@ -731,15 +818,17 @@ class SubscribeChain(ChainBase):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __update_lack_episodes(self, lefts: Dict[int, Dict[int, NotExistMediaInfo]],
|
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
|
||||||
subscribe: Subscribe,
|
subscribe: Subscribe,
|
||||||
meta: MetaBase,
|
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo,
|
||||||
update_date: bool = False):
|
update_date: bool = False):
|
||||||
"""
|
"""
|
||||||
更新订阅剩余集数
|
更新订阅剩余集数
|
||||||
"""
|
"""
|
||||||
left_seasons = lefts.get(mediainfo.tmdb_id)
|
if not lefts:
|
||||||
|
return
|
||||||
|
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||||
|
left_seasons = lefts.get(mediakey)
|
||||||
if left_seasons:
|
if left_seasons:
|
||||||
for season_info in left_seasons.values():
|
for season_info in left_seasons.values():
|
||||||
season = season_info.season
|
season = season_info.season
|
||||||
@@ -760,9 +849,6 @@ class SubscribeChain(ChainBase):
|
|||||||
self.subscribeoper.update(subscribe.id, {
|
self.subscribeoper.update(subscribe.id, {
|
||||||
"lack_episode": lack_episode
|
"lack_episode": lack_episode
|
||||||
})
|
})
|
||||||
else:
|
|
||||||
# 判断是否应完成订阅
|
|
||||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
|
||||||
|
|
||||||
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
|
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||||
"""
|
"""
|
||||||
@@ -780,14 +866,12 @@ class SubscribeChain(ChainBase):
|
|||||||
messages = []
|
messages = []
|
||||||
for subscribe in subscribes:
|
for subscribe in subscribes:
|
||||||
if subscribe.type == MediaType.MOVIE.value:
|
if subscribe.type == MediaType.MOVIE.value:
|
||||||
tmdb_link = f"https://www.themoviedb.org/movie/{subscribe.tmdbid}"
|
messages.append(f"{subscribe.id}. {subscribe.name}({subscribe.year})")
|
||||||
messages.append(f"{subscribe.id}. [{subscribe.name}({subscribe.year})]({tmdb_link})")
|
|
||||||
else:
|
else:
|
||||||
tmdb_link = f"https://www.themoviedb.org/tv/{subscribe.tmdbid}"
|
messages.append(f"{subscribe.id}. {subscribe.name}({subscribe.year})"
|
||||||
messages.append(f"{subscribe.id}. [{subscribe.name}({subscribe.year})]({tmdb_link}) "
|
|
||||||
f"第{subscribe.season}季 "
|
f"第{subscribe.season}季 "
|
||||||
f"_{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
|
f"[{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
|
||||||
f"/{subscribe.total_episode}_")
|
f"/{subscribe.total_episode}]")
|
||||||
# 发送列表
|
# 发送列表
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel,
|
||||||
title=title, text='\n'.join(messages), userid=userid))
|
title=title, text='\n'.join(messages), userid=userid))
|
||||||
@@ -818,25 +902,25 @@ class SubscribeChain(ChainBase):
|
|||||||
self.remote_list(channel, userid)
|
self.remote_list(channel, userid)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_subscribe_no_exits(no_exists: Dict[int, Dict[int, NotExistMediaInfo]],
|
def __get_subscribe_no_exits(no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
|
||||||
tmdb_id: int,
|
mediakey: Union[str, int],
|
||||||
begin_season: int,
|
begin_season: int,
|
||||||
total_episode: int,
|
total_episode: int,
|
||||||
start_episode: int):
|
start_episode: int):
|
||||||
"""
|
"""
|
||||||
根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数
|
根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数
|
||||||
:param no_exists: 缺失季集列表
|
:param no_exists: 缺失季集列表
|
||||||
:param tmdb_id: TMDB ID
|
:param mediakey: TMDB ID或豆瓣ID
|
||||||
:param begin_season: 开始季
|
:param begin_season: 开始季
|
||||||
:param total_episode: 订阅设定总集数
|
:param total_episode: 订阅设定总集数
|
||||||
:param start_episode: 订阅设定开始集数
|
:param start_episode: 订阅设定开始集数
|
||||||
"""
|
"""
|
||||||
# 使用订阅的总集数和开始集数替换no_exists
|
# 使用订阅的总集数和开始集数替换no_exists
|
||||||
if no_exists \
|
if no_exists \
|
||||||
and no_exists.get(tmdb_id) \
|
and no_exists.get(mediakey) \
|
||||||
and (total_episode or start_episode):
|
and (total_episode or start_episode):
|
||||||
# 该季原缺失信息
|
# 该季原缺失信息
|
||||||
no_exist_season = no_exists.get(tmdb_id).get(begin_season)
|
no_exist_season = no_exists.get(mediakey).get(begin_season)
|
||||||
if no_exist_season:
|
if no_exist_season:
|
||||||
# 原集列表
|
# 原集列表
|
||||||
episode_list = no_exist_season.episodes
|
episode_list = no_exist_season.episodes
|
||||||
@@ -868,10 +952,69 @@ class SubscribeChain(ChainBase):
|
|||||||
# 与原集列表取交集
|
# 与原集列表取交集
|
||||||
episodes = list(set(episode_list).intersection(set(new_episodes)))
|
episodes = list(set(episode_list).intersection(set(new_episodes)))
|
||||||
# 更新集合
|
# 更新集合
|
||||||
no_exists[tmdb_id][begin_season] = NotExistMediaInfo(
|
no_exists[mediakey][begin_season] = NotExistMediaInfo(
|
||||||
season=begin_season,
|
season=begin_season,
|
||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
total_episode=total_episode,
|
total_episode=total_episode,
|
||||||
start_episode=start_episode
|
start_episode=start_episode
|
||||||
)
|
)
|
||||||
return no_exists
|
return no_exists
|
||||||
|
|
||||||
|
@eventmanager.register(EventType.SiteDeleted)
|
||||||
|
def remove_site(self, event: Event):
|
||||||
|
"""
|
||||||
|
从订阅中移除与站点相关的设置
|
||||||
|
"""
|
||||||
|
if not event:
|
||||||
|
return
|
||||||
|
event_data = event.event_data or {}
|
||||||
|
site_id = event_data.get("site_id")
|
||||||
|
if not site_id:
|
||||||
|
return
|
||||||
|
if site_id == "*":
|
||||||
|
# 站点被重置
|
||||||
|
SystemConfigOper().set(SystemConfigKey.RssSites, [])
|
||||||
|
for subscribe in self.subscribeoper.list():
|
||||||
|
if not subscribe.sites:
|
||||||
|
continue
|
||||||
|
self.subscribeoper.update(subscribe.id, {
|
||||||
|
"sites": ""
|
||||||
|
})
|
||||||
|
return
|
||||||
|
# 从选中的rss站点中移除
|
||||||
|
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
||||||
|
if site_id in selected_sites:
|
||||||
|
selected_sites.remove(site_id)
|
||||||
|
SystemConfigOper().set(SystemConfigKey.RssSites, selected_sites)
|
||||||
|
# 查询所有订阅
|
||||||
|
for subscribe in self.subscribeoper.list():
|
||||||
|
if not subscribe.sites:
|
||||||
|
continue
|
||||||
|
sites = json.loads(subscribe.sites) or []
|
||||||
|
if site_id not in sites:
|
||||||
|
continue
|
||||||
|
sites.remove(site_id)
|
||||||
|
self.subscribeoper.update(subscribe.id, {
|
||||||
|
"sites": json.dumps(sites)
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_default_subscribe_config(mtype: MediaType, default_config_key: str):
|
||||||
|
"""
|
||||||
|
获取默认订阅配置
|
||||||
|
"""
|
||||||
|
default_subscribe_key = None
|
||||||
|
if mtype == MediaType.TV:
|
||||||
|
default_subscribe_key = "DefaultTvSubscribeConfig"
|
||||||
|
if mtype == MediaType.MOVIE:
|
||||||
|
default_subscribe_key = "DefaultMovieSubscribeConfig"
|
||||||
|
|
||||||
|
# 默认订阅规则
|
||||||
|
if hasattr(settings, default_subscribe_key):
|
||||||
|
value = getattr(settings, default_subscribe_key)
|
||||||
|
else:
|
||||||
|
value = SystemConfigOper().get(default_subscribe_key)
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return value.get(default_config_key) or None
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
获取最新版本
|
获取最新版本
|
||||||
"""
|
"""
|
||||||
version_res = RequestUtils(proxies=settings.PROXY).get_res(
|
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||||
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
|
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
|
||||||
if version_res:
|
if version_res:
|
||||||
ver_json = version_res.json()
|
ver_json = version_res.json()
|
||||||
|
|||||||
@@ -25,17 +25,21 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
:param page: 页码
|
:param page: 页码
|
||||||
:return: 媒体信息列表
|
:return: 媒体信息列表
|
||||||
"""
|
"""
|
||||||
|
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||||
|
return None
|
||||||
return self.run_module("tmdb_discover", mtype=mtype,
|
return self.run_module("tmdb_discover", mtype=mtype,
|
||||||
sort_by=sort_by, with_genres=with_genres,
|
sort_by=sort_by, with_genres=with_genres,
|
||||||
with_original_language=with_original_language,
|
with_original_language=with_original_language,
|
||||||
page=page)
|
page=page)
|
||||||
|
|
||||||
def tmdb_trending(self, page: int = 1) -> List[dict]:
|
def tmdb_trending(self, page: int = 1) -> Optional[List[dict]]:
|
||||||
"""
|
"""
|
||||||
TMDB流行趋势
|
TMDB流行趋势
|
||||||
:param page: 第几页
|
:param page: 第几页
|
||||||
:return: TMDB信息列表
|
:return: TMDB信息列表
|
||||||
"""
|
"""
|
||||||
|
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||||
|
return None
|
||||||
return self.run_module("tmdb_trending", page=page)
|
return self.run_module("tmdb_trending", page=page)
|
||||||
|
|
||||||
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
||||||
@@ -58,28 +62,28 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
根据TMDBID查询类似电影
|
根据TMDBID查询类似电影
|
||||||
:param tmdbid: 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]:
|
def tv_similar(self, tmdbid: int) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询类似电视剧
|
根据TMDBID查询类似电视剧
|
||||||
:param tmdbid: 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]:
|
def movie_recommend(self, tmdbid: int) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询推荐电影
|
根据TMDBID查询推荐电影
|
||||||
:param tmdbid: 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]:
|
def tv_recommend(self, tmdbid: int) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询推荐电视剧
|
根据TMDBID查询推荐电视剧
|
||||||
:param tmdbid: 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]:
|
def movie_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -87,7 +91,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
:param tmdbid: TMDBID
|
:param tmdbid: TMDBID
|
||||||
:param page: 页码
|
: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]:
|
def tv_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -95,14 +99,14 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
:param tmdbid: TMDBID
|
:param tmdbid: TMDBID
|
||||||
:param page: 页码
|
: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:
|
def person_detail(self, person_id: int) -> dict:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询演职员详情
|
根据TMDBID查询演职员详情
|
||||||
:param person_id: 人物ID
|
: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]:
|
def person_credits(self, person_id: int, page: int = 1) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -110,7 +114,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
:param person_id: 人物ID
|
:param person_id: 人物ID
|
||||||
:param page: 页码
|
: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))
|
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
||||||
def get_random_wallpager(self):
|
def get_random_wallpager(self):
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import re
|
import re
|
||||||
|
import traceback
|
||||||
from typing import Dict, List, Union
|
from typing import Dict, List, Union
|
||||||
|
|
||||||
from cachetools import cached, TTLCache
|
from cachetools import cached, TTLCache
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
|
from app.chain.media import MediaChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import TorrentInfo, Context, MediaInfo
|
from app.core.context import TorrentInfo, Context, MediaInfo
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
@@ -11,9 +13,10 @@ from app.db.site_oper import SiteOper
|
|||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.helper.rss import RssHelper
|
from app.helper.rss import RssHelper
|
||||||
from app.helper.sites import SitesHelper
|
from app.helper.sites import SitesHelper
|
||||||
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import Notification
|
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.singleton import Singleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
@@ -32,6 +35,8 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
self.siteoper = SiteOper()
|
self.siteoper = SiteOper()
|
||||||
self.rsshelper = RssHelper()
|
self.rsshelper = RssHelper()
|
||||||
self.systemconfig = SystemConfigOper()
|
self.systemconfig = SystemConfigOper()
|
||||||
|
self.mediachain = MediaChain()
|
||||||
|
self.torrenthelper = TorrentHelper()
|
||||||
|
|
||||||
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
|
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||||
"""
|
"""
|
||||||
@@ -58,7 +63,16 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
return self.load_cache(self._rss_file) or {}
|
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]:
|
def browse(self, domain: str) -> List[TorrentInfo]:
|
||||||
"""
|
"""
|
||||||
浏览站点首页内容,返回种子清单,TTL缓存10分钟
|
浏览站点首页内容,返回种子清单,TTL缓存10分钟
|
||||||
@@ -71,7 +85,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
return []
|
return []
|
||||||
return self.refresh_torrents(site=site)
|
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]:
|
def rss(self, domain: str) -> List[TorrentInfo]:
|
||||||
"""
|
"""
|
||||||
获取站点RSS内容,返回种子清单,TTL缓存5分钟
|
获取站点RSS内容,返回种子清单,TTL缓存5分钟
|
||||||
@@ -85,7 +99,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
if not site.get("rss"):
|
if not site.get("rss"):
|
||||||
logger.error(f'站点 {domain} 未配置RSS地址!')
|
logger.error(f'站点 {domain} 未配置RSS地址!')
|
||||||
return []
|
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:
|
if rss_items is None:
|
||||||
# rss过期,尝试保留原配置生成新的rss
|
# rss过期,尝试保留原配置生成新的rss
|
||||||
self.__renew_rss_url(domain=domain, site=site)
|
self.__renew_rss_url(domain=domain, site=site)
|
||||||
@@ -132,6 +146,11 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
# 读取缓存
|
# 读取缓存
|
||||||
torrents_cache = self.get_torrents()
|
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()
|
indexers = self.siteshelper.get_indexers()
|
||||||
# 遍历站点缓存资源
|
# 遍历站点缓存资源
|
||||||
@@ -165,10 +184,16 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
logger.info(f'处理资源:{torrent.title} ...')
|
logger.info(f'处理资源:{torrent.title} ...')
|
||||||
# 识别
|
# 识别
|
||||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||||
|
if torrent.title != meta.org_string:
|
||||||
|
logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}')
|
||||||
|
# 使用站点种子分类,校正类型识别
|
||||||
|
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:
|
if not mediainfo:
|
||||||
logger.warn(f'未识别到媒体信息,标题:{torrent.title}')
|
logger.warn(f'{torrent.title} 未识别到媒体信息')
|
||||||
# 存储空的媒体信息
|
# 存储空的媒体信息
|
||||||
mediainfo = MediaInfo()
|
mediainfo = MediaInfo()
|
||||||
# 清理多余数据
|
# 清理多余数据
|
||||||
@@ -228,5 +253,5 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
self.post_message(
|
self.post_message(
|
||||||
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
||||||
except Exception as e:
|
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链接已过期"))
|
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ class TransferChain(ChainBase):
|
|||||||
mtype = MediaType(downloadhis.type)
|
mtype = MediaType(downloadhis.type)
|
||||||
# 按TMDBID识别
|
# 按TMDBID识别
|
||||||
mediainfo = self.recognize_media(mtype=mtype,
|
mediainfo = self.recognize_media(mtype=mtype,
|
||||||
tmdbid=downloadhis.tmdbid)
|
tmdbid=downloadhis.tmdbid,
|
||||||
|
doubanid=downloadhis.doubanid)
|
||||||
else:
|
else:
|
||||||
# 非MoviePilot下载的任务,按文件识别
|
# 非MoviePilot下载的任务,按文件识别
|
||||||
mediainfo = None
|
mediainfo = None
|
||||||
@@ -243,7 +244,7 @@ class TransferChain(ChainBase):
|
|||||||
|
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
# 识别媒体信息
|
# 识别媒体信息
|
||||||
file_mediainfo = self.recognize_media(meta=file_meta)
|
file_mediainfo = self.mediachain.recognize_by_meta(file_meta)
|
||||||
else:
|
else:
|
||||||
file_mediainfo = mediainfo
|
file_mediainfo = mediainfo
|
||||||
|
|
||||||
@@ -258,8 +259,8 @@ class TransferChain(ChainBase):
|
|||||||
)
|
)
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
mtype=NotificationType.Manual,
|
mtype=NotificationType.Manual,
|
||||||
title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
|
title=f"{file_path.name} 未识别到媒体信息,无法入库!",
|
||||||
f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
|
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
|
||||||
))
|
))
|
||||||
# 计数
|
# 计数
|
||||||
processed_num += 1
|
processed_num += 1
|
||||||
@@ -275,13 +276,15 @@ class TransferChain(ChainBase):
|
|||||||
|
|
||||||
logger.info(f"{file_path.name} 识别为:{file_mediainfo.type.value} {file_mediainfo.title_year}")
|
logger.info(f"{file_path.name} 识别为:{file_mediainfo.type.value} {file_mediainfo.title_year}")
|
||||||
|
|
||||||
# 更新媒体图片
|
|
||||||
self.obtain_images(mediainfo=file_mediainfo)
|
|
||||||
|
|
||||||
# 获取集数据
|
# 获取集数据
|
||||||
if file_mediainfo.type == MediaType.TV:
|
if file_mediainfo.type == MediaType.TV:
|
||||||
episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=file_mediainfo.tmdb_id,
|
if file_meta.begin_season is None:
|
||||||
season=file_meta.begin_season or 1)
|
file_meta.begin_season = 1
|
||||||
|
file_mediainfo.season = file_mediainfo.season or file_meta.begin_season
|
||||||
|
episodes_info = self.tmdbchain.tmdb_episodes(
|
||||||
|
tmdbid=file_mediainfo.tmdb_id,
|
||||||
|
season=file_mediainfo.season
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
episodes_info = None
|
episodes_info = None
|
||||||
|
|
||||||
@@ -357,7 +360,8 @@ class TransferChain(ChainBase):
|
|||||||
if settings.SCRAP_METADATA:
|
if settings.SCRAP_METADATA:
|
||||||
self.scrape_metadata(path=transferinfo.target_path,
|
self.scrape_metadata(path=transferinfo.target_path,
|
||||||
mediainfo=file_mediainfo,
|
mediainfo=file_mediainfo,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type,
|
||||||
|
metainfo=file_meta)
|
||||||
# 更新进度
|
# 更新进度
|
||||||
processed_num += 1
|
processed_num += 1
|
||||||
self.progress.update(value=processed_num / total_num * 100,
|
self.progress.update(value=processed_num / total_num * 100,
|
||||||
@@ -449,7 +453,7 @@ class TransferChain(ChainBase):
|
|||||||
|
|
||||||
def args_error():
|
def args_error():
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel,
|
||||||
title="请输入正确的命令格式:/redo [id] [tmdbid]|[类型],"
|
title="请输入正确的命令格式:/redo [id] [tmdbid/豆瓣id]|[类型],"
|
||||||
"[id]历史记录编号", userid=userid))
|
"[id]历史记录编号", userid=userid))
|
||||||
|
|
||||||
if not arg_str:
|
if not arg_str:
|
||||||
@@ -464,31 +468,50 @@ class TransferChain(ChainBase):
|
|||||||
if not logid.isdigit():
|
if not logid.isdigit():
|
||||||
args_error()
|
args_error()
|
||||||
return
|
return
|
||||||
# TMDB ID
|
# TMDBID/豆瓣ID
|
||||||
tmdb_strs = arg_strs[1].split('|')
|
id_strs = arg_strs[1].split('|')
|
||||||
tmdbid = tmdb_strs[0]
|
media_id = id_strs[0]
|
||||||
if not logid.isdigit():
|
if not logid.isdigit():
|
||||||
args_error()
|
args_error()
|
||||||
return
|
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]:
|
if not type_str or type_str not in [MediaType.MOVIE.value, MediaType.TV.value]:
|
||||||
args_error()
|
args_error()
|
||||||
return
|
return
|
||||||
state, errmsg = self.re_transfer(logid=int(logid),
|
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:
|
if not state:
|
||||||
self.post_message(Notification(channel=channel, title="手动整理失败",
|
self.post_message(Notification(channel=channel, title="手动整理失败",
|
||||||
text=errmsg, userid=userid))
|
text=errmsg, userid=userid))
|
||||||
return
|
return
|
||||||
|
|
||||||
def re_transfer(self, logid: int,
|
@staticmethod
|
||||||
mtype: MediaType = None, tmdbid: int = None) -> Tuple[bool, str]:
|
def get_root_path(path: str, type_name: str, category: str) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
计算媒体库目录的根路径
|
||||||
|
"""
|
||||||
|
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 logid: 历史记录ID
|
||||||
:param mtype: 媒体类型
|
:param mtype: 媒体类型
|
||||||
:param tmdbid: TMDB ID
|
:param mediaid: TMDB ID/豆瓣ID
|
||||||
"""
|
"""
|
||||||
# 查询历史记录
|
# 查询历史记录
|
||||||
history: TransferHistory = self.transferhis.get(logid)
|
history: TransferHistory = self.transferhis.get(logid)
|
||||||
@@ -499,19 +522,20 @@ class TransferChain(ChainBase):
|
|||||||
src_path = Path(history.src)
|
src_path = Path(history.src)
|
||||||
if not src_path.exists():
|
if not src_path.exists():
|
||||||
return False, f"源目录不存在:{src_path}"
|
return False, f"源目录不存在:{src_path}"
|
||||||
dest_path = Path(history.dest) if history.dest else None
|
dest_path = self.get_root_path(path=history.dest, type_name=history.type, category=history.category)
|
||||||
# 查询媒体信息
|
# 查询媒体信息
|
||||||
if mtype and tmdbid:
|
if mtype and mediaid:
|
||||||
mediainfo = self.recognize_media(mtype=mtype, tmdbid=tmdbid)
|
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:
|
else:
|
||||||
meta = MetaInfoPath(src_path)
|
mediainfo = self.mediachain.recognize_by_path(str(src_path))
|
||||||
mediainfo = self.recognize_media(meta=meta)
|
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
return False, f"未识别到媒体信息,类型:{mtype.value},tmdbid:{tmdbid}"
|
return False, f"未识别到媒体信息,类型:{mtype.value},id:{mediaid}"
|
||||||
# 重新执行转移
|
# 重新执行转移
|
||||||
logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}")
|
logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}")
|
||||||
# 更新媒体图片
|
|
||||||
self.obtain_images(mediainfo=mediainfo)
|
|
||||||
|
|
||||||
# 删除旧的已整理文件
|
# 删除旧的已整理文件
|
||||||
if history.dest:
|
if history.dest:
|
||||||
|
|||||||
@@ -267,7 +267,10 @@ class Command(metaclass=Singleton):
|
|||||||
停止事件处理线程
|
停止事件处理线程
|
||||||
"""
|
"""
|
||||||
self._event.set()
|
self._event.set()
|
||||||
self._thread.join()
|
try:
|
||||||
|
self._thread.join()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
|
||||||
|
|
||||||
def get_commands(self):
|
def get_commands(self):
|
||||||
"""
|
"""
|
||||||
@@ -315,8 +318,7 @@ class Command(metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
logger.info(f"{command.get('description')} 执行完成")
|
logger.info(f"{command.get('description')} 执行完成")
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"执行命令 {cmd} 出错:{str(err)}")
|
logger.error(f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}")
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_plugin_event(etype: EventType, data: dict) -> None:
|
def send_plugin_event(etype: EventType, data: dict) -> None:
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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
|
from app.utils.system import SystemUtils
|
||||||
|
|
||||||
@@ -32,25 +32,23 @@ class Settings(BaseSettings):
|
|||||||
# 是否开发模式
|
# 是否开发模式
|
||||||
DEV: bool = False
|
DEV: bool = False
|
||||||
# 配置文件目录
|
# 配置文件目录
|
||||||
CONFIG_DIR: str = None
|
CONFIG_DIR: Optional[str] = None
|
||||||
# 超级管理员
|
# 超级管理员
|
||||||
SUPERUSER: str = "admin"
|
SUPERUSER: str = "admin"
|
||||||
# 超级管理员初始密码
|
|
||||||
SUPERUSER_PASSWORD: str = "password"
|
|
||||||
# API密钥,需要更换
|
# API密钥,需要更换
|
||||||
API_TOKEN: str = "moviepilot"
|
API_TOKEN: str = "moviepilot"
|
||||||
# 登录页面电影海报,tmdb/bing
|
# 登录页面电影海报,tmdb/bing
|
||||||
WALLPAPER: str = "tmdb"
|
WALLPAPER: str = "tmdb"
|
||||||
# 网络代理 IP:PORT
|
# 网络代理 IP:PORT
|
||||||
PROXY_HOST: str = None
|
PROXY_HOST: Optional[str] = None
|
||||||
# 媒体信息搜索来源
|
# 媒体识别来源 themoviedb/douban
|
||||||
SEARCH_SOURCE: str = "themoviedb"
|
RECOGNIZE_SOURCE: str = "themoviedb"
|
||||||
|
# 刮削来源 themoviedb/douban
|
||||||
|
SCRAP_SOURCE: str = "themoviedb"
|
||||||
# 刮削入库的媒体文件
|
# 刮削入库的媒体文件
|
||||||
SCRAP_METADATA: bool = True
|
SCRAP_METADATA: bool = True
|
||||||
# 新增已入库媒体是否跟随TMDB信息变化
|
# 新增已入库媒体是否跟随TMDB信息变化
|
||||||
SCRAP_FOLLOW_TMDB: bool = True
|
SCRAP_FOLLOW_TMDB: bool = True
|
||||||
# 刮削来源
|
|
||||||
SCRAP_SOURCE: str = "themoviedb"
|
|
||||||
# TMDB图片地址
|
# TMDB图片地址
|
||||||
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
|
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
|
||||||
# TMDB API地址
|
# TMDB API地址
|
||||||
@@ -59,6 +57,8 @@ class Settings(BaseSettings):
|
|||||||
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
||||||
# TVDB API Key
|
# TVDB API Key
|
||||||
TVDB_API_KEY: str = "6b481081-10aa-440c-99f2-21d17717ee02"
|
TVDB_API_KEY: str = "6b481081-10aa-440c-99f2-21d17717ee02"
|
||||||
|
# Fanart开关
|
||||||
|
FANART_ENABLE: bool = True
|
||||||
# Fanart API Key
|
# Fanart API Key
|
||||||
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
|
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
|
||||||
# 支持的后缀格式
|
# 支持的后缀格式
|
||||||
@@ -66,9 +66,11 @@ class Settings(BaseSettings):
|
|||||||
'.rmvb', '.avi', '.mov', '.mpeg',
|
'.rmvb', '.avi', '.mov', '.mpeg',
|
||||||
'.mpg', '.wmv', '.3gp', '.asf',
|
'.mpg', '.wmv', '.3gp', '.asf',
|
||||||
'.m4v', '.flv', '.m2ts', '.strm',
|
'.m4v', '.flv', '.m2ts', '.strm',
|
||||||
'.tp']
|
'.tp', '.f4v']
|
||||||
# 支持的字幕文件后缀格式
|
# 支持的字幕文件后缀格式
|
||||||
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa']
|
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa', '.sup']
|
||||||
|
# 下载器临时文件后缀
|
||||||
|
DOWNLOAD_TMPEXT: list = ['.!qB', '.part']
|
||||||
# 支持的音轨文件后缀格式
|
# 支持的音轨文件后缀格式
|
||||||
RMT_AUDIO_TRACK_EXT: list = ['.mka']
|
RMT_AUDIO_TRACK_EXT: list = ['.mka']
|
||||||
# 索引器
|
# 索引器
|
||||||
@@ -82,27 +84,27 @@ class Settings(BaseSettings):
|
|||||||
# 用户认证站点
|
# 用户认证站点
|
||||||
AUTH_SITE: str = ""
|
AUTH_SITE: str = ""
|
||||||
# 交互搜索自动下载用户ID,使用,分割
|
# 交互搜索自动下载用户ID,使用,分割
|
||||||
AUTO_DOWNLOAD_USER: str = None
|
AUTO_DOWNLOAD_USER: Optional[str] = None
|
||||||
# 消息通知渠道 telegram/wechat/slack,多个通知渠道用,分隔
|
# 消息通知渠道 telegram/wechat/slack/synologychat/vocechat,多个通知渠道用,分隔
|
||||||
MESSAGER: str = "telegram"
|
MESSAGER: str = "telegram"
|
||||||
# WeChat企业ID
|
# WeChat企业ID
|
||||||
WECHAT_CORPID: str = None
|
WECHAT_CORPID: Optional[str] = None
|
||||||
# WeChat应用Secret
|
# WeChat应用Secret
|
||||||
WECHAT_APP_SECRET: str = None
|
WECHAT_APP_SECRET: Optional[str] = None
|
||||||
# WeChat应用ID
|
# WeChat应用ID
|
||||||
WECHAT_APP_ID: str = None
|
WECHAT_APP_ID: Optional[str] = None
|
||||||
# WeChat代理服务器
|
# WeChat代理服务器
|
||||||
WECHAT_PROXY: str = "https://qyapi.weixin.qq.com"
|
WECHAT_PROXY: str = "https://qyapi.weixin.qq.com"
|
||||||
# WeChat Token
|
# WeChat Token
|
||||||
WECHAT_TOKEN: str = None
|
WECHAT_TOKEN: Optional[str] = None
|
||||||
# WeChat EncodingAESKey
|
# WeChat EncodingAESKey
|
||||||
WECHAT_ENCODING_AESKEY: str = None
|
WECHAT_ENCODING_AESKEY: Optional[str] = None
|
||||||
# WeChat 管理员
|
# WeChat 管理员
|
||||||
WECHAT_ADMINS: str = None
|
WECHAT_ADMINS: Optional[str] = None
|
||||||
# Telegram Bot Token
|
# Telegram Bot Token
|
||||||
TELEGRAM_TOKEN: str = None
|
TELEGRAM_TOKEN: Optional[str] = None
|
||||||
# Telegram Chat ID
|
# Telegram Chat ID
|
||||||
TELEGRAM_CHAT_ID: str = None
|
TELEGRAM_CHAT_ID: Optional[str] = None
|
||||||
# Telegram 用户ID,使用,分隔
|
# Telegram 用户ID,使用,分隔
|
||||||
TELEGRAM_USERS: str = ""
|
TELEGRAM_USERS: str = ""
|
||||||
# Telegram 管理员ID,使用,分隔
|
# Telegram 管理员ID,使用,分隔
|
||||||
@@ -117,16 +119,22 @@ class Settings(BaseSettings):
|
|||||||
SYNOLOGYCHAT_WEBHOOK: str = ""
|
SYNOLOGYCHAT_WEBHOOK: str = ""
|
||||||
# SynologyChat Token
|
# SynologyChat Token
|
||||||
SYNOLOGYCHAT_TOKEN: str = ""
|
SYNOLOGYCHAT_TOKEN: str = ""
|
||||||
# 下载器 qbittorrent/transmission
|
# VoceChat地址
|
||||||
|
VOCECHAT_HOST: str = ""
|
||||||
|
# VoceChat ApiKey
|
||||||
|
VOCECHAT_API_KEY: str = ""
|
||||||
|
# VoceChat 频道ID
|
||||||
|
VOCECHAT_CHANNEL_ID: str = ""
|
||||||
|
# 下载器 qbittorrent/transmission,启用多个下载器时使用,分隔,只有第一个会被默认使用
|
||||||
DOWNLOADER: str = "qbittorrent"
|
DOWNLOADER: str = "qbittorrent"
|
||||||
# 下载器监控开关
|
# 下载器监控开关
|
||||||
DOWNLOADER_MONITOR: bool = True
|
DOWNLOADER_MONITOR: bool = True
|
||||||
# Qbittorrent地址,IP:PORT
|
# Qbittorrent地址,IP:PORT
|
||||||
QB_HOST: str = None
|
QB_HOST: Optional[str] = None
|
||||||
# Qbittorrent用户名
|
# Qbittorrent用户名
|
||||||
QB_USER: str = None
|
QB_USER: Optional[str] = None
|
||||||
# Qbittorrent密码
|
# Qbittorrent密码
|
||||||
QB_PASSWORD: str = None
|
QB_PASSWORD: Optional[str] = None
|
||||||
# Qbittorrent分类自动管理
|
# Qbittorrent分类自动管理
|
||||||
QB_CATEGORY: bool = False
|
QB_CATEGORY: bool = False
|
||||||
# Qbittorrent按顺序下载
|
# Qbittorrent按顺序下载
|
||||||
@@ -134,21 +142,21 @@ class Settings(BaseSettings):
|
|||||||
# Qbittorrent忽略队列限制,强制继续
|
# Qbittorrent忽略队列限制,强制继续
|
||||||
QB_FORCE_RESUME: bool = False
|
QB_FORCE_RESUME: bool = False
|
||||||
# Transmission地址,IP:PORT
|
# Transmission地址,IP:PORT
|
||||||
TR_HOST: str = None
|
TR_HOST: Optional[str] = None
|
||||||
# Transmission用户名
|
# Transmission用户名
|
||||||
TR_USER: str = None
|
TR_USER: Optional[str] = None
|
||||||
# Transmission密码
|
# Transmission密码
|
||||||
TR_PASSWORD: str = None
|
TR_PASSWORD: Optional[str] = None
|
||||||
# 种子标签
|
# 种子标签
|
||||||
TORRENT_TAG: str = "MOVIEPILOT"
|
TORRENT_TAG: str = "MOVIEPILOT"
|
||||||
# 下载保存目录,容器内映射路径需要一致
|
# 下载保存目录,容器内映射路径需要一致
|
||||||
DOWNLOAD_PATH: str = None
|
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_CATEGORY: bool = False
|
||||||
# 下载站点字幕
|
# 下载站点字幕
|
||||||
@@ -156,43 +164,51 @@ class Settings(BaseSettings):
|
|||||||
# 媒体服务器 emby/jellyfin/plex,多个媒体服务器,分割
|
# 媒体服务器 emby/jellyfin/plex,多个媒体服务器,分割
|
||||||
MEDIASERVER: str = "emby"
|
MEDIASERVER: str = "emby"
|
||||||
# 媒体服务器同步间隔(小时)
|
# 媒体服务器同步间隔(小时)
|
||||||
MEDIASERVER_SYNC_INTERVAL: int = 6
|
MEDIASERVER_SYNC_INTERVAL: Optional[int] = 6
|
||||||
# 媒体服务器同步黑名单,多个媒体库名称,分割
|
# 媒体服务器同步黑名单,多个媒体库名称,分割
|
||||||
MEDIASERVER_SYNC_BLACKLIST: str = None
|
MEDIASERVER_SYNC_BLACKLIST: Optional[str] = None
|
||||||
# EMBY服务器地址,IP:PORT
|
# 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
|
||||||
EMBY_API_KEY: str = None
|
EMBY_API_KEY: Optional[str] = None
|
||||||
# Jellyfin服务器地址,IP:PORT
|
# 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
|
||||||
JELLYFIN_API_KEY: str = None
|
JELLYFIN_API_KEY: Optional[str] = None
|
||||||
# Plex服务器地址,IP:PORT
|
# 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
|
||||||
PLEX_TOKEN: str = None
|
PLEX_TOKEN: Optional[str] = None
|
||||||
# 转移方式 link/copy/move/softlink
|
# 转移方式 link/copy/move/softlink
|
||||||
TRANSFER_TYPE: str = "copy"
|
TRANSFER_TYPE: str = "copy"
|
||||||
|
# CookieCloud是否启动本地服务
|
||||||
|
COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False
|
||||||
# CookieCloud服务器地址
|
# CookieCloud服务器地址
|
||||||
COOKIECLOUD_HOST: str = "https://movie-pilot.org/cookiecloud"
|
COOKIECLOUD_HOST: str = "https://movie-pilot.org/cookiecloud"
|
||||||
# CookieCloud用户KEY
|
# CookieCloud用户KEY
|
||||||
COOKIECLOUD_KEY: str = None
|
COOKIECLOUD_KEY: Optional[str] = None
|
||||||
# CookieCloud端对端加密密码
|
# CookieCloud端对端加密密码
|
||||||
COOKIECLOUD_PASSWORD: str = None
|
COOKIECLOUD_PASSWORD: Optional[str] = None
|
||||||
# CookieCloud同步间隔(分钟)
|
# CookieCloud同步间隔(分钟)
|
||||||
COOKIECLOUD_INTERVAL: int = 60 * 24
|
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
||||||
# OCR服务器地址
|
# OCR服务器地址
|
||||||
OCR_HOST: str = "https://movie-pilot.org"
|
OCR_HOST: str = "https://movie-pilot.org"
|
||||||
# CookieCloud对应的浏览器UA
|
# 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"
|
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_PATH: Optional[str] = None
|
||||||
# 电影媒体库目录名,默认"电影"
|
# 电影媒体库目录名
|
||||||
LIBRARY_MOVIE_NAME: str = None
|
LIBRARY_MOVIE_NAME: str = "电影"
|
||||||
# 电视剧媒体库目录名,默认"电视剧"
|
# 电视剧媒体库目录名
|
||||||
LIBRARY_TV_NAME: str = None
|
LIBRARY_TV_NAME: str = "电视剧"
|
||||||
# 动漫媒体库目录名,默认"电视剧/动漫"
|
# 动漫媒体库目录名,不设置时使用电视剧目录
|
||||||
LIBRARY_ANIME_NAME: str = None
|
LIBRARY_ANIME_NAME: Optional[str] = None
|
||||||
# 二级分类
|
# 二级分类
|
||||||
LIBRARY_CATEGORY: bool = True
|
LIBRARY_CATEGORY: bool = True
|
||||||
# 电视剧动漫的分类genre_ids
|
# 电视剧动漫的分类genre_ids
|
||||||
@@ -211,7 +227,30 @@ class Settings(BaseSettings):
|
|||||||
# 大内存模式
|
# 大内存模式
|
||||||
BIG_MEMORY_MODE: bool = False
|
BIG_MEMORY_MODE: bool = False
|
||||||
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
||||||
PLUGIN_MARKET: str = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/"
|
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
|
||||||
|
# 是否启用DOH解析域名
|
||||||
|
DOH_ENABLE: bool = True
|
||||||
|
# 搜索多个名称
|
||||||
|
SEARCH_MULTIPLE_NAME: bool = False
|
||||||
|
|
||||||
|
@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
|
@property
|
||||||
def INNER_CONFIG_PATH(self):
|
def INNER_CONFIG_PATH(self):
|
||||||
@@ -242,6 +281,10 @@ class Settings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def LOG_PATH(self):
|
def LOG_PATH(self):
|
||||||
return self.CONFIG_PATH / "logs"
|
return self.CONFIG_PATH / "logs"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def COOKIE_PATH(self):
|
||||||
|
return self.CONFIG_PATH / "cookies"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def CACHE_CONF(self):
|
def CACHE_CONF(self):
|
||||||
@@ -252,7 +295,7 @@ class Settings(BaseSettings):
|
|||||||
"torrents": 100,
|
"torrents": 100,
|
||||||
"douban": 512,
|
"douban": 512,
|
||||||
"fanart": 512,
|
"fanart": 512,
|
||||||
"meta": 15 * 24 * 3600
|
"meta": (self.META_CACHE_EXPIRE or 168) * 3600
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"tmdb": 256,
|
"tmdb": 256,
|
||||||
@@ -260,7 +303,7 @@ class Settings(BaseSettings):
|
|||||||
"torrents": 50,
|
"torrents": 50,
|
||||||
"douban": 256,
|
"douban": 256,
|
||||||
"fanart": 128,
|
"fanart": 128,
|
||||||
"meta": 7 * 24 * 3600
|
"meta": (self.META_CACHE_EXPIRE or 72) * 3600
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -321,6 +364,35 @@ class Settings(BaseSettings):
|
|||||||
return Path(self.DOWNLOAD_ANIME_PATH)
|
return Path(self.DOWNLOAD_ANIME_PATH)
|
||||||
return self.SAVE_TV_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):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
with self.CONFIG_PATH as p:
|
with self.CONFIG_PATH as p:
|
||||||
@@ -335,12 +407,9 @@ class Settings(BaseSettings):
|
|||||||
with self.LOG_PATH as p:
|
with self.LOG_PATH as p:
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
with self.SAVE_PATH as p:
|
with self.COOKIE_PATH as p:
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
for path in self.LIBRARY_PATHS:
|
|
||||||
if not path.exists():
|
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app.core.config import settings
|
|||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -44,6 +45,8 @@ class TorrentInfo:
|
|||||||
pubdate: str = None
|
pubdate: str = None
|
||||||
# 已过时间
|
# 已过时间
|
||||||
date_elapsed: str = None
|
date_elapsed: str = None
|
||||||
|
# 免费截止时间
|
||||||
|
freedate: str = None
|
||||||
# 上传因子
|
# 上传因子
|
||||||
uploadvolumefactor: float = None
|
uploadvolumefactor: float = None
|
||||||
# 下载因子
|
# 下载因子
|
||||||
@@ -54,6 +57,8 @@ class TorrentInfo:
|
|||||||
labels: list = field(default_factory=list)
|
labels: list = field(default_factory=list)
|
||||||
# 种子优先级
|
# 种子优先级
|
||||||
pri_order: int = 0
|
pri_order: int = 0
|
||||||
|
# 种子分类 电影/电视剧
|
||||||
|
category: str = None
|
||||||
|
|
||||||
def __setattr__(self, name: str, value: Any):
|
def __setattr__(self, name: str, value: Any):
|
||||||
self.__dict__[name] = value
|
self.__dict__[name] = value
|
||||||
@@ -90,7 +95,9 @@ class TorrentInfo:
|
|||||||
"1.0 1.0": "普通",
|
"1.0 1.0": "普通",
|
||||||
"1.0 0.0": "免费",
|
"1.0 0.0": "免费",
|
||||||
"2.0 1.0": "2X",
|
"2.0 1.0": "2X",
|
||||||
|
"4.0 1.0": "4X",
|
||||||
"2.0 0.0": "2X免费",
|
"2.0 0.0": "2X免费",
|
||||||
|
"4.0 0.0": "4X免费",
|
||||||
"1.0 0.5": "50%",
|
"1.0 0.5": "50%",
|
||||||
"2.0 0.5": "2X 50%",
|
"2.0 0.5": "2X 50%",
|
||||||
"1.0 0.7": "70%",
|
"1.0 0.7": "70%",
|
||||||
@@ -105,12 +112,22 @@ class TorrentInfo:
|
|||||||
"""
|
"""
|
||||||
return self.get_free_string(self.uploadvolumefactor, self.downloadvolumefactor)
|
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):
|
def to_dict(self):
|
||||||
"""
|
"""
|
||||||
返回字典
|
返回字典
|
||||||
"""
|
"""
|
||||||
dicts = asdict(self)
|
dicts = asdict(self)
|
||||||
dicts["volume_factor"] = self.volume_factor
|
dicts["volume_factor"] = self.volume_factor
|
||||||
|
dicts["freedate_diff"] = self.freedate_diff
|
||||||
return dicts
|
return dicts
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +137,10 @@ class MediaInfo:
|
|||||||
type: MediaType = None
|
type: MediaType = None
|
||||||
# 媒体标题
|
# 媒体标题
|
||||||
title: str = None
|
title: str = None
|
||||||
|
# 英文标题
|
||||||
|
en_title: str = None
|
||||||
|
# 新加坡标题
|
||||||
|
sg_title: str = None
|
||||||
# 年份
|
# 年份
|
||||||
year: str = None
|
year: str = None
|
||||||
# 季
|
# 季
|
||||||
@@ -132,6 +153,8 @@ class MediaInfo:
|
|||||||
tvdb_id: int = None
|
tvdb_id: int = None
|
||||||
# 豆瓣ID
|
# 豆瓣ID
|
||||||
douban_id: str = None
|
douban_id: str = None
|
||||||
|
# Bangumi ID
|
||||||
|
bangumi_id: int = None
|
||||||
# 媒体原语种
|
# 媒体原语种
|
||||||
original_language: str = None
|
original_language: str = None
|
||||||
# 媒体原发行标题
|
# 媒体原发行标题
|
||||||
@@ -145,7 +168,7 @@ class MediaInfo:
|
|||||||
# LOGO
|
# LOGO
|
||||||
logo_path: str = None
|
logo_path: str = None
|
||||||
# 评分
|
# 评分
|
||||||
vote_average: int = 0
|
vote_average: float = 0
|
||||||
# 描述
|
# 描述
|
||||||
overview: str = None
|
overview: str = None
|
||||||
# 风格ID
|
# 风格ID
|
||||||
@@ -164,6 +187,8 @@ class MediaInfo:
|
|||||||
tmdb_info: dict = field(default_factory=dict)
|
tmdb_info: dict = field(default_factory=dict)
|
||||||
# 豆瓣 INFO
|
# 豆瓣 INFO
|
||||||
douban_info: dict = field(default_factory=dict)
|
douban_info: dict = field(default_factory=dict)
|
||||||
|
# Bangumi INFO
|
||||||
|
bangumi_info: dict = field(default_factory=dict)
|
||||||
# 导演
|
# 导演
|
||||||
directors: List[dict] = field(default_factory=list)
|
directors: List[dict] = field(default_factory=list)
|
||||||
# 演员
|
# 演员
|
||||||
@@ -219,6 +244,8 @@ class MediaInfo:
|
|||||||
self.set_tmdb_info(self.tmdb_info)
|
self.set_tmdb_info(self.tmdb_info)
|
||||||
if self.douban_info:
|
if self.douban_info:
|
||||||
self.set_douban_info(self.douban_info)
|
self.set_douban_info(self.douban_info)
|
||||||
|
if self.bangumi_info:
|
||||||
|
self.set_bangumi_info(self.bangumi_info)
|
||||||
|
|
||||||
def __setattr__(self, name: str, value: Any):
|
def __setattr__(self, name: str, value: Any):
|
||||||
self.__dict__[name] = value
|
self.__dict__[name] = value
|
||||||
@@ -353,6 +380,10 @@ class MediaInfo:
|
|||||||
self.genre_ids = info.get('genre_ids') or []
|
self.genre_ids = info.get('genre_ids') or []
|
||||||
# 原语种
|
# 原语种
|
||||||
self.original_language = info.get('original_language')
|
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:
|
if self.type == MediaType.MOVIE:
|
||||||
# 标题
|
# 标题
|
||||||
self.title = info.get('title')
|
self.title = info.get('title')
|
||||||
@@ -414,24 +445,34 @@ class MediaInfo:
|
|||||||
# 豆瓣ID
|
# 豆瓣ID
|
||||||
self.douban_id = str(info.get("id"))
|
self.douban_id = str(info.get("id"))
|
||||||
# 类型
|
# 类型
|
||||||
|
|
||||||
if not self.type:
|
if not self.type:
|
||||||
if isinstance(info.get('media_type'), MediaType):
|
if isinstance(info.get('media_type'), MediaType):
|
||||||
self.type = info.get('media_type')
|
self.type = info.get('media_type')
|
||||||
else:
|
elif info.get("type"):
|
||||||
self.type = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
|
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:
|
if not self.title:
|
||||||
self.title = info.get("title")
|
self.title = info.get("title")
|
||||||
# 识别标题中的季
|
# 英文标题,暂时不支持
|
||||||
meta = MetaInfo(self.title)
|
if not self.en_title:
|
||||||
self.season = meta.begin_season
|
self.en_title = info.get('original_title')
|
||||||
# 原语种标题
|
# 原语种标题
|
||||||
if not self.original_title:
|
if not self.original_title:
|
||||||
self.original_title = info.get("original_title")
|
self.original_title = info.get("original_title")
|
||||||
# 年份
|
# 年份
|
||||||
if not self.year:
|
if not self.year:
|
||||||
self.year = info.get("year")[:4] if info.get("year") else None
|
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:
|
if not self.vote_average:
|
||||||
rating = info.get("rating")
|
rating = info.get("rating")
|
||||||
@@ -452,7 +493,8 @@ class MediaInfo:
|
|||||||
self.release_date = match.group()
|
self.release_date = match.group()
|
||||||
# 海报
|
# 海报
|
||||||
if not self.poster_path:
|
if not self.poster_path:
|
||||||
self.poster_path = info.get("pic", {}).get("large")
|
if info.get("pic"):
|
||||||
|
self.poster_path = info.get("pic", {}).get("large")
|
||||||
if not self.poster_path and info.get("cover_url"):
|
if not self.poster_path and info.get("cover_url"):
|
||||||
self.poster_path = info.get("cover_url")
|
self.poster_path = info.get("cover_url")
|
||||||
if not self.poster_path and info.get("cover"):
|
if not self.poster_path and info.get("cover"):
|
||||||
@@ -472,19 +514,103 @@ class MediaInfo:
|
|||||||
self.actors = info.get("actors") or []
|
self.actors = info.get("actors") or []
|
||||||
# 别名
|
# 别名
|
||||||
if not self.names:
|
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:
|
if self.type == MediaType.TV and not self.seasons:
|
||||||
meta = MetaInfo(info.get("title"))
|
meta = MetaInfo(info.get("title"))
|
||||||
if meta.begin_season:
|
season = meta.begin_season or 1
|
||||||
episodes_count = info.get("episodes_count")
|
episodes_count = info.get("episodes_count")
|
||||||
if episodes_count:
|
if episodes_count:
|
||||||
self.seasons[meta.begin_season] = list(range(1, episodes_count + 1))
|
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():
|
for key, value in info.items():
|
||||||
if not hasattr(self, key):
|
if not hasattr(self, key):
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
def set_bangumi_info(self, info: dict):
|
||||||
|
"""
|
||||||
|
初始化Bangumi信息
|
||||||
|
"""
|
||||||
|
if not info:
|
||||||
|
return
|
||||||
|
# 本体
|
||||||
|
self.bangumi_info = info
|
||||||
|
# 豆瓣ID
|
||||||
|
self.bangumi_id = info.get("id")
|
||||||
|
# 类型
|
||||||
|
if not self.type:
|
||||||
|
self.type = MediaType.TV
|
||||||
|
# 标题
|
||||||
|
if not self.title:
|
||||||
|
self.title = info.get("name_cn") or info.get("name")
|
||||||
|
# 原语种标题
|
||||||
|
if not self.original_title:
|
||||||
|
self.original_title = info.get("name")
|
||||||
|
# 识别标题中的季
|
||||||
|
meta = MetaInfo(self.title)
|
||||||
|
# 季
|
||||||
|
if not self.season:
|
||||||
|
self.season = meta.begin_season
|
||||||
|
# 评分
|
||||||
|
if not self.vote_average:
|
||||||
|
rating = info.get("rating")
|
||||||
|
if rating:
|
||||||
|
vote_average = float(rating.get("score"))
|
||||||
|
else:
|
||||||
|
vote_average = 0
|
||||||
|
self.vote_average = vote_average
|
||||||
|
# 发行日期
|
||||||
|
if not self.release_date:
|
||||||
|
self.release_date = info.get("date") or info.get("air_date")
|
||||||
|
# 年份
|
||||||
|
if not self.year:
|
||||||
|
self.year = self.release_date[:4] if self.release_date else None
|
||||||
|
# 海报
|
||||||
|
if not self.poster_path:
|
||||||
|
if info.get("images"):
|
||||||
|
self.poster_path = info.get("images", {}).get("large")
|
||||||
|
# 简介
|
||||||
|
if not self.overview:
|
||||||
|
self.overview = info.get("summary")
|
||||||
|
# 别名
|
||||||
|
if not self.names:
|
||||||
|
infobox = info.get("infobox")
|
||||||
|
if infobox:
|
||||||
|
akas = [item.get("value") for item in infobox if item.get("key") == "别名"]
|
||||||
|
if akas:
|
||||||
|
self.names = [aka.get("v") for aka in akas[0]]
|
||||||
|
|
||||||
|
# 剧集
|
||||||
|
if self.type == MediaType.TV and not self.seasons:
|
||||||
|
meta = MetaInfo(self.title)
|
||||||
|
season = meta.begin_season or 1
|
||||||
|
episodes_count = info.get("total_episodes")
|
||||||
|
if episodes_count:
|
||||||
|
self.seasons[season] = list(range(1, episodes_count + 1))
|
||||||
|
# 演员
|
||||||
|
if not self.actors:
|
||||||
|
self.actors = info.get("actors") or []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title_year(self):
|
def title_year(self):
|
||||||
if self.title:
|
if self.title:
|
||||||
@@ -503,6 +629,8 @@ class MediaInfo:
|
|||||||
return "https://www.themoviedb.org/tv/%s" % self.tmdb_id
|
return "https://www.themoviedb.org/tv/%s" % self.tmdb_id
|
||||||
elif self.douban_id:
|
elif self.douban_id:
|
||||||
return "https://movie.douban.com/subject/%s" % self.douban_id
|
return "https://movie.douban.com/subject/%s" % self.douban_id
|
||||||
|
elif self.bangumi_id:
|
||||||
|
return "http://bgm.tv/subject/%s" % self.bangumi_id
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -564,6 +692,9 @@ class MediaInfo:
|
|||||||
dicts["type"] = self.type.value if self.type else None
|
dicts["type"] = self.type.value if self.type else None
|
||||||
dicts["detail_link"] = self.detail_link
|
dicts["detail_link"] = self.detail_link
|
||||||
dicts["title_year"] = self.title_year
|
dicts["title_year"] = self.title_year
|
||||||
|
dicts["tmdb_info"] = None
|
||||||
|
dicts["douban_info"] = None
|
||||||
|
dicts["bangumi_info"] = None
|
||||||
return dicts
|
return dicts
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
@@ -572,6 +703,7 @@ class MediaInfo:
|
|||||||
"""
|
"""
|
||||||
self.tmdb_info = {}
|
self.tmdb_info = {}
|
||||||
self.douban_info = {}
|
self.douban_info = {}
|
||||||
|
self.bangumi_info = {}
|
||||||
self.seasons = {}
|
self.seasons = {}
|
||||||
self.genres = []
|
self.genres = []
|
||||||
self.season_info = []
|
self.season_info = []
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from queue import Queue, Empty
|
from queue import Queue, Empty
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
@@ -14,7 +15,7 @@ class EventManager(metaclass=Singleton):
|
|||||||
# 事件队列
|
# 事件队列
|
||||||
self._eventQueue = Queue()
|
self._eventQueue = Queue()
|
||||||
# 事件响应函数字典
|
# 事件响应函数字典
|
||||||
self._handlers = {}
|
self._handlers: Dict[str, Dict[str, Any]] = {}
|
||||||
# 已禁用的事件响应
|
# 已禁用的事件响应
|
||||||
self._disabled_handlers = []
|
self._disabled_handlers = []
|
||||||
|
|
||||||
@@ -24,33 +25,40 @@ class EventManager(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
event = self._eventQueue.get(block=True, timeout=1)
|
event = self._eventQueue.get(block=True, timeout=1)
|
||||||
handlerList = self._handlers.get(event.event_type) or []
|
handlers = self._handlers.get(event.event_type) or {}
|
||||||
if handlerList:
|
if handlers:
|
||||||
# 去除掉被禁用的事件响应
|
# 去除掉被禁用的事件响应
|
||||||
handlerList = [handler for handler in handlerList
|
handlerList = [handler for handler in handlers.values()
|
||||||
if handler.__qualname__.split(".")[0] not in self._disabled_handlers]
|
if handler.__qualname__.split(".")[0] not in self._disabled_handlers]
|
||||||
return event, handlerList
|
return event, handlerList
|
||||||
|
return event, []
|
||||||
except Empty:
|
except Empty:
|
||||||
return None, []
|
return None, []
|
||||||
|
|
||||||
def check(self, etype: EventType):
|
def check(self, etype: EventType):
|
||||||
"""
|
"""
|
||||||
检查事件是否存在响应
|
检查事件是否存在响应,去除掉被禁用的事件响应
|
||||||
"""
|
"""
|
||||||
return etype.value in self._handlers
|
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):
|
def add_event_listener(self, etype: EventType, handler: type):
|
||||||
"""
|
"""
|
||||||
注册事件处理
|
注册事件处理
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
handlerList = self._handlers[etype.value]
|
handlers = self._handlers[etype.value]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
handlerList = []
|
handlers = {}
|
||||||
self._handlers[etype.value] = handlerList
|
self._handlers[etype.value] = handlers
|
||||||
if handler not in handlerList:
|
if handler.__qualname__ in handlers:
|
||||||
handlerList.append(handler)
|
handlers.pop(handler.__qualname__)
|
||||||
logger.debug(f"Event Registed:{etype.value} - {handler}")
|
else:
|
||||||
|
logger.debug(f"Event Registed:{etype.value} - {handler.__qualname__}")
|
||||||
|
handlers[handler.__qualname__] = handler
|
||||||
|
|
||||||
def disable_events_hander(self, class_name: str):
|
def disable_events_hander(self, class_name: str):
|
||||||
"""
|
"""
|
||||||
@@ -66,7 +74,7 @@ class EventManager(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
if class_name in self._disabled_handlers:
|
if class_name in self._disabled_handlers:
|
||||||
self._disabled_handlers.remove(class_name)
|
self._disabled_handlers.remove(class_name)
|
||||||
logger.debug(f"Event Enabled:{class_name}")
|
logger.debug(f"Event Enabled:{class_name}")
|
||||||
|
|
||||||
def send_event(self, etype: EventType, data: dict = None):
|
def send_event(self, etype: EventType, data: dict = None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import re
|
import re
|
||||||
|
import traceback
|
||||||
|
|
||||||
import zhconv
|
import zhconv
|
||||||
import anitopy
|
import anitopy
|
||||||
from app.core.meta.customization import CustomizationMatcher
|
from app.core.meta.customization import CustomizationMatcher
|
||||||
from app.core.meta.metabase import MetaBase
|
from app.core.meta.metabase import MetaBase
|
||||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||||
|
from app.log import logger
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
|
|
||||||
@@ -13,7 +16,7 @@ class MetaAnime(MetaBase):
|
|||||||
识别动漫
|
识别动漫
|
||||||
"""
|
"""
|
||||||
_anime_no_words = ['CHS&CHT', 'MP4', 'GB MP4', 'WEB-DL']
|
_anime_no_words = ['CHS&CHT', 'MP4', 'GB MP4', 'WEB-DL']
|
||||||
_name_nostring_re = r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}"
|
_name_nostring_re = r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}|\s+GB"
|
||||||
|
|
||||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||||
super().__init__(title, subtitle, isfile)
|
super().__init__(title, subtitle, isfile)
|
||||||
@@ -29,8 +32,6 @@ class MetaAnime(MetaBase):
|
|||||||
if anitopy_info:
|
if anitopy_info:
|
||||||
# 名称
|
# 名称
|
||||||
name = anitopy_info.get("anime_title")
|
name = anitopy_info.get("anime_title")
|
||||||
if name and name.find("/") != -1:
|
|
||||||
name = name.split("/")[-1].strip()
|
|
||||||
if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):
|
if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):
|
||||||
anitopy_info = anitopy.parse("[ANIME]" + title)
|
anitopy_info = anitopy.parse("[ANIME]" + title)
|
||||||
if anitopy_info:
|
if anitopy_info:
|
||||||
@@ -41,23 +42,41 @@ class MetaAnime(MetaBase):
|
|||||||
name = name_match.group(1).strip()
|
name = name_match.group(1).strip()
|
||||||
# 拆份中英文名称
|
# 拆份中英文名称
|
||||||
if name:
|
if name:
|
||||||
lastword_type = ""
|
_split_flag = True
|
||||||
for word in name.split():
|
# 按/拆分中英文
|
||||||
if not word:
|
if name.find("/") != -1:
|
||||||
continue
|
names = name.split("/")
|
||||||
if word.endswith(']'):
|
if StringUtils.is_chinese(names[0]):
|
||||||
word = word[:-1]
|
self.cn_name = names[0]
|
||||||
if word.isdigit():
|
if len(names) > 1:
|
||||||
if lastword_type == "cn":
|
self.en_name = names[1]
|
||||||
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
_split_flag = False
|
||||||
elif lastword_type == "en":
|
elif StringUtils.is_chinese(names[-1]):
|
||||||
self.en_name = "%s %s" % (self.en_name or "", word)
|
self.cn_name = names[-1]
|
||||||
elif StringUtils.is_chinese(word):
|
if len(names) > 1:
|
||||||
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
self.en_name = names[0]
|
||||||
lastword_type = "cn"
|
_split_flag = False
|
||||||
else:
|
else:
|
||||||
self.en_name = "%s %s" % (self.en_name or "", word)
|
name = names[-1]
|
||||||
lastword_type = "en"
|
# 拆分中英文
|
||||||
|
if _split_flag:
|
||||||
|
lastword_type = ""
|
||||||
|
for word in name.split():
|
||||||
|
if not word:
|
||||||
|
continue
|
||||||
|
if word.endswith(']'):
|
||||||
|
word = word[:-1]
|
||||||
|
if word.isdigit():
|
||||||
|
if lastword_type == "cn":
|
||||||
|
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
||||||
|
elif lastword_type == "en":
|
||||||
|
self.en_name = "%s %s" % (self.en_name or "", word)
|
||||||
|
elif StringUtils.is_chinese(word):
|
||||||
|
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
||||||
|
lastword_type = "cn"
|
||||||
|
else:
|
||||||
|
self.en_name = "%s %s" % (self.en_name or "", word)
|
||||||
|
lastword_type = "en"
|
||||||
if self.cn_name:
|
if self.cn_name:
|
||||||
_, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)
|
_, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)
|
||||||
if self.cn_name:
|
if self.cn_name:
|
||||||
@@ -117,7 +136,7 @@ class MetaAnime(MetaBase):
|
|||||||
else:
|
else:
|
||||||
self.total_episode = 1
|
self.total_episode = 1
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(str(err))
|
logger.debug(f"解析集数失败:{str(err)} - {traceback.format_exc()}")
|
||||||
self.begin_episode = None
|
self.begin_episode = None
|
||||||
self.end_episode = None
|
self.end_episode = None
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
@@ -162,7 +181,7 @@ class MetaAnime(MetaBase):
|
|||||||
if not self.type:
|
if not self.type:
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(str(e))
|
logger.error(f"解析动漫信息失败:{str(e)} - {traceback.format_exc()}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __prepare_title(title: str):
|
def __prepare_title(title: str):
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import traceback
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from typing import Union, Optional, List, Self
|
from typing import Union, Optional, List, Self
|
||||||
|
|
||||||
import cn2an
|
import cn2an
|
||||||
import regex as re
|
import regex as re
|
||||||
|
|
||||||
|
from app.log import logger
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
|
|
||||||
@@ -59,19 +61,24 @@ class MetaBase(object):
|
|||||||
audio_encode: Optional[str] = None
|
audio_encode: Optional[str] = None
|
||||||
# 应用的识别词信息
|
# 应用的识别词信息
|
||||||
apply_words: Optional[List[str]] = None
|
apply_words: Optional[List[str]] = None
|
||||||
|
# 附加信息
|
||||||
|
tmdbid: int = None
|
||||||
|
doubanid: str = None
|
||||||
|
|
||||||
# 副标题解析
|
# 副标题解析
|
||||||
_subtitle_flag = False
|
_subtitle_flag = False
|
||||||
|
_title_episodel_re = r"Episode\s+(\d{1,4})"
|
||||||
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
|
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
|
||||||
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季|([0-9一二三四五六七八九十]+)\s*季\s*全"
|
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季|([0-9一二三四五六七八九十]+)\s*季\s*全"
|
||||||
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP\-]+)\s*[集话話期](?!\s*[全共])"
|
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP]+)\s*[集话話期幕](?!\s*[全共])"
|
||||||
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期]"
|
_subtitle_episode_between_re = r"[第]*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]?\s*-\s*第*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
||||||
|
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
||||||
|
|
||||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||||
if not title:
|
if not title:
|
||||||
return
|
return
|
||||||
self.org_string = title
|
self.org_string = title.strip() if title else None
|
||||||
self.subtitle = subtitle
|
self.subtitle = subtitle.strip() if subtitle else None
|
||||||
self.isfile = isfile
|
self.isfile = isfile
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -105,7 +112,39 @@ class MetaBase(object):
|
|||||||
if not title_text:
|
if not title_text:
|
||||||
return
|
return
|
||||||
title_text = f" {title_text} "
|
title_text = f" {title_text} "
|
||||||
if re.search(r'[全第季集话話期]', title_text, re.IGNORECASE):
|
if re.search(r"%s" % self._title_episodel_re, title_text, re.IGNORECASE):
|
||||||
|
episode_str = re.search(r'%s' % self._title_episodel_re, title_text, re.IGNORECASE)
|
||||||
|
if episode_str:
|
||||||
|
try:
|
||||||
|
episode = int(episode_str.group(1))
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
if episode >= 10000:
|
||||||
|
return
|
||||||
|
if self.begin_episode is None:
|
||||||
|
self.begin_episode = episode
|
||||||
|
self.total_episode = 1
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self._subtitle_flag = True
|
||||||
|
elif re.search(r'[全第季集话話期幕]', title_text, re.IGNORECASE):
|
||||||
|
# 全x季 x季全
|
||||||
|
season_all_str = re.search(r"%s" % self._subtitle_season_all_re, title_text, re.IGNORECASE)
|
||||||
|
if season_all_str:
|
||||||
|
season_all = season_all_str.group(1)
|
||||||
|
if not season_all:
|
||||||
|
season_all = season_all_str.group(2)
|
||||||
|
if season_all and self.begin_season is None and self.begin_episode is None:
|
||||||
|
try:
|
||||||
|
self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart'))
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
self.begin_season = 1
|
||||||
|
self.end_season = self.total_season
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self._subtitle_flag = True
|
||||||
|
return
|
||||||
# 第x季
|
# 第x季
|
||||||
season_str = re.search(r'%s' % self._subtitle_season_re, title_text, re.IGNORECASE)
|
season_str = re.search(r'%s' % self._subtitle_season_re, title_text, re.IGNORECASE)
|
||||||
if season_str:
|
if season_str:
|
||||||
@@ -124,7 +163,11 @@ class MetaBase(object):
|
|||||||
else:
|
else:
|
||||||
begin_season = int(cn2an.cn2an(seasons, mode='smart'))
|
begin_season = int(cn2an.cn2an(seasons, mode='smart'))
|
||||||
except Exception as err:
|
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
|
return
|
||||||
if self.begin_season is None and isinstance(begin_season, int):
|
if self.begin_season is None and isinstance(begin_season, int):
|
||||||
self.begin_season = begin_season
|
self.begin_season = begin_season
|
||||||
@@ -137,6 +180,37 @@ class MetaBase(object):
|
|||||||
self.total_season = (self.end_season - self.begin_season) + 1
|
self.total_season = (self.end_season - self.begin_season) + 1
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
self._subtitle_flag = True
|
self._subtitle_flag = True
|
||||||
|
# 第x-x集 第x集-x集
|
||||||
|
episode_between_str = re.search(r'%s' % self._subtitle_episode_between_re, title_text, re.IGNORECASE)
|
||||||
|
if episode_between_str:
|
||||||
|
episodes = episode_between_str.groups()
|
||||||
|
if episodes:
|
||||||
|
begin_episode = episodes[0]
|
||||||
|
end_episode = episodes[1]
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
begin_episode = int(cn2an.cn2an(begin_episode.strip(), mode='smart'))
|
||||||
|
end_episode = int(cn2an.cn2an(end_episode.strip(), mode='smart'))
|
||||||
|
except Exception as 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
|
||||||
|
self.total_episode = 1
|
||||||
|
if self.begin_episode is not None \
|
||||||
|
and self.end_episode is None \
|
||||||
|
and isinstance(end_episode, int) \
|
||||||
|
and end_episode != self.begin_episode:
|
||||||
|
self.end_episode = end_episode
|
||||||
|
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self._subtitle_flag = True
|
||||||
|
return
|
||||||
# 第x集
|
# 第x集
|
||||||
episode_str = re.search(r'%s' % self._subtitle_episode_re, title_text, re.IGNORECASE)
|
episode_str = re.search(r'%s' % self._subtitle_episode_re, title_text, re.IGNORECASE)
|
||||||
if episode_str:
|
if episode_str:
|
||||||
@@ -155,7 +229,11 @@ class MetaBase(object):
|
|||||||
else:
|
else:
|
||||||
begin_episode = int(cn2an.cn2an(episodes, mode='smart'))
|
begin_episode = int(cn2an.cn2an(episodes, mode='smart'))
|
||||||
except Exception as err:
|
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
|
return
|
||||||
if self.begin_episode is None and isinstance(begin_episode, int):
|
if self.begin_episode is None and isinstance(begin_episode, int):
|
||||||
self.begin_episode = begin_episode
|
self.begin_episode = begin_episode
|
||||||
@@ -168,6 +246,7 @@ class MetaBase(object):
|
|||||||
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
self._subtitle_flag = True
|
self._subtitle_flag = True
|
||||||
|
return
|
||||||
# x集全
|
# x集全
|
||||||
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
|
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
|
||||||
if episode_all_str:
|
if episode_all_str:
|
||||||
@@ -178,28 +257,13 @@ class MetaBase(object):
|
|||||||
try:
|
try:
|
||||||
self.total_episode = int(cn2an.cn2an(episode_all.strip(), mode='smart'))
|
self.total_episode = int(cn2an.cn2an(episode_all.strip(), mode='smart'))
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(str(err))
|
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
||||||
return
|
return
|
||||||
self.begin_episode = None
|
self.begin_episode = None
|
||||||
self.end_episode = None
|
self.end_episode = None
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
self._subtitle_flag = True
|
self._subtitle_flag = True
|
||||||
# 全x季 x季全
|
return
|
||||||
season_all_str = re.search(r"%s" % self._subtitle_season_all_re, title_text, re.IGNORECASE)
|
|
||||||
if season_all_str:
|
|
||||||
season_all = season_all_str.group(1)
|
|
||||||
if not season_all:
|
|
||||||
season_all = season_all_str.group(2)
|
|
||||||
if season_all and self.begin_season is None and self.begin_episode is None:
|
|
||||||
try:
|
|
||||||
self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart'))
|
|
||||||
except Exception as err:
|
|
||||||
print(str(err))
|
|
||||||
return
|
|
||||||
self.begin_season = 1
|
|
||||||
self.end_season = self.total_season
|
|
||||||
self.type = MediaType.TV
|
|
||||||
self._subtitle_flag = True
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def season(self) -> str:
|
def season(self) -> str:
|
||||||
@@ -227,7 +291,7 @@ class MetaBase(object):
|
|||||||
return self.season
|
return self.season
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def season_seq(self) -> str:
|
def season_seq(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -270,7 +334,7 @@ class MetaBase(object):
|
|||||||
str(self.end_episode).rjust(2, "0"))
|
str(self.end_episode).rjust(2, "0"))
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def episode_list(self) -> List[int]:
|
def episode_list(self) -> List[int]:
|
||||||
"""
|
"""
|
||||||
@@ -468,7 +532,7 @@ class MetaBase(object):
|
|||||||
self.end_episode = end
|
self.end_episode = end
|
||||||
if self.begin_episode and self.end_episode:
|
if self.begin_episode and self.end_episode:
|
||||||
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
||||||
|
|
||||||
def merge(self, meta: Self):
|
def merge(self, meta: Self):
|
||||||
"""
|
"""
|
||||||
全并Meta信息
|
全并Meta信息
|
||||||
@@ -486,13 +550,13 @@ class MetaBase(object):
|
|||||||
self.year = meta.year
|
self.year = meta.year
|
||||||
# 季
|
# 季
|
||||||
if (self.type == MediaType.TV
|
if (self.type == MediaType.TV
|
||||||
and not self.season):
|
and self.begin_season is None):
|
||||||
self.begin_season = meta.begin_season
|
self.begin_season = meta.begin_season
|
||||||
self.end_season = meta.end_season
|
self.end_season = meta.end_season
|
||||||
self.total_season = meta.total_season
|
self.total_season = meta.total_season
|
||||||
# 开始集
|
# 开始集
|
||||||
if (self.type == MediaType.TV
|
if (self.type == MediaType.TV
|
||||||
and not self.episode):
|
and self.begin_episode is None):
|
||||||
self.begin_episode = meta.begin_episode
|
self.begin_episode = meta.begin_episode
|
||||||
self.end_episode = meta.end_episode
|
self.end_episode = meta.end_episode
|
||||||
self.total_episode = meta.total_episode
|
self.total_episode = meta.total_episode
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from Pinyin2Hanzi import is_pinyin
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.meta.customization import CustomizationMatcher
|
from app.core.meta.customization import CustomizationMatcher
|
||||||
from app.core.meta.metabase import MetaBase
|
from app.core.meta.metabase import MetaBase
|
||||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||||
|
from app.schemas.types import MediaType
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
from app.utils.tokens import Tokens
|
from app.utils.tokens import Tokens
|
||||||
from app.schemas.types import MediaType
|
|
||||||
|
|
||||||
|
|
||||||
class MetaVideo(MetaBase):
|
class MetaVideo(MetaBase):
|
||||||
@@ -31,26 +34,33 @@ class MetaVideo(MetaBase):
|
|||||||
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$"
|
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$"
|
||||||
_effect_re = r"^REMUX$|^UHD$|^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
|
_effect_re = r"^REMUX$|^UHD$|^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
|
||||||
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
|
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
|
||||||
_name_no_begin_re = r"^\[.+?]"
|
_name_no_begin_re = r"^[\[【].+?[\]】]"
|
||||||
_name_no_chinese_re = r".*版|.*字幕"
|
_name_no_chinese_re = r".*版|.*字幕"
|
||||||
_name_se_words = ['共', '第', '季', '集', '话', '話', '期']
|
_name_se_words = ['共', '第', '季', '集', '话', '話', '期']
|
||||||
|
_name_movie_words = ['剧场版', '劇場版', '电影版', '電影版']
|
||||||
_name_nostring_re = r"^PTS|^JADE|^AOD|^CHC|^[A-Z]{1,4}TV[\-0-9UVHDK]*" \
|
_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"|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]+季" \
|
r"|[第\s共]+[0-9一二三四五六七八九十\-\s]+季" \
|
||||||
r"|[第\s共]+[0-9一二三四五六七八九十百零\-\s]+[集话話]" \
|
r"|[第\s共]+[0-9一二三四五六七八九十百零\-\s]+[集话話]" \
|
||||||
r"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|蓝光|翡翠台|梦幻天堂·龙网|★?\d*月?新番" \
|
r"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|无水印|下载|蓝光|翡翠台|梦幻天堂·龙网|★?\d*月?新番" \
|
||||||
r"|最终季|合集|[多中国英葡法俄日韩德意西印泰台港粤双文语简繁体特效内封官译外挂]+字幕|版本|出品|台版|港版|\w+字幕组" \
|
r"|最终季|合集|[多中国英葡法俄日韩德意西印泰台港粤双文语简繁体特效内封官译外挂]+字幕|版本|出品|台版|港版|\w+字幕组|\w+字幕社" \
|
||||||
r"|未删减版|UNCUT$|UNRATE$|WITH EXTRAS$|RERIP$|SUBBED$|PROPER$|REPACK$|SEASON$|EPISODE$|Complete$|Extended$|Extended Version$" \
|
r"|未删减版|UNCUT$|UNRATE$|WITH EXTRAS$|RERIP$|SUBBED$|PROPER$|REPACK$|SEASON$|EPISODE$|Complete$|Extended$|Extended Version$" \
|
||||||
r"|S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}" \
|
r"|S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}" \
|
||||||
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]" \
|
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]" \
|
||||||
r"|[248]K|\d{3,4}[PIX]+" \
|
r"|[248]K|\d{3,4}[PIX]+" \
|
||||||
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]"
|
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]|\s+GB"
|
||||||
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
|
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
|
||||||
_resources_pix_re2 = r"(^[248]+K)"
|
_resources_pix_re2 = r"(^[248]+K)"
|
||||||
_video_encode_re = r"^[HX]26[45]$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^HDR\d*$"
|
_video_encode_re = r"^[HX]26[45]$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^HDR\d*$"
|
||||||
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$"
|
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$"
|
||||||
|
|
||||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||||
|
"""
|
||||||
|
初始化
|
||||||
|
:param title: 标题
|
||||||
|
:param subtitle: 副标题
|
||||||
|
:param isfile: 是否是文件名
|
||||||
|
"""
|
||||||
super().__init__(title, subtitle, isfile)
|
super().__init__(title, subtitle, isfile)
|
||||||
if not title:
|
if not title:
|
||||||
return
|
return
|
||||||
@@ -129,12 +139,47 @@ class MetaVideo(MetaBase):
|
|||||||
# 处理part
|
# 处理part
|
||||||
if self.part and self.part.upper() == "PART":
|
if self.part and self.part.upper() == "PART":
|
||||||
self.part = None
|
self.part = None
|
||||||
|
# 没有中文标题时,偿试中描述中获取中文名
|
||||||
|
if not self.cn_name and self.en_name and self.subtitle:
|
||||||
|
if self.__is_pinyin(self.en_name):
|
||||||
|
# 英文名是拼音
|
||||||
|
cn_name = self.__get_title_from_description(self.subtitle)
|
||||||
|
if cn_name and len(cn_name) == len(self.en_name.split()):
|
||||||
|
# 中文名和拼音单词数相同,认为是中文名
|
||||||
|
self.cn_name = cn_name
|
||||||
# 制作组/字幕组
|
# 制作组/字幕组
|
||||||
self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None
|
self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None
|
||||||
# 自定义占位符
|
# 自定义占位符
|
||||||
self.customization = CustomizationMatcher().match(title=original_title) or None
|
self.customization = CustomizationMatcher().match(title=original_title) or None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_title_from_description(description: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从描述中提取标题
|
||||||
|
"""
|
||||||
|
if not description:
|
||||||
|
return None
|
||||||
|
titles = re.split(r'[\s/|]+', description)
|
||||||
|
if StringUtils.is_chinese(titles[0]):
|
||||||
|
return titles[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __is_pinyin(name_str: str) -> bool:
|
||||||
|
"""
|
||||||
|
判断是否拼音
|
||||||
|
"""
|
||||||
|
if not name_str:
|
||||||
|
return False
|
||||||
|
for n in name_str.lower().split():
|
||||||
|
if not is_pinyin(n):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def __fix_name(self, name: str):
|
def __fix_name(self, name: str):
|
||||||
|
"""
|
||||||
|
去掉名字中不需要的干扰字符
|
||||||
|
"""
|
||||||
if not name:
|
if not name:
|
||||||
return name
|
return name
|
||||||
name = re.sub(r'%s' % self._name_nostring_re, '', name,
|
name = re.sub(r'%s' % self._name_nostring_re, '', name,
|
||||||
@@ -156,6 +201,9 @@ class MetaVideo(MetaBase):
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
def __init_name(self, token: str):
|
def __init_name(self, token: str):
|
||||||
|
"""
|
||||||
|
识别名称
|
||||||
|
"""
|
||||||
if not token:
|
if not token:
|
||||||
return
|
return
|
||||||
# 回收标题
|
# 回收标题
|
||||||
@@ -182,8 +230,9 @@ class MetaVideo(MetaBase):
|
|||||||
if not self.cn_name:
|
if not self.cn_name:
|
||||||
self.cn_name = token
|
self.cn_name = token
|
||||||
elif not self._stop_cnname_flag:
|
elif not self._stop_cnname_flag:
|
||||||
if not re.search("%s" % self._name_no_chinese_re, token, flags=re.IGNORECASE) \
|
if re.search("%s" % self._name_movie_words, token, flags=re.IGNORECASE) \
|
||||||
and not re.search("%s" % self._name_se_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.cn_name = "%s %s" % (self.cn_name, token)
|
||||||
self._stop_cnname_flag = True
|
self._stop_cnname_flag = True
|
||||||
else:
|
else:
|
||||||
@@ -248,6 +297,9 @@ class MetaVideo(MetaBase):
|
|||||||
self._last_token_type = "enname"
|
self._last_token_type = "enname"
|
||||||
|
|
||||||
def __init_part(self, token: str):
|
def __init_part(self, token: str):
|
||||||
|
"""
|
||||||
|
识别Part
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
if not self.year \
|
if not self.year \
|
||||||
@@ -268,9 +320,12 @@ class MetaVideo(MetaBase):
|
|||||||
self.tokens.get_next()
|
self.tokens.get_next()
|
||||||
self._last_token_type = "part"
|
self._last_token_type = "part"
|
||||||
self._continue_flag = False
|
self._continue_flag = False
|
||||||
self._stop_name_flag = False
|
# self._stop_name_flag = False
|
||||||
|
|
||||||
def __init_year(self, token: str):
|
def __init_year(self, token: str):
|
||||||
|
"""
|
||||||
|
识别年份
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
if not token.isdigit():
|
if not token.isdigit():
|
||||||
@@ -293,6 +348,9 @@ class MetaVideo(MetaBase):
|
|||||||
self._stop_name_flag = True
|
self._stop_name_flag = True
|
||||||
|
|
||||||
def __init_resource_pix(self, token: str):
|
def __init_resource_pix(self, token: str):
|
||||||
|
"""
|
||||||
|
识别分辨率
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
re_res = re.findall(r"%s" % self._resources_pix_re, token, re.IGNORECASE)
|
re_res = re.findall(r"%s" % self._resources_pix_re, token, re.IGNORECASE)
|
||||||
@@ -329,6 +387,9 @@ class MetaVideo(MetaBase):
|
|||||||
self.resource_pix = re_res.group(1).lower()
|
self.resource_pix = re_res.group(1).lower()
|
||||||
|
|
||||||
def __init_season(self, token: str):
|
def __init_season(self, token: str):
|
||||||
|
"""
|
||||||
|
识别季
|
||||||
|
"""
|
||||||
re_res = re.findall(r"%s" % self._season_re, token, re.IGNORECASE)
|
re_res = re.findall(r"%s" % self._season_re, token, re.IGNORECASE)
|
||||||
if re_res:
|
if re_res:
|
||||||
self._last_token_type = "season"
|
self._last_token_type = "season"
|
||||||
@@ -378,6 +439,9 @@ class MetaVideo(MetaBase):
|
|||||||
self.begin_season = 1
|
self.begin_season = 1
|
||||||
|
|
||||||
def __init_episode(self, token: str):
|
def __init_episode(self, token: str):
|
||||||
|
"""
|
||||||
|
识别集
|
||||||
|
"""
|
||||||
re_res = re.findall(r"%s" % self._episode_re, token, re.IGNORECASE)
|
re_res = re.findall(r"%s" % self._episode_re, token, re.IGNORECASE)
|
||||||
if re_res:
|
if re_res:
|
||||||
self._last_token_type = "episode"
|
self._last_token_type = "episode"
|
||||||
@@ -448,6 +512,9 @@ class MetaVideo(MetaBase):
|
|||||||
self._last_token_type = "EPISODE"
|
self._last_token_type = "EPISODE"
|
||||||
|
|
||||||
def __init_resource_type(self, token):
|
def __init_resource_type(self, token):
|
||||||
|
"""
|
||||||
|
识别资源类型
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
source_res = re.search(r"(%s)" % self._source_re, token, re.IGNORECASE)
|
source_res = re.search(r"(%s)" % self._source_re, token, re.IGNORECASE)
|
||||||
@@ -486,6 +553,9 @@ class MetaVideo(MetaBase):
|
|||||||
self._last_token = effect.upper()
|
self._last_token = effect.upper()
|
||||||
|
|
||||||
def __init_video_encode(self, token: str):
|
def __init_video_encode(self, token: str):
|
||||||
|
"""
|
||||||
|
识别视频编码
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
if not self.year \
|
if not self.year \
|
||||||
@@ -526,6 +596,9 @@ class MetaVideo(MetaBase):
|
|||||||
self.video_encode = f"{self.video_encode} 10bit"
|
self.video_encode = f"{self.video_encode} 10bit"
|
||||||
|
|
||||||
def __init_audio_encode(self, token: str):
|
def __init_audio_encode(self, token: str):
|
||||||
|
"""
|
||||||
|
识别音频编码
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
if not self.year \
|
if not self.year \
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import cn2an
|
|||||||
import regex as re
|
import regex as re
|
||||||
|
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.log import logger
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ class WordsMatcher(metaclass=Singleton):
|
|||||||
# 读取自定义识别词
|
# 读取自定义识别词
|
||||||
words: List[str] = self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []
|
words: List[str] = self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []
|
||||||
for word in words:
|
for word in words:
|
||||||
if not word:
|
if not word or word.startswith("#"):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "):
|
if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "):
|
||||||
@@ -52,17 +53,18 @@ class WordsMatcher(metaclass=Singleton):
|
|||||||
strings = word.split(" <> ")
|
strings = word.split(" <> ")
|
||||||
offsets = strings[1].split(" >> ")
|
offsets = strings[1].split(" >> ")
|
||||||
strings[1] = offsets[0]
|
strings[1] = offsets[0]
|
||||||
title, message, state = self.__episode_offset(title, strings[0], strings[1],
|
title, message, state = self.__episode_offset(title, strings[0], strings[1], offsets[1])
|
||||||
offsets[1])
|
|
||||||
else:
|
else:
|
||||||
# 屏蔽词
|
# 屏蔽词
|
||||||
|
if not word.strip():
|
||||||
|
continue
|
||||||
title, message, state = self.__replace_regex(title, word, "")
|
title, message, state = self.__replace_regex(title, word, "")
|
||||||
|
|
||||||
if state:
|
if state:
|
||||||
appley_words.append(word)
|
appley_words.append(word)
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(str(err))
|
logger.warn(f"自定义识别词 {word} 预处理标题失败:{str(err)} - 标题:{title}")
|
||||||
|
|
||||||
return title, appley_words
|
return title, appley_words
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ class WordsMatcher(metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
return re.sub(r'%s' % replaced, r'%s' % replace, title), "", True
|
return re.sub(r'%s' % replaced, r'%s' % replace, title), "", True
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(str(err))
|
logger.warn(f"自定义识别词正则替换失败:{str(err)} - 标题:{title},被替换词:{replaced},替换词:{replace}")
|
||||||
return title, str(err), False
|
return title, str(err), False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -129,5 +131,5 @@ class WordsMatcher(metaclass=Singleton):
|
|||||||
title = re.sub(episode_offset_re, r'%s' % episode_num[1], title)
|
title = re.sub(episode_offset_re, r'%s' % episode_num[1], title)
|
||||||
return title, "", True
|
return title, "", True
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(str(err))
|
logger.warn(f"自定义识别词集数偏移失败:{str(err)} - 标题:{title},前定位词:{front},后定位词:{back},偏移量:{offset}")
|
||||||
return title, str(err), False
|
return title, str(err), False
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
|||||||
# 修正媒体信息
|
# 修正媒体信息
|
||||||
if metainfo.get('tmdbid'):
|
if metainfo.get('tmdbid'):
|
||||||
meta.tmdbid = metainfo['tmdbid']
|
meta.tmdbid = metainfo['tmdbid']
|
||||||
|
if metainfo.get('doubanid'):
|
||||||
|
meta.tmdbid = metainfo['doubanid']
|
||||||
if metainfo.get('type'):
|
if metainfo.get('type'):
|
||||||
meta.type = metainfo['type']
|
meta.type = metainfo['type']
|
||||||
if metainfo.get('begin_season'):
|
if metainfo.get('begin_season'):
|
||||||
@@ -58,12 +60,16 @@ def MetaInfoPath(path: Path) -> MetaBase:
|
|||||||
根据路径识别元数据
|
根据路径识别元数据
|
||||||
:param path: 路径
|
:param path: 路径
|
||||||
"""
|
"""
|
||||||
|
# 文件元数据,不包含后缀
|
||||||
|
file_meta = MetaInfo(title=path.name)
|
||||||
# 上级目录元数据
|
# 上级目录元数据
|
||||||
dir_meta = MetaInfo(title=path.parent.name)
|
dir_meta = MetaInfo(title=path.parent.name)
|
||||||
# 文件元数据,不包含后缀
|
|
||||||
file_meta = MetaInfo(title=path.stem)
|
|
||||||
# 合并元数据
|
# 合并元数据
|
||||||
file_meta.merge(dir_meta)
|
file_meta.merge(dir_meta)
|
||||||
|
# 上上级目录元数据
|
||||||
|
root_meta = MetaInfo(title=path.parent.parent.name)
|
||||||
|
# 合并元数据
|
||||||
|
file_meta.merge(root_meta)
|
||||||
return file_meta
|
return file_meta
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +99,7 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
|
|||||||
"""
|
"""
|
||||||
metainfo = {
|
metainfo = {
|
||||||
'tmdbid': None,
|
'tmdbid': None,
|
||||||
|
'doubanid': None,
|
||||||
'type': None,
|
'type': None,
|
||||||
'begin_season': None,
|
'begin_season': None,
|
||||||
'end_season': None,
|
'end_season': None,
|
||||||
@@ -108,12 +115,16 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
|
|||||||
if not results:
|
if not results:
|
||||||
return title, metainfo
|
return title, metainfo
|
||||||
for result in results:
|
for result in results:
|
||||||
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
|
|
||||||
# 查找tmdbid信息
|
# 查找tmdbid信息
|
||||||
|
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
|
||||||
if tmdbid and tmdbid[0].isdigit():
|
if tmdbid and tmdbid[0].isdigit():
|
||||||
metainfo['tmdbid'] = tmdbid[0]
|
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=)\d+', result)
|
mtype = re.findall(r'(?<=type=)\w+', result)
|
||||||
if mtype:
|
if mtype:
|
||||||
match mtype[0]:
|
match mtype[0]:
|
||||||
case "movie":
|
case "movie":
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Generator, Optional
|
from typing import Generator, Optional, Tuple
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.helper.module import ModuleHelper
|
from app.helper.module import ModuleHelper
|
||||||
@@ -51,6 +51,25 @@ class ModuleManager(metaclass=Singleton):
|
|||||||
if hasattr(module, "stop"):
|
if hasattr(module, "stop"):
|
||||||
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
|
@staticmethod
|
||||||
def check_setting(setting: Optional[tuple]) -> bool:
|
def check_setting(setting: Optional[tuple]) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -59,9 +78,12 @@ class ModuleManager(metaclass=Singleton):
|
|||||||
if not setting:
|
if not setting:
|
||||||
return True
|
return True
|
||||||
switch, value = setting
|
switch, value = setting
|
||||||
if getattr(settings, switch) and value is True:
|
option = getattr(settings, switch)
|
||||||
|
if not option:
|
||||||
|
return False
|
||||||
|
if option and value is True:
|
||||||
return True
|
return True
|
||||||
if value in getattr(settings, switch):
|
if value in option:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import concurrent
|
||||||
|
import concurrent.futures
|
||||||
import traceback
|
import traceback
|
||||||
from typing import List, Any, Dict, Tuple
|
from typing import List, Any, Dict, Tuple, Optional
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.event import eventmanager
|
from app.core.event import eventmanager
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
@@ -12,6 +15,7 @@ from app.schemas.types import SystemConfigKey
|
|||||||
from app.utils.object import ObjectUtils
|
from app.utils.object import ObjectUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
from app.utils.system import SystemUtils
|
||||||
|
|
||||||
|
|
||||||
class PluginManager(metaclass=Singleton):
|
class PluginManager(metaclass=Singleton):
|
||||||
@@ -30,21 +34,19 @@ class PluginManager(metaclass=Singleton):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.siteshelper = SitesHelper()
|
self.siteshelper = SitesHelper()
|
||||||
self.pluginhelper = PluginHelper()
|
self.pluginhelper = PluginHelper()
|
||||||
self.init_config()
|
self.systemconfig = SystemConfigOper()
|
||||||
|
|
||||||
def init_config(self):
|
def init_config(self):
|
||||||
# 配置管理
|
|
||||||
self.systemconfig = SystemConfigOper()
|
|
||||||
# 停止已有插件
|
# 停止已有插件
|
||||||
self.stop()
|
self.stop()
|
||||||
# 启动插件
|
# 启动插件
|
||||||
self.start()
|
self.start()
|
||||||
|
|
||||||
def start(self):
|
def start(self, pid: str = None):
|
||||||
"""
|
"""
|
||||||
启动加载插件
|
启动加载插件
|
||||||
|
:param pid: 插件ID,为空加载所有插件
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 扫描插件目录
|
# 扫描插件目录
|
||||||
plugins = ModuleHelper.load(
|
plugins = ModuleHelper.load(
|
||||||
"app.plugins",
|
"app.plugins",
|
||||||
@@ -54,10 +56,10 @@ class PluginManager(metaclass=Singleton):
|
|||||||
installed_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
installed_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||||
# 排序
|
# 排序
|
||||||
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
|
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
|
||||||
self._running_plugins = {}
|
|
||||||
self._plugins = {}
|
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
plugin_id = plugin.__name__
|
plugin_id = plugin.__name__
|
||||||
|
if pid and plugin_id != pid:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
# 存储Class
|
# 存储Class
|
||||||
self._plugins[plugin_id] = plugin
|
self._plugins[plugin_id] = plugin
|
||||||
@@ -72,55 +74,148 @@ class PluginManager(metaclass=Singleton):
|
|||||||
plugin_obj.init_plugin(self.get_plugin_config(plugin_id))
|
plugin_obj.init_plugin(self.get_plugin_config(plugin_id))
|
||||||
# 存储运行实例
|
# 存储运行实例
|
||||||
self._running_plugins[plugin_id] = plugin_obj
|
self._running_plugins[plugin_id] = plugin_obj
|
||||||
logger.info(f"Plugin Loaded:{plugin_id}")
|
logger.info(f"加载插件:{plugin_id} 版本:{plugin_obj.plugin_version}")
|
||||||
# 设置事件注册状态可用
|
# 启用的插件才设置事件注册状态可用
|
||||||
eventmanager.enable_events_hander(plugin_id)
|
if plugin_obj.get_state():
|
||||||
|
eventmanager.enable_events_hander(plugin_id)
|
||||||
|
else:
|
||||||
|
eventmanager.disable_events_hander(plugin_id)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
|
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
|
||||||
|
|
||||||
def reload_plugin(self, plugin_id: str, conf: dict):
|
def init_plugin(self, plugin_id: str, conf: dict):
|
||||||
"""
|
"""
|
||||||
重新加载插件
|
初始化插件
|
||||||
|
:param plugin_id: 插件ID
|
||||||
|
:param conf: 插件配置
|
||||||
"""
|
"""
|
||||||
if not self._running_plugins.get(plugin_id):
|
if not self._running_plugins.get(plugin_id):
|
||||||
return
|
return
|
||||||
self._running_plugins[plugin_id].init_plugin(conf)
|
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):
|
def stop(self, pid: str = None):
|
||||||
"""
|
"""
|
||||||
停止
|
停止插件服务
|
||||||
|
:param pid: 插件ID,为空停止所有插件
|
||||||
"""
|
"""
|
||||||
# 停止所有插件
|
# 停止插件
|
||||||
for plugin in self._running_plugins.values():
|
for plugin_id, plugin in self._running_plugins.items():
|
||||||
# 关闭数据库
|
if pid and plugin_id != pid:
|
||||||
if hasattr(plugin, "close"):
|
continue
|
||||||
plugin.close()
|
self.__stop_plugin(plugin)
|
||||||
# 关闭插件
|
|
||||||
if hasattr(plugin, "stop_service"):
|
|
||||||
plugin.stop_service()
|
|
||||||
# 清空对像
|
# 清空对像
|
||||||
self._plugins = {}
|
if pid:
|
||||||
self._running_plugins = {}
|
# 清空指定插件
|
||||||
|
if pid in self._running_plugins:
|
||||||
|
self._running_plugins.pop(pid)
|
||||||
|
if pid in self._plugins:
|
||||||
|
self._plugins.pop(pid)
|
||||||
|
else:
|
||||||
|
# 清空
|
||||||
|
self._plugins = {}
|
||||||
|
self._running_plugins = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __stop_plugin(plugin: Any):
|
||||||
|
"""
|
||||||
|
停止插件
|
||||||
|
:param plugin: 插件实例
|
||||||
|
"""
|
||||||
|
# 关闭数据库
|
||||||
|
if hasattr(plugin, "close"):
|
||||||
|
plugin.close()
|
||||||
|
# 关闭插件
|
||||||
|
if hasattr(plugin, "stop_service"):
|
||||||
|
plugin.stop_service()
|
||||||
|
|
||||||
|
def remove_plugin(self, plugin_id: str):
|
||||||
|
"""
|
||||||
|
从内存中移除一个插件
|
||||||
|
:param plugin_id: 插件ID
|
||||||
|
"""
|
||||||
|
self.stop(plugin_id)
|
||||||
|
|
||||||
|
def reload_plugin(self, plugin_id: str):
|
||||||
|
"""
|
||||||
|
将一个插件重新加载到内存
|
||||||
|
:param plugin_id: 插件ID
|
||||||
|
"""
|
||||||
|
# 先移除
|
||||||
|
self.stop(plugin_id)
|
||||||
|
# 重新加载
|
||||||
|
self.start(plugin_id)
|
||||||
|
|
||||||
|
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.id in install_plugins and not self.is_plugin_exists(plugin.id):
|
||||||
|
# 下载安装
|
||||||
|
state, msg = self.pluginhelper.install(pid=plugin.id,
|
||||||
|
repo_url=plugin.repo_url)
|
||||||
|
# 安装失败
|
||||||
|
if not state:
|
||||||
|
logger.error(
|
||||||
|
f"插件 {plugin.plugin_name} v{plugin.plugin_version} 安装失败:{msg}")
|
||||||
|
continue
|
||||||
|
logger.info(f"插件 {plugin.plugin_name} 安装成功,版本:{plugin.plugin_version}")
|
||||||
|
logger.info("第三方插件安装完成")
|
||||||
|
|
||||||
def get_plugin_config(self, pid: str) -> dict:
|
def get_plugin_config(self, pid: str) -> dict:
|
||||||
"""
|
"""
|
||||||
获取插件配置
|
获取插件配置
|
||||||
|
:param pid: 插件ID
|
||||||
"""
|
"""
|
||||||
if not self._plugins.get(pid):
|
if not self._plugins.get(pid):
|
||||||
return {}
|
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:
|
def save_plugin_config(self, pid: str, conf: dict) -> bool:
|
||||||
"""
|
"""
|
||||||
保存插件配置
|
保存插件配置
|
||||||
|
:param pid: 插件ID
|
||||||
|
:param conf: 配置
|
||||||
"""
|
"""
|
||||||
if not self._plugins.get(pid):
|
if not self._plugins.get(pid):
|
||||||
return False
|
return False
|
||||||
return self.systemconfig.set(self._config_key % pid, conf)
|
return self.systemconfig.set(self._config_key % pid, conf)
|
||||||
|
|
||||||
|
def delete_plugin_config(self, pid: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除插件配置
|
||||||
|
:param pid: 插件ID
|
||||||
|
"""
|
||||||
|
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]]:
|
def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取插件表单
|
获取插件表单
|
||||||
|
:param pid: 插件ID
|
||||||
"""
|
"""
|
||||||
if not self._running_plugins.get(pid):
|
if not self._running_plugins.get(pid):
|
||||||
return [], {}
|
return [], {}
|
||||||
@@ -131,6 +226,7 @@ class PluginManager(metaclass=Singleton):
|
|||||||
def get_plugin_page(self, pid: str) -> List[dict]:
|
def get_plugin_page(self, pid: str) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
获取插件页面
|
获取插件页面
|
||||||
|
:param pid: 插件ID
|
||||||
"""
|
"""
|
||||||
if not self._running_plugins.get(pid):
|
if not self._running_plugins.get(pid):
|
||||||
return []
|
return []
|
||||||
@@ -152,7 +248,10 @@ class PluginManager(metaclass=Singleton):
|
|||||||
for _, plugin in self._running_plugins.items():
|
for _, plugin in self._running_plugins.items():
|
||||||
if hasattr(plugin, "get_command") \
|
if hasattr(plugin, "get_command") \
|
||||||
and ObjectUtils.check_method(plugin.get_command):
|
and ObjectUtils.check_method(plugin.get_command):
|
||||||
ret_commands += plugin.get_command() or []
|
try:
|
||||||
|
ret_commands += plugin.get_command() or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取插件命令出错:{str(e)}")
|
||||||
return ret_commands
|
return ret_commands
|
||||||
|
|
||||||
def get_plugin_apis(self) -> List[Dict[str, Any]]:
|
def get_plugin_apis(self) -> List[Dict[str, Any]]:
|
||||||
@@ -170,15 +269,57 @@ class PluginManager(metaclass=Singleton):
|
|||||||
for pid, plugin in self._running_plugins.items():
|
for pid, plugin in self._running_plugins.items():
|
||||||
if hasattr(plugin, "get_api") \
|
if hasattr(plugin, "get_api") \
|
||||||
and ObjectUtils.check_method(plugin.get_api):
|
and ObjectUtils.check_method(plugin.get_api):
|
||||||
apis = plugin.get_api() or []
|
try:
|
||||||
for api in apis:
|
apis = plugin.get_api() or []
|
||||||
api["path"] = f"/{pid}{api['path']}"
|
for api in apis:
|
||||||
ret_apis.extend(apis)
|
api["path"] = f"/{pid}{api['path']}"
|
||||||
|
ret_apis.extend(apis)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取插件 {pid} API出错:{str(e)}")
|
||||||
return ret_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):
|
||||||
|
try:
|
||||||
|
services = plugin.get_service()
|
||||||
|
if services:
|
||||||
|
ret_services.extend(services)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取插件 {pid} 服务出错:{str(e)}")
|
||||||
|
return ret_services
|
||||||
|
|
||||||
|
def get_plugin_attr(self, pid: str, attr: str) -> Any:
|
||||||
|
"""
|
||||||
|
获取插件属性
|
||||||
|
:param pid: 插件ID
|
||||||
|
:param attr: 属性名
|
||||||
|
"""
|
||||||
|
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:
|
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
|
||||||
"""
|
"""
|
||||||
运行插件方法
|
运行插件方法
|
||||||
|
:param pid: 插件ID
|
||||||
|
:param method: 方法名
|
||||||
|
:param args: 参数
|
||||||
|
:param kwargs: 关键字参数
|
||||||
"""
|
"""
|
||||||
if not self._running_plugins.get(pid):
|
if not self._running_plugins.get(pid):
|
||||||
return None
|
return None
|
||||||
@@ -192,143 +333,198 @@ class PluginManager(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return list(self._plugins.keys())
|
return list(self._plugins.keys())
|
||||||
|
|
||||||
def get_online_plugins(self) -> List[Dict[str, dict]]:
|
def get_running_plugin_ids(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
获取所有运行态插件ID
|
||||||
|
"""
|
||||||
|
return list(self._running_plugins.keys())
|
||||||
|
|
||||||
|
def get_online_plugins(self) -> List[schemas.Plugin]:
|
||||||
"""
|
"""
|
||||||
获取所有在线插件信息
|
获取所有在线插件信息
|
||||||
"""
|
"""
|
||||||
# 返回值
|
|
||||||
all_confs = []
|
def __get_plugin_info(market: str) -> Optional[List[schemas.Plugin]]:
|
||||||
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 {}
|
online_plugins = self.pluginhelper.get_plugins(market) or {}
|
||||||
for pid, plugin in online_plugins.items():
|
if not online_plugins:
|
||||||
|
logger.warn(f"获取插件库失败:{market}")
|
||||||
|
return
|
||||||
|
ret_plugins = []
|
||||||
|
for pid, plugin_info in online_plugins.items():
|
||||||
# 运行状插件
|
# 运行状插件
|
||||||
plugin_obj = self._running_plugins.get(pid)
|
plugin_obj = self._running_plugins.get(pid)
|
||||||
# 非运行态插件
|
# 非运行态插件
|
||||||
plugin_static = self._plugins.get(pid)
|
plugin_static = self._plugins.get(pid)
|
||||||
# 基本属性
|
# 基本属性
|
||||||
conf = {}
|
plugin = schemas.Plugin()
|
||||||
# ID
|
# ID
|
||||||
conf.update({"id": pid})
|
plugin.id = pid
|
||||||
# 安装状态
|
# 安装状态
|
||||||
if pid in installed_apps:
|
if pid in installed_apps and plugin_static:
|
||||||
conf.update({"installed": True})
|
plugin.installed = True
|
||||||
else:
|
else:
|
||||||
conf.update({"installed": False})
|
plugin.installed = False
|
||||||
# 是否有新版本
|
# 是否有新版本
|
||||||
conf.update({"has_update": False})
|
plugin.has_update = False
|
||||||
if plugin_static:
|
if plugin_static:
|
||||||
installed_version = getattr(plugin_static, "plugin_version")
|
installed_version = getattr(plugin_static, "plugin_version")
|
||||||
if StringUtils.compare_version(installed_version, plugin.get("version")) < 0:
|
if StringUtils.compare_version(installed_version, plugin_info.get("version")) < 0:
|
||||||
# 需要更新
|
# 需要更新
|
||||||
conf.update({"has_update": True})
|
plugin.has_update = True
|
||||||
# 运行状态
|
# 运行状态
|
||||||
if plugin_obj and hasattr(plugin_obj, "get_state"):
|
if plugin_obj and hasattr(plugin_obj, "get_state"):
|
||||||
conf.update({"state": plugin_obj.get_state()})
|
try:
|
||||||
|
state = plugin_obj.get_state()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
|
||||||
|
state = False
|
||||||
|
plugin.state = state
|
||||||
else:
|
else:
|
||||||
conf.update({"state": False})
|
plugin.state = False
|
||||||
# 是否有详情页面
|
# 是否有详情页面
|
||||||
conf.update({"has_page": False})
|
plugin.has_page = False
|
||||||
if plugin_obj and hasattr(plugin_obj, "get_page"):
|
if plugin_obj and hasattr(plugin_obj, "get_page"):
|
||||||
if ObjectUtils.check_method(plugin_obj.get_page):
|
if ObjectUtils.check_method(plugin_obj.get_page):
|
||||||
conf.update({"has_page": True})
|
plugin.has_page = True
|
||||||
# 权限
|
# 权限
|
||||||
if plugin.get("level"):
|
if plugin_info.get("level"):
|
||||||
conf.update({"auth_level": plugin.get("level")})
|
plugin.auth_level = plugin_info.get("level")
|
||||||
if self.siteshelper.auth_level < plugin.get("level"):
|
if self.siteshelper.auth_level < plugin.auth_level:
|
||||||
continue
|
continue
|
||||||
# 名称
|
# 名称
|
||||||
if plugin.get("name"):
|
if plugin_info.get("name"):
|
||||||
conf.update({"plugin_name": plugin.get("name")})
|
plugin.plugin_name = plugin_info.get("name")
|
||||||
# 描述
|
# 描述
|
||||||
if plugin.get("description"):
|
if plugin_info.get("description"):
|
||||||
conf.update({"plugin_desc": plugin.get("description")})
|
plugin.plugin_desc = plugin_info.get("description")
|
||||||
# 版本
|
# 版本
|
||||||
if plugin.get("version"):
|
if plugin_info.get("version"):
|
||||||
conf.update({"plugin_version": plugin.get("version")})
|
plugin.plugin_version = plugin_info.get("version")
|
||||||
# 图标
|
# 图标
|
||||||
if plugin.get("icon"):
|
if plugin_info.get("icon"):
|
||||||
conf.update({"plugin_icon": plugin.get("icon")})
|
plugin.plugin_icon = plugin_info.get("icon")
|
||||||
# 主题色
|
|
||||||
if plugin.get("color"):
|
|
||||||
conf.update({"plugin_color": plugin.get("color")})
|
|
||||||
# 作者
|
# 作者
|
||||||
if plugin.get("author"):
|
if plugin_info.get("author"):
|
||||||
conf.update({"plugin_author": plugin.get("author")})
|
plugin.plugin_author = plugin_info.get("author")
|
||||||
|
# 更新历史
|
||||||
|
if plugin_info.get("history"):
|
||||||
|
plugin.history = plugin_info.get("history")
|
||||||
# 仓库链接
|
# 仓库链接
|
||||||
conf.update({"repo_url": market})
|
plugin.repo_url = market
|
||||||
# 本地标志
|
# 本地标志
|
||||||
conf.update({"is_local": False})
|
plugin.is_local = False
|
||||||
# 汇总
|
# 汇总
|
||||||
all_confs.append(conf)
|
ret_plugins.append(plugin)
|
||||||
return all_confs
|
|
||||||
|
|
||||||
def get_local_plugins(self) -> List[Dict[str, dict]]:
|
return ret_plugins
|
||||||
|
|
||||||
|
if not settings.PLUGIN_MARKET:
|
||||||
|
return []
|
||||||
|
# 返回值
|
||||||
|
all_plugins = []
|
||||||
|
# 已安装插件
|
||||||
|
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||||
|
# 使用多线程获取线上插件
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
futures = []
|
||||||
|
for m in settings.PLUGIN_MARKET.split(","):
|
||||||
|
futures.append(executor.submit(__get_plugin_info, m))
|
||||||
|
for future in concurrent.futures.as_completed(futures):
|
||||||
|
plugins = future.result()
|
||||||
|
if plugins:
|
||||||
|
all_plugins.extend(plugins)
|
||||||
|
# 所有插件按repo在设置中的顺序排序
|
||||||
|
all_plugins.sort(
|
||||||
|
key=lambda x: settings.PLUGIN_MARKET.split(",").index(x.repo_url) if x.repo_url else 0
|
||||||
|
)
|
||||||
|
# 按插件ID和版本号去重,相同插件以前面的为准
|
||||||
|
result = []
|
||||||
|
_dup = []
|
||||||
|
for p in all_plugins:
|
||||||
|
key = f"{p.id}v{p.plugin_version}"
|
||||||
|
if key not in _dup:
|
||||||
|
_dup.append(key)
|
||||||
|
result.append(p)
|
||||||
|
logger.info(f"共获取到 {len(result)} 个第三方插件")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_local_plugins(self) -> List[schemas.Plugin]:
|
||||||
"""
|
"""
|
||||||
获取所有本地已下载的插件信息
|
获取所有本地已下载的插件信息
|
||||||
"""
|
"""
|
||||||
# 返回值
|
# 返回值
|
||||||
all_confs = []
|
plugins = []
|
||||||
# 已安装插件
|
# 已安装插件
|
||||||
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||||
for pid, plugin in self._plugins.items():
|
for pid, plugin_class in self._plugins.items():
|
||||||
# 运行状插件
|
# 运行状插件
|
||||||
plugin_obj = self._running_plugins.get(pid)
|
plugin_obj = self._running_plugins.get(pid)
|
||||||
# 基本属性
|
# 基本属性
|
||||||
conf = {}
|
plugin = schemas.Plugin()
|
||||||
# ID
|
# ID
|
||||||
conf.update({"id": pid})
|
plugin.id = pid
|
||||||
# 安装状态
|
# 安装状态
|
||||||
if pid in installed_apps:
|
if pid in installed_apps:
|
||||||
conf.update({"installed": True})
|
plugin.installed = True
|
||||||
else:
|
else:
|
||||||
conf.update({"installed": False})
|
plugin.installed = False
|
||||||
# 运行状态
|
# 运行状态
|
||||||
if plugin_obj and hasattr(plugin_obj, "get_state"):
|
if plugin_obj and hasattr(plugin_obj, "get_state"):
|
||||||
conf.update({"state": plugin_obj.get_state()})
|
try:
|
||||||
|
state = plugin_obj.get_state()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
|
||||||
|
state = False
|
||||||
|
plugin.state = state
|
||||||
else:
|
else:
|
||||||
conf.update({"state": False})
|
plugin.state = False
|
||||||
# 是否有详情页面
|
# 是否有详情页面
|
||||||
if hasattr(plugin, "get_page"):
|
if hasattr(plugin_class, "get_page"):
|
||||||
if ObjectUtils.check_method(plugin.get_page):
|
if ObjectUtils.check_method(plugin_class.get_page):
|
||||||
conf.update({"has_page": True})
|
plugin.has_page = True
|
||||||
else:
|
else:
|
||||||
conf.update({"has_page": False})
|
plugin.has_page = False
|
||||||
# 权限
|
# 权限
|
||||||
if hasattr(plugin, "auth_level"):
|
if hasattr(plugin_class, "auth_level"):
|
||||||
conf.update({"auth_level": plugin.auth_level})
|
plugin.auth_level = plugin_class.auth_level
|
||||||
if self.siteshelper.auth_level < plugin.auth_level:
|
if self.siteshelper.auth_level < plugin.auth_level:
|
||||||
continue
|
continue
|
||||||
# 名称
|
# 名称
|
||||||
if hasattr(plugin, "plugin_name"):
|
if hasattr(plugin_class, "plugin_name"):
|
||||||
conf.update({"plugin_name": plugin.plugin_name})
|
plugin.plugin_name = plugin_class.plugin_name
|
||||||
# 描述
|
# 描述
|
||||||
if hasattr(plugin, "plugin_desc"):
|
if hasattr(plugin_class, "plugin_desc"):
|
||||||
conf.update({"plugin_desc": plugin.plugin_desc})
|
plugin.plugin_desc = plugin_class.plugin_desc
|
||||||
# 版本
|
# 版本
|
||||||
if hasattr(plugin, "plugin_version"):
|
if hasattr(plugin_class, "plugin_version"):
|
||||||
conf.update({"plugin_version": plugin.plugin_version})
|
plugin.plugin_version = plugin_class.plugin_version
|
||||||
# 图标
|
# 图标
|
||||||
if hasattr(plugin, "plugin_icon"):
|
if hasattr(plugin_class, "plugin_icon"):
|
||||||
conf.update({"plugin_icon": plugin.plugin_icon})
|
plugin.plugin_icon = plugin_class.plugin_icon
|
||||||
# 主题色
|
|
||||||
if hasattr(plugin, "plugin_color"):
|
|
||||||
conf.update({"plugin_color": plugin.plugin_color})
|
|
||||||
# 作者
|
# 作者
|
||||||
if hasattr(plugin, "plugin_author"):
|
if hasattr(plugin_class, "plugin_author"):
|
||||||
conf.update({"plugin_author": plugin.plugin_author})
|
plugin.plugin_author = plugin_class.plugin_author
|
||||||
# 作者链接
|
# 作者链接
|
||||||
if hasattr(plugin, "author_url"):
|
if hasattr(plugin_class, "author_url"):
|
||||||
conf.update({"author_url": plugin.author_url})
|
plugin.author_url = plugin_class.author_url
|
||||||
# 是否需要更新
|
# 是否需要更新
|
||||||
conf.update({"has_update": False})
|
plugin.has_update = False
|
||||||
# 本地标志
|
# 本地标志
|
||||||
conf.update({"is_local": True})
|
plugin.is_local = True
|
||||||
# 汇总
|
# 汇总
|
||||||
all_confs.append(conf)
|
plugins.append(plugin)
|
||||||
return all_confs
|
return plugins
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_plugin_exists(pid: str) -> bool:
|
||||||
|
"""
|
||||||
|
判断插件是否在本地文件系统存在
|
||||||
|
:param pid: 插件ID
|
||||||
|
"""
|
||||||
|
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 hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import traceback
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Union, Optional
|
from typing import Any, Union, Optional, Annotated
|
||||||
import jwt
|
import jwt
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
from Crypto.Util.Padding import pad
|
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 fastapi.security import OAuth2PasswordBearer
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ from app import schemas
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
@@ -26,7 +29,8 @@ reusable_oauth2 = OAuth2PasswordBearer(
|
|||||||
|
|
||||||
|
|
||||||
def create_access_token(
|
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:
|
) -> str:
|
||||||
if expires_delta:
|
if expires_delta:
|
||||||
expire = datetime.utcnow() + expires_delta
|
expire = datetime.utcnow() + expires_delta
|
||||||
@@ -34,7 +38,12 @@ def create_access_token(
|
|||||||
expire = datetime.utcnow() + timedelta(
|
expire = datetime.utcnow() + timedelta(
|
||||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
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)
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
return encoded_jwt
|
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:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
@@ -68,7 +115,7 @@ def decrypt(data: bytes, key: bytes) -> Optional[bytes]:
|
|||||||
try:
|
try:
|
||||||
return fernet.decrypt(data)
|
return fernet.decrypt(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(str(e))
|
logger.error(f"解密失败:{str(e)} - {traceback.format_exc()}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
from typing import Any, Self, List
|
||||||
from typing import Tuple, Optional, Generator
|
from typing import Tuple, Optional, Generator
|
||||||
|
|
||||||
from sqlalchemy import create_engine, QueuePool
|
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
|
from app.core.config import settings
|
||||||
|
|
||||||
@@ -135,6 +138,52 @@ def db_query(func):
|
|||||||
return wrapper
|
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:
|
class DbOper:
|
||||||
"""
|
"""
|
||||||
数据库操作基类
|
数据库操作基类
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from app.db import DbOper
|
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
|
: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:
|
def get_by_hash(self, download_hash: str) -> DownloadHistory:
|
||||||
"""
|
"""
|
||||||
@@ -57,7 +56,14 @@ class DownloadHistoryOper(DbOper):
|
|||||||
按fullpath查询下载文件记录
|
按fullpath查询下载文件记录
|
||||||
:param fullpath: 数据key
|
: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]:
|
def get_files_by_savepath(self, fullpath: str) -> List[DownloadFiles]:
|
||||||
"""
|
"""
|
||||||
@@ -78,7 +84,7 @@ class DownloadHistoryOper(DbOper):
|
|||||||
按fullpath查询下载文件记录hash
|
按fullpath查询下载文件记录hash
|
||||||
:param fullpath: 数据key
|
:param fullpath: 数据key
|
||||||
"""
|
"""
|
||||||
fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath)
|
fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False)
|
||||||
if fileinfo:
|
if fileinfo:
|
||||||
return fileinfo.download_hash
|
return fileinfo.download_hash
|
||||||
return ""
|
return ""
|
||||||
@@ -115,3 +121,21 @@ class DownloadHistoryOper(DbOper):
|
|||||||
return DownloadHistory.list_by_user_date(db=self._db,
|
return DownloadHistory.list_by_user_date(db=self._db,
|
||||||
date=date,
|
date=date,
|
||||||
username=username)
|
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
|
import random
|
||||||
from pathlib import Path
|
import string
|
||||||
|
|
||||||
from alembic.command import upgrade
|
from alembic.command import upgrade
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import get_password_hash
|
from app.core.security import get_password_hash
|
||||||
from app.db import Engine, SessionFactory
|
from app.db import Engine, SessionFactory, Base
|
||||||
from app.db.models import Base
|
from app.db.models import *
|
||||||
from app.db.models.user import User
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -16,21 +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)
|
Base.metadata.create_all(bind=Engine)
|
||||||
|
|
||||||
|
|
||||||
|
def init_super_user():
|
||||||
|
"""
|
||||||
|
初始化超级管理员
|
||||||
|
"""
|
||||||
# 初始化超级管理员
|
# 初始化超级管理员
|
||||||
with SessionFactory() as db:
|
with SessionFactory() as db:
|
||||||
user = User.get_by_name(db=db, name=settings.SUPERUSER)
|
_user = User.get_by_name(db=db, name=settings.SUPERUSER)
|
||||||
if not user:
|
if not _user:
|
||||||
user = 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,
|
name=settings.SUPERUSER,
|
||||||
hashed_password=get_password_hash(settings.SUPERUSER_PASSWORD),
|
hashed_password=get_password_hash(random_password),
|
||||||
is_superuser=True,
|
is_superuser=True,
|
||||||
)
|
)
|
||||||
user.create(db)
|
_user.create(db)
|
||||||
|
|
||||||
|
|
||||||
def update_db():
|
def update_db():
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class MediaServerOper(DbOper):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def empty(self, server: str):
|
def empty(self, server: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
清空媒体服务器数据
|
清空媒体服务器数据
|
||||||
"""
|
"""
|
||||||
@@ -39,10 +39,12 @@ class MediaServerOper(DbOper):
|
|||||||
# 优先按TMDBID查
|
# 优先按TMDBID查
|
||||||
item = MediaServerItem.exist_by_tmdbid(self._db, tmdbid=kwargs.get("tmdbid"),
|
item = MediaServerItem.exist_by_tmdbid(self._db, tmdbid=kwargs.get("tmdbid"),
|
||||||
mtype=kwargs.get("mtype"))
|
mtype=kwargs.get("mtype"))
|
||||||
else:
|
elif kwargs.get("title"):
|
||||||
# 按标题、类型、年份查
|
# 按标题、类型、年份查
|
||||||
item = MediaServerItem.exists_by_title(self._db, title=kwargs.get("title"),
|
item = MediaServerItem.exists_by_title(self._db, title=kwargs.get("title"),
|
||||||
mtype=kwargs.get("mtype"), year=kwargs.get("year"))
|
mtype=kwargs.get("mtype"), year=kwargs.get("year"))
|
||||||
|
else:
|
||||||
|
return None
|
||||||
if not item:
|
if not item:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
61
app/db/message_oper.py
Normal file
61
app/db/message_oper.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import DbOper
|
||||||
|
from app.db.models.message import Message
|
||||||
|
from app.schemas import MessageChannel, NotificationType
|
||||||
|
|
||||||
|
|
||||||
|
class MessageOper(DbOper):
|
||||||
|
"""
|
||||||
|
消息数据管理
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session = None):
|
||||||
|
super().__init__(db)
|
||||||
|
|
||||||
|
def add(self,
|
||||||
|
channel: MessageChannel = None,
|
||||||
|
mtype: NotificationType = None,
|
||||||
|
title: str = None,
|
||||||
|
text: str = None,
|
||||||
|
image: str = None,
|
||||||
|
link: str = None,
|
||||||
|
userid: str = None,
|
||||||
|
action: int = 1,
|
||||||
|
note: Union[list, dict] = None,
|
||||||
|
**kwargs):
|
||||||
|
"""
|
||||||
|
新增媒体服务器数据
|
||||||
|
:param channel: 消息渠道
|
||||||
|
:param mtype: 消息类型
|
||||||
|
:param title: 标题
|
||||||
|
:param text: 文本内容
|
||||||
|
:param image: 图片
|
||||||
|
:param link: 链接
|
||||||
|
:param userid: 用户ID
|
||||||
|
:param action: 消息方向:0-接收息,1-发送消息
|
||||||
|
:param note: 附件json
|
||||||
|
"""
|
||||||
|
kwargs.update({
|
||||||
|
"channel": channel.value if channel else '',
|
||||||
|
"mtype": mtype.value if mtype else '',
|
||||||
|
"title": title,
|
||||||
|
"text": text,
|
||||||
|
"image": image,
|
||||||
|
"link": link,
|
||||||
|
"userid": userid,
|
||||||
|
"action": action,
|
||||||
|
"reg_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||||
|
"note": json.dumps(note) if note else ''
|
||||||
|
})
|
||||||
|
Message(**kwargs).create(self._db)
|
||||||
|
|
||||||
|
def list_by_page(self, page: int = 1, count: int = 30) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取媒体服务器数据ID
|
||||||
|
"""
|
||||||
|
return Message.list_by_page(self._db, page, count)
|
||||||
@@ -1,52 +1,9 @@
|
|||||||
from typing import Any, Self, List
|
from .downloadhistory import DownloadHistory, DownloadFiles
|
||||||
|
from .mediaserver import MediaServerItem
|
||||||
from sqlalchemy import inspect
|
from .plugindata import PluginData
|
||||||
from sqlalchemy.orm import as_declarative, declared_attr, Session
|
from .site import Site
|
||||||
|
from .siteicon import SiteIcon
|
||||||
from app.db import db_update, db_query
|
from .subscribe import Subscribe
|
||||||
|
from .systemconfig import SystemConfig
|
||||||
|
from .transferhistory import TransferHistory
|
||||||
@as_declarative()
|
from .user import User
|
||||||
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()
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Sequence
|
from sqlalchemy import Column, Integer, String, Sequence
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query
|
from app.db import db_query, db_update, Base
|
||||||
from app.db.models import Base, db_update
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadHistory(Base):
|
class DownloadHistory(Base):
|
||||||
@@ -123,6 +124,34 @@ class DownloadHistory(Base):
|
|||||||
DownloadHistory.id.desc()).all()
|
DownloadHistory.id.desc()).all()
|
||||||
return list(result)
|
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):
|
class DownloadFiles(Base):
|
||||||
"""
|
"""
|
||||||
@@ -157,9 +186,13 @@ class DownloadFiles(Base):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def get_by_fullpath(db: Session, fullpath: str):
|
def get_by_fullpath(db: Session, fullpath: str, all_files: bool = False):
|
||||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
if not all_files:
|
||||||
DownloadFiles.id.desc()).first()
|
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
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
@@ -167,6 +200,7 @@ class DownloadFiles(Base):
|
|||||||
result = db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
|
result = db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
|
||||||
return list(result)
|
return list(result)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
@db_update
|
@db_update
|
||||||
def delete_by_fullpath(db: Session, fullpath: str):
|
def delete_by_fullpath(db: Session, fullpath: str):
|
||||||
db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath,
|
db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath,
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Sequence
|
from sqlalchemy import Column, Integer, String, Sequence
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query
|
from app.db import db_query, db_update, Base
|
||||||
from app.db.models import Base, db_update
|
|
||||||
|
|
||||||
|
|
||||||
class MediaServerItem(Base):
|
class MediaServerItem(Base):
|
||||||
"""
|
"""
|
||||||
站点表
|
媒体服务器媒体条目表
|
||||||
"""
|
"""
|
||||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
# 服务器类型
|
# 服务器类型
|
||||||
@@ -48,8 +48,11 @@ class MediaServerItem(Base):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_update
|
@db_update
|
||||||
def empty(db: Session, server: str):
|
def empty(db: Session, server: Optional[str] = None):
|
||||||
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
|
if server is None:
|
||||||
|
db.query(MediaServerItem).delete()
|
||||||
|
else:
|
||||||
|
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
|
|||||||
39
app/db/models/message.py
Normal file
39
app/db/models/message.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Sequence
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import db_query, Base
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
"""
|
||||||
|
消息表
|
||||||
|
"""
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
# 消息渠道
|
||||||
|
channel = Column(String)
|
||||||
|
# 消息类型
|
||||||
|
mtype = Column(String)
|
||||||
|
# 标题
|
||||||
|
title = Column(String)
|
||||||
|
# 文本内容
|
||||||
|
text = Column(String)
|
||||||
|
# 图片
|
||||||
|
image = Column(String)
|
||||||
|
# 链接
|
||||||
|
link = Column(String)
|
||||||
|
# 用户ID
|
||||||
|
userid = Column(String)
|
||||||
|
# 登记时间
|
||||||
|
reg_time = Column(String, index=True)
|
||||||
|
# 消息方向:0-接收息,1-发送消息
|
||||||
|
action = Column(Integer)
|
||||||
|
# 附件json
|
||||||
|
note = Column(String)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def list_by_page(db: Session, page: int = 1, count: int = 30):
|
||||||
|
result = db.query(Message).order_by(Message.reg_time.desc()).offset((page - 1) * count).limit(
|
||||||
|
count).all()
|
||||||
|
result.sort(key=lambda x: x.reg_time, reverse=False)
|
||||||
|
return list(result)
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Sequence
|
from sqlalchemy import Column, Integer, String, Sequence
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query
|
from app.db import db_query, db_update, Base
|
||||||
from app.db.models import Base, db_update
|
|
||||||
|
|
||||||
|
|
||||||
class PluginData(Base):
|
class PluginData(Base):
|
||||||
@@ -3,8 +3,7 @@ from datetime import datetime
|
|||||||
from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query
|
from app.db import db_query, db_update, Base
|
||||||
from app.db.models import Base, db_update
|
|
||||||
|
|
||||||
|
|
||||||
class Site(Base):
|
class Site(Base):
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Sequence
|
from sqlalchemy import Column, Integer, String, Sequence
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query
|
from app.db import db_query, Base
|
||||||
from app.db.models import Base
|
|
||||||
|
|
||||||
|
|
||||||
class SiteIcon(Base):
|
class SiteIcon(Base):
|
||||||
|
|||||||
37
app/db/models/sitestatistic.py
Normal file
37
app/db/models/sitestatistic.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Sequence
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import db_query, db_update, Base
|
||||||
|
|
||||||
|
|
||||||
|
class SiteStatistic(Base):
|
||||||
|
"""
|
||||||
|
站点统计表
|
||||||
|
"""
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
# 域名Key
|
||||||
|
domain = Column(String, index=True)
|
||||||
|
# 成功次数
|
||||||
|
success = Column(Integer)
|
||||||
|
# 失败次数
|
||||||
|
fail = Column(Integer)
|
||||||
|
# 平均耗时 秒
|
||||||
|
seconds = Column(Integer)
|
||||||
|
# 最后一次访问状态 0-成功 1-失败
|
||||||
|
lst_state = Column(Integer)
|
||||||
|
# 最后访问时间
|
||||||
|
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
# 耗时记录 Json
|
||||||
|
note = Column(String)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_by_domain(db: Session, domain: str):
|
||||||
|
return db.query(SiteStatistic).filter(SiteStatistic.domain == domain).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_update
|
||||||
|
def reset(db: Session):
|
||||||
|
db.query(SiteStatistic).delete()
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Sequence
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Sequence, Float
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_update, db_query
|
from app.db import db_query, db_update, Base
|
||||||
from app.db.models import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Subscribe(Base):
|
class Subscribe(Base):
|
||||||
@@ -22,14 +23,15 @@ class Subscribe(Base):
|
|||||||
imdbid = Column(String)
|
imdbid = Column(String)
|
||||||
tvdbid = Column(Integer)
|
tvdbid = Column(Integer)
|
||||||
doubanid = Column(String, index=True)
|
doubanid = Column(String, index=True)
|
||||||
|
bangumiid = Column(Integer, index=True)
|
||||||
# 季号
|
# 季号
|
||||||
season = Column(Integer)
|
season = Column(Integer)
|
||||||
# 海报
|
# 海报
|
||||||
poster = Column(String)
|
poster = Column(String)
|
||||||
# 背景图
|
# 背景图
|
||||||
backdrop = Column(String)
|
backdrop = Column(String)
|
||||||
# 评分
|
# 评分,float
|
||||||
vote = Column(Integer)
|
vote = Column(Float)
|
||||||
# 简介
|
# 简介
|
||||||
description = Column(String)
|
description = Column(String)
|
||||||
# 过滤规则
|
# 过滤规则
|
||||||
@@ -66,14 +68,24 @@ class Subscribe(Base):
|
|||||||
best_version = Column(Integer, default=0)
|
best_version = Column(Integer, default=0)
|
||||||
# 当前优先级
|
# 当前优先级
|
||||||
current_priority = Column(Integer)
|
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
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def exists(db: Session, tmdbid: int, season: int = None):
|
def exists(db: Session, tmdbid: int = None, doubanid: str = None, season: int = None):
|
||||||
if season:
|
if tmdbid:
|
||||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
|
if season:
|
||||||
Subscribe.season == season).first()
|
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
|
||||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).first()
|
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
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
@@ -93,7 +105,10 @@ class Subscribe(Base):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def get_by_title(db: Session, title: str):
|
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()
|
return db.query(Subscribe).filter(Subscribe.name == title).first()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -101,6 +116,11 @@ class Subscribe(Base):
|
|||||||
def get_by_doubanid(db: Session, doubanid: str):
|
def get_by_doubanid(db: Session, doubanid: str):
|
||||||
return db.query(Subscribe).filter(Subscribe.doubanid == doubanid).first()
|
return db.query(Subscribe).filter(Subscribe.doubanid == doubanid).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_by_bangumiid(db: Session, bangumiid: int):
|
||||||
|
return db.query(Subscribe).filter(Subscribe.bangumiid == bangumiid).first()
|
||||||
|
|
||||||
@db_update
|
@db_update
|
||||||
def delete_by_tmdbid(self, db: Session, tmdbid: int, season: int):
|
def delete_by_tmdbid(self, db: Session, tmdbid: int, season: int):
|
||||||
subscrbies = self.get_by_tmdbid(db, tmdbid, season)
|
subscrbies = self.get_by_tmdbid(db, tmdbid, season)
|
||||||
@@ -114,3 +134,13 @@ class Subscribe(Base):
|
|||||||
if subscribe:
|
if subscribe:
|
||||||
subscribe.delete(db, subscribe.id)
|
subscribe.delete(db, subscribe.id)
|
||||||
return True
|
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)
|
||||||
|
|||||||
72
app/db/models/subscribehistory.py
Normal file
72
app/db/models/subscribehistory.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Sequence, Float
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import db_query, Base
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeHistory(Base):
|
||||||
|
"""
|
||||||
|
订阅历史表
|
||||||
|
"""
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
# 标题
|
||||||
|
name = Column(String, nullable=False, index=True)
|
||||||
|
# 年份
|
||||||
|
year = Column(String)
|
||||||
|
# 类型
|
||||||
|
type = Column(String)
|
||||||
|
# 搜索关键字
|
||||||
|
keyword = Column(String)
|
||||||
|
tmdbid = Column(Integer, index=True)
|
||||||
|
imdbid = Column(String)
|
||||||
|
tvdbid = Column(Integer)
|
||||||
|
doubanid = Column(String, index=True)
|
||||||
|
bangumiid = Column(Integer, index=True)
|
||||||
|
# 季号
|
||||||
|
season = Column(Integer)
|
||||||
|
# 海报
|
||||||
|
poster = Column(String)
|
||||||
|
# 背景图
|
||||||
|
backdrop = Column(String)
|
||||||
|
# 评分,float
|
||||||
|
vote = Column(Float)
|
||||||
|
# 简介
|
||||||
|
description = Column(String)
|
||||||
|
# 过滤规则
|
||||||
|
filter = Column(String)
|
||||||
|
# 包含
|
||||||
|
include = Column(String)
|
||||||
|
# 排除
|
||||||
|
exclude = Column(String)
|
||||||
|
# 质量
|
||||||
|
quality = Column(String)
|
||||||
|
# 分辨率
|
||||||
|
resolution = Column(String)
|
||||||
|
# 特效
|
||||||
|
effect = Column(String)
|
||||||
|
# 总集数
|
||||||
|
total_episode = Column(Integer)
|
||||||
|
# 开始集数
|
||||||
|
start_episode = Column(Integer)
|
||||||
|
# 订阅完成时间
|
||||||
|
date = Column(String)
|
||||||
|
# 订阅用户
|
||||||
|
username = Column(String)
|
||||||
|
# 订阅站点
|
||||||
|
sites = Column(String)
|
||||||
|
# 是否洗版
|
||||||
|
best_version = Column(Integer, default=0)
|
||||||
|
# 保存路径
|
||||||
|
save_path = Column(String)
|
||||||
|
# 是否使用 imdbid 搜索
|
||||||
|
search_imdbid = Column(Integer, default=0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def list_by_type(db: Session, mtype: str, page: int = 1, count: int = 30):
|
||||||
|
result = db.query(SubscribeHistory).filter(
|
||||||
|
SubscribeHistory.type == mtype
|
||||||
|
).order_by(
|
||||||
|
SubscribeHistory.date.desc()
|
||||||
|
).offset((page - 1) * count).limit(count).all()
|
||||||
|
return list(result)
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Sequence
|
from sqlalchemy import Column, Integer, String, Sequence
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_update, db_query
|
from app.db import db_query, db_update, Base
|
||||||
from app.db.models import Base
|
|
||||||
|
|
||||||
|
|
||||||
class SystemConfig(Base):
|
class SystemConfig(Base):
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Sequence, Boolean, func
|
from sqlalchemy import Column, Integer, String, Sequence, Boolean, func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query
|
from app.db import db_query, db_update, Base
|
||||||
from app.db.models import Base, db_update
|
|
||||||
|
|
||||||
|
|
||||||
class TransferHistory(Base):
|
class TransferHistory(Base):
|
||||||
@@ -49,17 +48,35 @@ class TransferHistory(Base):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def list_by_title(db: Session, title: str, page: int = 1, count: int = 30):
|
def list_by_title(db: Session, title: str, page: int = 1, count: int = 30, status: bool = None):
|
||||||
result = db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%')).order_by(
|
if status is not None:
|
||||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
result = db.query(TransferHistory).filter(
|
||||||
count).all()
|
TransferHistory.status == status
|
||||||
|
).order_by(
|
||||||
|
TransferHistory.date.desc()
|
||||||
|
).offset((page - 1) * count).limit(count).all()
|
||||||
|
else:
|
||||||
|
result = db.query(TransferHistory).filter(or_(
|
||||||
|
TransferHistory.src.like(f'%{title}%'),
|
||||||
|
TransferHistory.dest.like(f'%{title}%'),
|
||||||
|
)).order_by(
|
||||||
|
TransferHistory.date.desc()
|
||||||
|
).offset((page - 1) * count).limit(count).all()
|
||||||
return list(result)
|
return list(result)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def list_by_page(db: Session, page: int = 1, count: int = 30):
|
def list_by_page(db: Session, page: int = 1, count: int = 30, status: bool = None):
|
||||||
result = db.query(TransferHistory).order_by(TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
if status is not None:
|
||||||
count).all()
|
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)
|
return list(result)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -93,13 +110,22 @@ class TransferHistory(Base):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def count(db: Session):
|
def count(db: Session, status: bool = None):
|
||||||
return db.query(func.count(TransferHistory.id)).first()[0]
|
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
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def count_by_title(db: Session, title: str):
|
def count_by_title(db: Session, title: str, status: bool = None):
|
||||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%')).first()[0]
|
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)).filter(or_(
|
||||||
|
TransferHistory.src.like(f'%{title}%'),
|
||||||
|
TransferHistory.dest.like(f'%{title}%')
|
||||||
|
)).first()[0]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
from typing import Tuple, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.security import verify_password
|
from app.core.security import verify_password
|
||||||
from app.db import db_update, db_query
|
from app.db import db_query, db_update, Base
|
||||||
from app.db.models import Base
|
from app.schemas import User
|
||||||
|
from app.utils.otp import OtpUtils
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@@ -24,16 +27,23 @@ class User(Base):
|
|||||||
is_superuser = Column(Boolean(), default=False)
|
is_superuser = Column(Boolean(), default=False)
|
||||||
# 头像
|
# 头像
|
||||||
avatar = Column(String)
|
avatar = Column(String)
|
||||||
|
# 是否启用otp二次验证
|
||||||
|
is_otp = Column(Boolean(), default=False)
|
||||||
|
# otp秘钥
|
||||||
|
otp_secret = Column(String, default=None)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def authenticate(db: Session, name: str, password: str):
|
def authenticate(db: Session, name: str, password: str, otp_password: str) -> Tuple[bool, Optional[User]]:
|
||||||
user = db.query(User).filter(User.name == name).first()
|
user = db.query(User).filter(User.name == name).first()
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return False, None
|
||||||
if not verify_password(password, str(user.hashed_password)):
|
if not verify_password(password, str(user.hashed_password)):
|
||||||
return None
|
return False, user
|
||||||
return user
|
if user.is_otp:
|
||||||
|
if not otp_password or not OtpUtils.check(user.otp_secret, otp_password):
|
||||||
|
return False, user
|
||||||
|
return True, user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
@@ -46,3 +56,14 @@ class User(Base):
|
|||||||
if user:
|
if user:
|
||||||
user.delete(db, user.id)
|
user.delete(db, user.id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@db_update
|
||||||
|
def update_otp_by_name(self, db: Session, name: str, otp: bool, secret: str):
|
||||||
|
user = self.get_by_name(db, name)
|
||||||
|
if user:
|
||||||
|
user.update(db, {
|
||||||
|
'is_otp': otp,
|
||||||
|
'otp_secret': secret
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import json
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.db import DbOper
|
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
|
from app.utils.object import ObjectUtils
|
||||||
|
|
||||||
|
|
||||||
@@ -28,18 +28,21 @@ class PluginDataOper(DbOper):
|
|||||||
else:
|
else:
|
||||||
PluginData(plugin_id=plugin_id, key=key, value=value).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 plugin_id: 插件id
|
||||||
:param key: 数据key
|
:param key: 数据key
|
||||||
"""
|
"""
|
||||||
data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
|
if key:
|
||||||
if not data:
|
data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
|
||||||
return None
|
if not data:
|
||||||
if ObjectUtils.is_obj(data.value):
|
return None
|
||||||
return json.loads(data.value)
|
if ObjectUtils.is_obj(data.value):
|
||||||
return 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:
|
def del_data(self, plugin_id: str, key: str) -> Any:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ class SiteIconOper(DbOper):
|
|||||||
更新站点图标
|
更新站点图标
|
||||||
"""
|
"""
|
||||||
icon_base64 = f"data:image/ico;base64,{icon_base64}" if icon_base64 else ""
|
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)
|
siteicon = self.get_by_domain(domain)
|
||||||
if not self.get_by_domain(domain):
|
if not siteicon:
|
||||||
siteicon.create(self._db)
|
SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64).create(self._db)
|
||||||
elif icon_base64:
|
elif icon_base64:
|
||||||
siteicon.update(self._db, {
|
siteicon.update(self._db, {
|
||||||
"url": icon_url,
|
"url": icon_url,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import Tuple, List
|
from typing import Tuple, List
|
||||||
|
|
||||||
@@ -15,14 +16,22 @@ 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:
|
if not subscribe:
|
||||||
|
if kwargs.get("sites") and not isinstance(kwargs.get("sites"), str):
|
||||||
|
kwargs["sites"] = json.dumps(kwargs.get("sites"))
|
||||||
|
|
||||||
subscribe = Subscribe(name=mediainfo.title,
|
subscribe = Subscribe(name=mediainfo.title,
|
||||||
year=mediainfo.year,
|
year=mediainfo.year,
|
||||||
type=mediainfo.type.value,
|
type=mediainfo.type.value,
|
||||||
tmdbid=mediainfo.tmdb_id,
|
tmdbid=mediainfo.tmdb_id,
|
||||||
imdbid=mediainfo.imdb_id,
|
imdbid=mediainfo.imdb_id,
|
||||||
tvdbid=mediainfo.tvdb_id,
|
tvdbid=mediainfo.tvdb_id,
|
||||||
|
doubanid=mediainfo.douban_id,
|
||||||
|
bangumiid=mediainfo.bangumi_id,
|
||||||
poster=mediainfo.get_poster_image(),
|
poster=mediainfo.get_poster_image(),
|
||||||
backdrop=mediainfo.get_backdrop_image(),
|
backdrop=mediainfo.get_backdrop_image(),
|
||||||
vote=mediainfo.vote_average,
|
vote=mediainfo.vote_average,
|
||||||
@@ -31,19 +40,26 @@ class SubscribeOper(DbOper):
|
|||||||
**kwargs)
|
**kwargs)
|
||||||
subscribe.create(self._db)
|
subscribe.create(self._db)
|
||||||
# 查询订阅
|
# 查询订阅
|
||||||
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'))
|
||||||
return subscribe.id, "新增订阅成功"
|
return subscribe.id, "新增订阅成功"
|
||||||
else:
|
else:
|
||||||
return subscribe.id, "订阅已存在"
|
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:
|
if tmdbid:
|
||||||
return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False
|
if season:
|
||||||
else:
|
return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False
|
||||||
return True if Subscribe.exists(self._db, tmdbid=tmdbid) 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:
|
def get(self, sid: int) -> Subscribe:
|
||||||
"""
|
"""
|
||||||
@@ -72,3 +88,9 @@ class SubscribeOper(DbOper):
|
|||||||
subscribe = self.get(sid)
|
subscribe = self.get(sid)
|
||||||
subscribe.update(self._db, payload)
|
subscribe.update(self._db, payload)
|
||||||
return subscribe
|
return subscribe
|
||||||
|
|
||||||
|
def list_by_type(self, mtype: str, days: int = 7) -> Subscribe:
|
||||||
|
"""
|
||||||
|
获取指定类型的订阅
|
||||||
|
"""
|
||||||
|
return Subscribe.list_by_type(self._db, mtype=mtype, days=days)
|
||||||
|
|||||||
27
app/db/subscribehistory_oper.py
Normal file
27
app/db/subscribehistory_oper.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from app.db import DbOper
|
||||||
|
from app.db.models.subscribehistory import SubscribeHistory
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeHistoryOper(DbOper):
|
||||||
|
"""
|
||||||
|
订阅历史管理
|
||||||
|
"""
|
||||||
|
|
||||||
|
def add(self, **kwargs):
|
||||||
|
"""
|
||||||
|
新增订阅
|
||||||
|
"""
|
||||||
|
# 去除kwargs中 SubscribeHistory 没有的字段
|
||||||
|
kwargs = {k: v for k, v in kwargs.items() if hasattr(SubscribeHistory, k)}
|
||||||
|
# 更新完成订阅时间
|
||||||
|
kwargs.update({"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())})
|
||||||
|
subscribe = SubscribeHistory(**kwargs)
|
||||||
|
subscribe.create(self._db)
|
||||||
|
|
||||||
|
def list_by_type(self, mtype: str, page: int = 1, count: int = 30) -> SubscribeHistory:
|
||||||
|
"""
|
||||||
|
获取指定类型的订阅
|
||||||
|
"""
|
||||||
|
return SubscribeHistory.list_by_type(self._db, mtype=mtype, page=page, count=count)
|
||||||
@@ -56,6 +56,26 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
|||||||
return self.__SYSTEMCONF
|
return self.__SYSTEMCONF
|
||||||
return self.__SYSTEMCONF.get(key)
|
return self.__SYSTEMCONF.get(key)
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
"""
|
||||||
|
获取所有系统设置
|
||||||
|
"""
|
||||||
|
return self.__SYSTEMCONF or {}
|
||||||
|
|
||||||
|
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):
|
def __del__(self):
|
||||||
if self._db:
|
if self._db:
|
||||||
self._db.close()
|
self._db.close()
|
||||||
|
|||||||
70
app/db/sytestatistic_oper.py
Normal file
70
app/db/sytestatistic_oper.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.db import DbOper
|
||||||
|
from app.db.models.sitestatistic import SiteStatistic
|
||||||
|
|
||||||
|
|
||||||
|
class SiteStatisticOper(DbOper):
|
||||||
|
"""
|
||||||
|
站点统计管理
|
||||||
|
"""
|
||||||
|
|
||||||
|
def success(self, domain: str, seconds: int = None):
|
||||||
|
"""
|
||||||
|
站点访问成功
|
||||||
|
"""
|
||||||
|
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
sta = SiteStatistic.get_by_domain(self._db, domain)
|
||||||
|
if sta:
|
||||||
|
avg_seconds, note = None, {}
|
||||||
|
if seconds is not None:
|
||||||
|
note: dict = json.loads(sta.note or "{}")
|
||||||
|
note[lst_date] = seconds or 1
|
||||||
|
avg_times = len(note.keys())
|
||||||
|
if avg_times > 10:
|
||||||
|
note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10])
|
||||||
|
avg_seconds = sum([v for v in note.values()]) // avg_times
|
||||||
|
sta.update(self._db, {
|
||||||
|
"success": sta.success + 1,
|
||||||
|
"seconds": avg_seconds or sta.seconds,
|
||||||
|
"lst_state": 0,
|
||||||
|
"lst_mod_date": lst_date,
|
||||||
|
"note": json.dumps(note) if note else sta.note
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
note = {}
|
||||||
|
if seconds is not None:
|
||||||
|
note = {
|
||||||
|
lst_date: seconds or 1
|
||||||
|
}
|
||||||
|
SiteStatistic(
|
||||||
|
domain=domain,
|
||||||
|
success=1,
|
||||||
|
fail=0,
|
||||||
|
seconds=seconds or 1,
|
||||||
|
lst_state=0,
|
||||||
|
lst_mod_date=lst_date,
|
||||||
|
note=json.dumps(note)
|
||||||
|
).create(self._db)
|
||||||
|
|
||||||
|
def fail(self, domain: str):
|
||||||
|
"""
|
||||||
|
站点访问失败
|
||||||
|
"""
|
||||||
|
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
sta = SiteStatistic.get_by_domain(self._db, domain)
|
||||||
|
if sta:
|
||||||
|
sta.update(self._db, {
|
||||||
|
"fail": sta.fail + 1,
|
||||||
|
"lst_state": 1,
|
||||||
|
"lst_mod_date": lst_date
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
SiteStatistic(
|
||||||
|
domain=domain,
|
||||||
|
success=0,
|
||||||
|
fail=1,
|
||||||
|
lst_state=1,
|
||||||
|
lst_mod_date=lst_date
|
||||||
|
).create(self._db)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from .doh import doh_query_json
|
||||||
|
from .cloudflare import under_challenge
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class PlaywrightHelper:
|
|||||||
ua: str = None,
|
ua: str = None,
|
||||||
proxies: dict = None,
|
proxies: dict = None,
|
||||||
headless: bool = False,
|
headless: bool = False,
|
||||||
timeout: int = 30) -> str:
|
timeout: int = 20) -> str:
|
||||||
"""
|
"""
|
||||||
获取网页源码
|
获取网页源码
|
||||||
:param url: 网页地址
|
:param url: 网页地址
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from playwright.sync_api import Page
|
|||||||
|
|
||||||
from app.helper.browser import PlaywrightHelper
|
from app.helper.browser import PlaywrightHelper
|
||||||
from app.helper.ocr import OcrHelper
|
from app.helper.ocr import OcrHelper
|
||||||
|
from app.helper.twofa import TwoFactorAuth
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.site import SiteUtils
|
from app.utils.site import SiteUtils
|
||||||
@@ -51,7 +52,8 @@ class CookieHelper:
|
|||||||
],
|
],
|
||||||
"twostep": [
|
"twostep": [
|
||||||
'//input[@name="two_step_code"]',
|
'//input[@name="two_step_code"]',
|
||||||
'//input[@name="2fa_secret"]'
|
'//input[@name="2fa_secret"]',
|
||||||
|
'//input[@name="otp"]'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +73,14 @@ class CookieHelper:
|
|||||||
url: str,
|
url: str,
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
|
two_step_code: str = None,
|
||||||
proxies: dict = None) -> Tuple[Optional[str], Optional[str], str]:
|
proxies: dict = None) -> Tuple[Optional[str], Optional[str], str]:
|
||||||
"""
|
"""
|
||||||
获取站点cookie和ua
|
获取站点cookie和ua
|
||||||
:param url: 站点地址
|
:param url: 站点地址
|
||||||
:param username: 用户名
|
:param username: 用户名
|
||||||
:param password: 密码
|
:param password: 密码
|
||||||
|
:param two_step_code: 二步验证码或密钥
|
||||||
:param proxies: 代理
|
:param proxies: 代理
|
||||||
:return: cookie、ua、message
|
:return: cookie、ua、message
|
||||||
"""
|
"""
|
||||||
@@ -107,6 +111,15 @@ class CookieHelper:
|
|||||||
break
|
break
|
||||||
if not password_xpath:
|
if not password_xpath:
|
||||||
return None, None, "未找到密码输入框"
|
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
|
captcha_xpath = None
|
||||||
for xpath in self._SITE_LOGIN_XPATH.get("captcha"):
|
for xpath in self._SITE_LOGIN_XPATH.get("captcha"):
|
||||||
@@ -138,6 +151,9 @@ class CookieHelper:
|
|||||||
page.fill(username_xpath, username)
|
page.fill(username_xpath, username)
|
||||||
# 输入密码
|
# 输入密码
|
||||||
page.fill(password_xpath, password)
|
page.fill(password_xpath, password)
|
||||||
|
# 输入二步验证码
|
||||||
|
if twostep_xpath:
|
||||||
|
page.fill(twostep_xpath, otp_code)
|
||||||
# 识别验证码
|
# 识别验证码
|
||||||
if captcha_xpath and captcha_img_url:
|
if captcha_xpath and captcha_img_url:
|
||||||
captcha_element = page.query_selector(captcha_xpath)
|
captcha_element = page.query_selector(captcha_xpath)
|
||||||
@@ -164,6 +180,24 @@ class CookieHelper:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"仿真登录失败:{str(e)}")
|
logger.error(f"仿真登录失败:{str(e)}")
|
||||||
return None, None, 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()
|
html_text = page.content()
|
||||||
if not html_text:
|
if not html_text:
|
||||||
|
|||||||
@@ -1,68 +1,126 @@
|
|||||||
from typing import Tuple, Optional
|
import json
|
||||||
|
from hashlib import md5
|
||||||
|
from typing import Any, Dict, Tuple, Optional
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.utils.common import decrypt
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
|
|
||||||
class CookieCloudHelper:
|
class CookieCloudHelper:
|
||||||
|
|
||||||
_ignore_cookies: list = ["CookieAutoDeleteBrowsingDataCleanup", "CookieAutoDeleteCleaningDiscarded"]
|
_ignore_cookies: list = ["CookieAutoDeleteBrowsingDataCleanup", "CookieAutoDeleteCleaningDiscarded"]
|
||||||
|
|
||||||
def __init__(self, server, key, password):
|
def __init__(self):
|
||||||
self._server = server
|
self._sync_setting()
|
||||||
self._key = key
|
|
||||||
self._password = password
|
|
||||||
self._req = RequestUtils(content_type="application/json")
|
self._req = RequestUtils(content_type="application/json")
|
||||||
|
|
||||||
|
def _sync_setting(self):
|
||||||
|
self._server = settings.COOKIECLOUD_HOST
|
||||||
|
self._key = settings.COOKIECLOUD_KEY
|
||||||
|
self._password = settings.COOKIECLOUD_PASSWORD
|
||||||
|
self._enable_local = settings.COOKIECLOUD_ENABLE_LOCAL
|
||||||
|
self._local_path = settings.COOKIE_PATH
|
||||||
|
|
||||||
def download(self) -> Tuple[Optional[dict], str]:
|
def download(self) -> Tuple[Optional[dict], str]:
|
||||||
"""
|
"""
|
||||||
从CookieCloud下载数据
|
从CookieCloud下载数据
|
||||||
:return: Cookie数据、错误信息
|
:return: Cookie数据、错误信息
|
||||||
"""
|
"""
|
||||||
if not self._server or not self._key or not self._password:
|
# 更新为最新设置
|
||||||
|
self._sync_setting()
|
||||||
|
|
||||||
|
if ((not self._server and not self._enable_local)
|
||||||
|
or not self._key
|
||||||
|
or not self._password):
|
||||||
return None, "CookieCloud参数不正确"
|
return None, "CookieCloud参数不正确"
|
||||||
req_url = "%s/get/%s" % (self._server, self._key)
|
|
||||||
ret = self._req.post_res(url=req_url, json={"password": self._password})
|
if self._enable_local:
|
||||||
if ret and ret.status_code == 200:
|
# 开启本地服务时,从本地直接读取数据
|
||||||
result = ret.json()
|
result = self._load_local_encrypt_data(self._key)
|
||||||
if not result:
|
if not result:
|
||||||
return {}, "未下载到数据"
|
return {}, "未从本地CookieCloud服务加载到cookie数据,请检查服务器设置、用户KEY及加密密码是否正确"
|
||||||
if result.get("cookie_data"):
|
|
||||||
contents = result.get("cookie_data")
|
|
||||||
else:
|
|
||||||
contents = result
|
|
||||||
# 整理数据,使用domain域名的最后两级作为分组依据
|
|
||||||
domain_groups = {}
|
|
||||||
for site, cookies in contents.items():
|
|
||||||
for cookie in cookies:
|
|
||||||
domain_key = StringUtils.get_url_domain(cookie.get("domain"))
|
|
||||||
if not domain_groups.get(domain_key):
|
|
||||||
domain_groups[domain_key] = [cookie]
|
|
||||||
else:
|
|
||||||
domain_groups[domain_key].append(cookie)
|
|
||||||
# 返回错误
|
|
||||||
ret_cookies = {}
|
|
||||||
# 索引器
|
|
||||||
for domain, content_list in domain_groups.items():
|
|
||||||
if not content_list:
|
|
||||||
continue
|
|
||||||
# 只有cf的cookie过滤掉
|
|
||||||
cloudflare_cookie = True
|
|
||||||
for content in content_list:
|
|
||||||
if content["name"] != "cf_clearance":
|
|
||||||
cloudflare_cookie = False
|
|
||||||
break
|
|
||||||
if cloudflare_cookie:
|
|
||||||
continue
|
|
||||||
# 站点Cookie
|
|
||||||
cookie_str = ";".join(
|
|
||||||
[f"{content.get('name')}={content.get('value')}"
|
|
||||||
for content in content_list
|
|
||||||
if content.get("name") and content.get("name") not in self._ignore_cookies]
|
|
||||||
)
|
|
||||||
ret_cookies[domain] = cookie_str
|
|
||||||
return ret_cookies, ""
|
|
||||||
elif ret:
|
|
||||||
return None, f"同步CookieCloud失败,错误码:{ret.status_code}"
|
|
||||||
else:
|
else:
|
||||||
return None, "CookieCloud请求失败,请检查服务器地址、用户KEY及加密密码是否正确"
|
req_url = "%s/get/%s" % (self._server, str(self._key).strip())
|
||||||
|
ret = self._req.get_res(url=req_url)
|
||||||
|
if ret and ret.status_code == 200:
|
||||||
|
try:
|
||||||
|
result = ret.json()
|
||||||
|
if not result:
|
||||||
|
return {}, f"未从{self._server}下载到cookie数据"
|
||||||
|
except Exception as err:
|
||||||
|
return {}, f"从{self._server}下载cookie数据错误:{str(err)}"
|
||||||
|
elif ret:
|
||||||
|
return None, f"远程同步CookieCloud失败,错误码:{ret.status_code}"
|
||||||
|
else:
|
||||||
|
return None, "CookieCloud请求失败,请检查服务器地址、用户KEY及加密密码是否正确"
|
||||||
|
|
||||||
|
encrypted = result.get("encrypted")
|
||||||
|
if not encrypted:
|
||||||
|
return {}, "未获取到cookie密文"
|
||||||
|
else:
|
||||||
|
crypt_key = self._get_crypt_key()
|
||||||
|
try:
|
||||||
|
decrypted_data = decrypt(encrypted, crypt_key).decode('utf-8')
|
||||||
|
result = json.loads(decrypted_data)
|
||||||
|
except Exception as e:
|
||||||
|
return {}, "cookie解密失败:" + str(e)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return {}, "cookie解密为空"
|
||||||
|
|
||||||
|
if result.get("cookie_data"):
|
||||||
|
contents = result.get("cookie_data")
|
||||||
|
else:
|
||||||
|
contents = result
|
||||||
|
# 整理数据,使用domain域名的最后两级作为分组依据
|
||||||
|
domain_groups = {}
|
||||||
|
for site, cookies in contents.items():
|
||||||
|
for cookie in cookies:
|
||||||
|
domain_key = StringUtils.get_url_domain(cookie.get("domain"))
|
||||||
|
if not domain_groups.get(domain_key):
|
||||||
|
domain_groups[domain_key] = [cookie]
|
||||||
|
else:
|
||||||
|
domain_groups[domain_key].append(cookie)
|
||||||
|
# 返回错误
|
||||||
|
ret_cookies = {}
|
||||||
|
# 索引器
|
||||||
|
for domain, content_list in domain_groups.items():
|
||||||
|
if not content_list:
|
||||||
|
continue
|
||||||
|
# 只有cf的cookie过滤掉
|
||||||
|
cloudflare_cookie = True
|
||||||
|
for content in content_list:
|
||||||
|
if content["name"] != "cf_clearance":
|
||||||
|
cloudflare_cookie = False
|
||||||
|
break
|
||||||
|
if cloudflare_cookie:
|
||||||
|
continue
|
||||||
|
# 站点Cookie
|
||||||
|
cookie_str = ";".join(
|
||||||
|
[f"{content.get('name')}={content.get('value')}"
|
||||||
|
for content in content_list
|
||||||
|
if content.get("name") and content.get("name") not in self._ignore_cookies]
|
||||||
|
)
|
||||||
|
ret_cookies[domain] = cookie_str
|
||||||
|
return ret_cookies, ""
|
||||||
|
|
||||||
|
def _get_crypt_key(self) -> bytes:
|
||||||
|
"""
|
||||||
|
使用UUID和密码生成CookieCloud的加解密密钥
|
||||||
|
"""
|
||||||
|
md5_generator = md5()
|
||||||
|
md5_generator.update((str(self._key).strip() + '-' + str(self._password).strip()).encode('utf-8'))
|
||||||
|
return (md5_generator.hexdigest()[:16]).encode('utf-8')
|
||||||
|
|
||||||
|
def _load_local_encrypt_data(self, uuid: str) -> Dict[str, Any]:
|
||||||
|
file_path = self._local_path / f"{uuid}.json"
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not file_path.exists():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 读取文件
|
||||||
|
with open(file_path, encoding="utf-8", mode="r") as file:
|
||||||
|
read_content = file.read()
|
||||||
|
data = json.loads(read_content.encode("utf-8"))
|
||||||
|
return data
|
||||||
|
|||||||
156
app/helper/doh.py
Normal file
156
app/helper/doh.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
doh函数的实现。
|
||||||
|
author: https://github.com/C5H12O5/syno-videoinfo-plugin
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import concurrent
|
||||||
|
import concurrent.futures
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import urllib
|
||||||
|
import urllib.request
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
# 定义一个全局集合来存储注册的主机
|
||||||
|
_registered_hosts = {
|
||||||
|
'api.themoviedb.org',
|
||||||
|
'api.tmdb.org',
|
||||||
|
'webservice.fanart.tv',
|
||||||
|
'api.github.com',
|
||||||
|
'github.com',
|
||||||
|
'raw.githubusercontent.com',
|
||||||
|
'api.telegram.org'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 定义一个全局线程池执行器
|
||||||
|
_executor = concurrent.futures.ThreadPoolExecutor()
|
||||||
|
|
||||||
|
# 定义默认的DoH配置
|
||||||
|
_doh_timeout = 5
|
||||||
|
_doh_cache: Dict[str, str] = {}
|
||||||
|
_doh_resolvers = [
|
||||||
|
# https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https
|
||||||
|
"1.0.0.1",
|
||||||
|
"1.1.1.1",
|
||||||
|
# https://support.quad9.net/hc/en-us
|
||||||
|
"9.9.9.9",
|
||||||
|
"149.112.112.112"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_getaddrinfo(host, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
socket.getaddrinfo的补丁版本。
|
||||||
|
"""
|
||||||
|
if host not in _registered_hosts:
|
||||||
|
return _orig_getaddrinfo(host, *args, **kwargs)
|
||||||
|
|
||||||
|
# 检查主机是否已解析
|
||||||
|
if host in _doh_cache:
|
||||||
|
ip = _doh_cache[host]
|
||||||
|
logger.info("已解析 [%s] 为 [%s] (缓存)", host, ip)
|
||||||
|
return _orig_getaddrinfo(ip, *args, **kwargs)
|
||||||
|
|
||||||
|
# 使用DoH解析主机
|
||||||
|
futures = []
|
||||||
|
for resolver in _doh_resolvers:
|
||||||
|
futures.append(_executor.submit(_doh_query, resolver, host))
|
||||||
|
|
||||||
|
for future in concurrent.futures.as_completed(futures):
|
||||||
|
ip = future.result()
|
||||||
|
if ip is not None:
|
||||||
|
logger.info("已解析 [%s] 为 [%s]", host, ip)
|
||||||
|
_doh_cache[host] = ip
|
||||||
|
host = ip
|
||||||
|
break
|
||||||
|
|
||||||
|
return _orig_getaddrinfo(host, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# 对 socket.getaddrinfo 进行补丁
|
||||||
|
if settings.DOH_ENABLE:
|
||||||
|
_orig_getaddrinfo = socket.getaddrinfo
|
||||||
|
socket.getaddrinfo = _patched_getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
|
def _doh_query(resolver: str, host: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
使用给定的DoH解析器查询给定主机的IP地址。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 构造DNS查询消息(RFC 1035)
|
||||||
|
header = b"".join(
|
||||||
|
[
|
||||||
|
b"\x00\x00", # ID: 0
|
||||||
|
b"\x01\x00", # FLAGS: 标准递归查询
|
||||||
|
b"\x00\x01", # QDCOUNT: 1
|
||||||
|
b"\x00\x00", # ANCOUNT: 0
|
||||||
|
b"\x00\x00", # NSCOUNT: 0
|
||||||
|
b"\x00\x00", # ARCOUNT: 0
|
||||||
|
]
|
||||||
|
)
|
||||||
|
question = b"".join(
|
||||||
|
[
|
||||||
|
b"".join(
|
||||||
|
[
|
||||||
|
struct.pack("B", len(item)) + item.encode("utf-8")
|
||||||
|
for item in host.split(".")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ b"\x00", # QNAME: 域名序列
|
||||||
|
b"\x00\x01", # QTYPE: A
|
||||||
|
b"\x00\x01", # QCLASS: IN
|
||||||
|
]
|
||||||
|
)
|
||||||
|
message = header + question
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 发送GET请求到DoH解析器(RFC 8484)
|
||||||
|
b64message = base64.b64encode(message).decode("utf-8").rstrip("=")
|
||||||
|
url = f"https://{resolver}/dns-query?dns={b64message}"
|
||||||
|
headers = {"Content-Type": "application/dns-message"}
|
||||||
|
logger.debug("DoH请求: %s", url)
|
||||||
|
|
||||||
|
request = urllib.request.Request(url, headers=headers, method="GET")
|
||||||
|
with urllib.request.urlopen(request, timeout=_doh_timeout) as response:
|
||||||
|
logger.debug("解析器(%s)响应: %s", resolver, response.status)
|
||||||
|
if response.status != 200:
|
||||||
|
return None
|
||||||
|
resp_body = response.read()
|
||||||
|
|
||||||
|
# 解析DNS响应消息(RFC 1035)
|
||||||
|
# name(压缩):2 + type:2 + class:2 + ttl:4 + rdlength:2 = 12字节
|
||||||
|
first_rdata_start = len(header) + len(question) + 12
|
||||||
|
# rdata(A记录)= 4字节
|
||||||
|
first_rdata_end = first_rdata_start + 4
|
||||||
|
# 将rdata转换为IP地址
|
||||||
|
return socket.inet_ntoa(resp_body[first_rdata_start:first_rdata_end])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("解析器(%s)请求错误: %s", resolver, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def doh_query_json(resolver: str, host: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
使用给定的DoH解析器查询给定主机的IP地址。
|
||||||
|
"""
|
||||||
|
url = f"https://{resolver}/dns-query?name={host}&type=A"
|
||||||
|
headers = {"Accept": "application/dns-json"}
|
||||||
|
logger.debug("DoH请求: %s", url)
|
||||||
|
try:
|
||||||
|
request = urllib.request.Request(url, headers=headers, method="GET")
|
||||||
|
with urllib.request.urlopen(request, timeout=_doh_timeout) as response:
|
||||||
|
logger.debug("解析器(%s)响应: %s", resolver, response.status)
|
||||||
|
if response.status != 200:
|
||||||
|
return None
|
||||||
|
response_body = response.read().decode("utf-8")
|
||||||
|
logger.debug("<== body: %s", response_body)
|
||||||
|
answer = json.loads(response_body)["Answer"]
|
||||||
|
return answer[0]["data"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("解析器(%s)请求错误: %s", resolver, e)
|
||||||
|
return None
|
||||||
@@ -1,19 +1,46 @@
|
|||||||
|
import json
|
||||||
import queue
|
import queue
|
||||||
|
import time
|
||||||
|
from typing import Optional, Any, Union
|
||||||
|
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
|
|
||||||
class MessageHelper(metaclass=Singleton):
|
class MessageHelper(metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
消息队列管理器
|
消息队列管理器,包括系统消息和用户消息
|
||||||
"""
|
"""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.queue = queue.Queue()
|
self.sys_queue = queue.Queue()
|
||||||
|
self.user_queue = queue.Queue()
|
||||||
|
|
||||||
def put(self, message: str):
|
def put(self, message: Any, role: str = "sys", note: Union[list, dict] = None):
|
||||||
self.queue.put(message)
|
"""
|
||||||
|
存消息
|
||||||
|
:param message: 消息
|
||||||
|
:param role: 消息通道 sys/user
|
||||||
|
:param note: 附件json
|
||||||
|
"""
|
||||||
|
if role == "sys":
|
||||||
|
self.sys_queue.put(message)
|
||||||
|
else:
|
||||||
|
if isinstance(message, str):
|
||||||
|
self.user_queue.put(message)
|
||||||
|
elif hasattr(message, "to_dict"):
|
||||||
|
content = message.to_dict()
|
||||||
|
content['date'] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||||
|
content['note'] = json.dumps(note) if note else None
|
||||||
|
self.user_queue.put(json.dumps(content))
|
||||||
|
|
||||||
def get(self):
|
def get(self, role: str = "sys") -> Optional[str]:
|
||||||
if not self.queue.empty():
|
"""
|
||||||
return self.queue.get(block=False)
|
取消息
|
||||||
|
:param role: 消息通道 sys/user
|
||||||
|
"""
|
||||||
|
if role == "sys":
|
||||||
|
if not self.sys_queue.empty():
|
||||||
|
return self.sys_queue.get(block=False)
|
||||||
|
else:
|
||||||
|
if not self.user_queue.empty():
|
||||||
|
return self.user_queue.get(block=False)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import importlib
|
import importlib
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
class ModuleHelper:
|
class ModuleHelper:
|
||||||
@@ -19,19 +23,33 @@ class ModuleHelper:
|
|||||||
|
|
||||||
submodules: list = []
|
submodules: list = []
|
||||||
packages = importlib.import_module(package_path)
|
packages = importlib.import_module(package_path)
|
||||||
importlib.reload(packages)
|
|
||||||
for importer, package_name, _ in pkgutil.iter_modules(packages.__path__):
|
for importer, package_name, _ in pkgutil.iter_modules(packages.__path__):
|
||||||
try:
|
try:
|
||||||
if package_name.startswith('_'):
|
if package_name.startswith('_'):
|
||||||
continue
|
continue
|
||||||
full_package_name = f'{package_path}.{package_name}'
|
full_package_name = f'{package_path}.{package_name}'
|
||||||
module = importlib.import_module(full_package_name)
|
module = importlib.import_module(full_package_name)
|
||||||
|
importlib.reload(module)
|
||||||
for name, obj in module.__dict__.items():
|
for name, obj in module.__dict__.items():
|
||||||
if name.startswith('_'):
|
if name.startswith('_'):
|
||||||
continue
|
continue
|
||||||
if isinstance(obj, type) and filter_func(name, obj):
|
if isinstance(obj, type) and filter_func(name, obj):
|
||||||
submodules.append(obj)
|
submodules.append(obj)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(f'加载模块 {package_name} 失败:{err}')
|
logger.debug(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
|
||||||
|
|
||||||
return submodules
|
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,11 +1,15 @@
|
|||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Tuple, Optional, List
|
from typing import Dict, Tuple, Optional, List
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
from cachetools import TTLCache, cached
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.log import logger
|
||||||
|
from app.schemas.types import SystemConfigKey
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
@@ -16,7 +20,21 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
插件市场管理,下载安装插件到本地
|
插件市场管理,下载安装插件到本地
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=1800))
|
_base_url = "https://raw.githubusercontent.com/%s/%s/main/"
|
||||||
|
|
||||||
|
_install_reg = "https://movie-pilot.org/plugin/install/%s"
|
||||||
|
|
||||||
|
_install_report = "https://movie-pilot.org/plugin/install"
|
||||||
|
|
||||||
|
_install_statistic = "https://movie-pilot.org/plugin/statistic"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.systemconfig = SystemConfigOper()
|
||||||
|
if not self.systemconfig.get(SystemConfigKey.PluginInstallReport):
|
||||||
|
if self.install_report():
|
||||||
|
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
||||||
|
|
||||||
|
@cached(cache=TTLCache(maxsize=1000, ttl=1800))
|
||||||
def get_plugins(self, repo_url: str) -> Dict[str, dict]:
|
def get_plugins(self, repo_url: str) -> Dict[str, dict]:
|
||||||
"""
|
"""
|
||||||
获取Github所有最新插件列表
|
获取Github所有最新插件列表
|
||||||
@@ -24,33 +42,99 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
if not repo_url:
|
if not repo_url:
|
||||||
return {}
|
return {}
|
||||||
res = RequestUtils(proxies=settings.PROXY, timeout=10).get_res(f"{repo_url}package.json")
|
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:
|
if res:
|
||||||
return json.loads(res.text)
|
try:
|
||||||
|
return json.loads(res.text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error(f"插件包数据解析失败:{res.text}")
|
||||||
|
return {}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def install(pid: str, repo_url: str) -> Tuple[bool, str]:
|
def get_repo_info(repo_url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
安装插件
|
获取Github仓库信息
|
||||||
|
:param repo_url: Github仓库地址
|
||||||
"""
|
"""
|
||||||
# 从Github的repo_url获取用户和项目名
|
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:
|
try:
|
||||||
user, repo = repo_url.split("/")[-4:-2]
|
user, repo = repo_url.split("/")[-4:-2]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"不支持的插件仓库地址格式:{str(e)}"
|
logger.error(f"解析Github仓库地址失败:{str(e)} - {traceback.format_exc()}")
|
||||||
if not user or not repo:
|
return None, None
|
||||||
return False, "不支持的插件仓库地址格式"
|
return user, repo
|
||||||
|
|
||||||
|
@cached(cache=TTLCache(maxsize=1, ttl=1800))
|
||||||
|
def get_statistic(self) -> Dict:
|
||||||
|
"""
|
||||||
|
获取插件安装统计
|
||||||
|
"""
|
||||||
|
res = RequestUtils(timeout=10).get_res(self._install_statistic)
|
||||||
|
if res and res.status_code == 200:
|
||||||
|
return res.json()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def install_reg(self, pid: str) -> bool:
|
||||||
|
"""
|
||||||
|
安装插件统计
|
||||||
|
"""
|
||||||
|
if not pid:
|
||||||
|
return False
|
||||||
|
res = RequestUtils(timeout=5).get_res(self._install_reg % pid)
|
||||||
|
if res and res.status_code == 200:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def install_report(self) -> bool:
|
||||||
|
"""
|
||||||
|
上报存量插件安装统计
|
||||||
|
"""
|
||||||
|
plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins)
|
||||||
|
if not plugins:
|
||||||
|
return False
|
||||||
|
res = RequestUtils(content_type="application/json",
|
||||||
|
timeout=5).post(self._install_report,
|
||||||
|
json={
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"plugin_id": plugin,
|
||||||
|
} for plugin in plugins
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return True if res else False
|
||||||
|
|
||||||
|
def install(self, pid: str, repo_url: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
安装插件
|
||||||
|
"""
|
||||||
if SystemUtils.is_frozen():
|
if SystemUtils.is_frozen():
|
||||||
return False, "可执行文件模式下,只能安装本地插件"
|
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]]:
|
def __get_filelist(_p: str) -> Tuple[Optional[list], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
获取插件的文件列表
|
获取插件的文件列表
|
||||||
"""
|
"""
|
||||||
file_api = f"https://api.github.com/repos/{user}/{repo}/contents/plugins/{_p.lower()}"
|
file_api = f"https://api.github.com/repos/{user}/{repo}/contents/plugins/{_p}"
|
||||||
r = RequestUtils(proxies=settings.PROXY).get_res(file_api)
|
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=30).get_res(file_api)
|
||||||
if not r or r.status_code != 200:
|
if r is None:
|
||||||
|
return None, "连接仓库失败"
|
||||||
|
elif r.status_code != 200:
|
||||||
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
|
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
|
||||||
ret = r.json()
|
ret = r.json()
|
||||||
if ret and ret[0].get("message") == "Not Found":
|
if ret and ret[0].get("message") == "Not Found":
|
||||||
@@ -66,7 +150,8 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
for item in _l:
|
for item in _l:
|
||||||
if item.get("download_url"):
|
if item.get("download_url"):
|
||||||
# 下载插件文件
|
# 下载插件文件
|
||||||
res = RequestUtils(proxies=settings.PROXY).get_res(item["download_url"])
|
res = RequestUtils(proxies=settings.PROXY,
|
||||||
|
headers=settings.GITHUB_HEADERS, timeout=60).get_res(item["download_url"])
|
||||||
if not res:
|
if not res:
|
||||||
return False, f"文件 {item.get('name')} 下载失败!"
|
return False, f"文件 {item.get('name')} 下载失败!"
|
||||||
elif res.status_code != 200:
|
elif res.status_code != 200:
|
||||||
@@ -83,7 +168,7 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
l, m = __get_filelist(p)
|
l, m = __get_filelist(p)
|
||||||
if not l:
|
if not l:
|
||||||
return False, m
|
return False, m
|
||||||
return __download_files(p, l)
|
__download_files(p, l)
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
if not pid or not repo_url:
|
if not pid or not repo_url:
|
||||||
@@ -120,4 +205,11 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
shutil.rmtree(plugin_dir, ignore_errors=True)
|
shutil.rmtree(plugin_dir, ignore_errors=True)
|
||||||
# 下载所有文件
|
# 下载所有文件
|
||||||
__download_files(pid.lower(), file_list)
|
__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")
|
||||||
|
# 安装成功后统计
|
||||||
|
self.install_reg(pid)
|
||||||
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|||||||
105
app/helper/resource.py
Normal file
105
app/helper/resource.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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
|
||||||
|
# 判断版本号
|
||||||
|
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 r and not r.ok:
|
||||||
|
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
|
||||||
|
elif not r:
|
||||||
|
return None, "连接仓库失败"
|
||||||
|
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
|
import xml.dom.minidom
|
||||||
from typing import List, Tuple, Union
|
from typing import List, Tuple, Union
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import chardet
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.helper.browser import PlaywrightHelper
|
from app.helper.browser import PlaywrightHelper
|
||||||
|
from app.log import logger
|
||||||
from app.utils.dom import DomUtils
|
from app.utils.dom import DomUtils
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
@@ -221,11 +225,12 @@ class RssHelper:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse(url, proxy: bool = False) -> Union[List[dict], None]:
|
def parse(url, proxy: bool = False, timeout: int = 15) -> Union[List[dict], None]:
|
||||||
"""
|
"""
|
||||||
解析RSS订阅URL,获取RSS中的种子信息
|
解析RSS订阅URL,获取RSS中的种子信息
|
||||||
:param url: RSS地址
|
:param url: RSS地址
|
||||||
:param proxy: 是否使用代理
|
:param proxy: 是否使用代理
|
||||||
|
:param timeout: 请求超时
|
||||||
:return: 种子信息列表,如为None代表Rss过期
|
:return: 种子信息列表,如为None代表Rss过期
|
||||||
"""
|
"""
|
||||||
# 开始处理
|
# 开始处理
|
||||||
@@ -233,15 +238,35 @@ class RssHelper:
|
|||||||
if not url:
|
if not url:
|
||||||
return []
|
return []
|
||||||
try:
|
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:
|
if not ret:
|
||||||
return []
|
return []
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(str(err))
|
logger.error(f"获取RSS失败:{str(err)} - {traceback.format_exc()}")
|
||||||
return []
|
return []
|
||||||
if ret:
|
if ret:
|
||||||
ret_xml = ret.text
|
ret_xml = ""
|
||||||
try:
|
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
|
# 解析XML
|
||||||
dom_tree = xml.dom.minidom.parseString(ret_xml)
|
dom_tree = xml.dom.minidom.parseString(ret_xml)
|
||||||
rootNode = dom_tree.documentElement
|
rootNode = dom_tree.documentElement
|
||||||
@@ -283,10 +308,10 @@ class RssHelper:
|
|||||||
'pubdate': pubdate}
|
'pubdate': pubdate}
|
||||||
ret_array.append(tmp_dict)
|
ret_array.append(tmp_dict)
|
||||||
except Exception as e1:
|
except Exception as e1:
|
||||||
print(str(e1))
|
logger.debug(f"解析RSS失败:{str(e1)} - {traceback.format_exc()}")
|
||||||
continue
|
continue
|
||||||
except Exception as e2:
|
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过期 观众RSS 链接已过期,您需要获得一个新的! pthome RSS Link has expired, You need to get a new one!
|
||||||
_rss_expired_msg = [
|
_rss_expired_msg = [
|
||||||
"RSS 链接已过期, 您需要获得一个新的!",
|
"RSS 链接已过期, 您需要获得一个新的!",
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
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 urllib.parse import unquote
|
||||||
|
|
||||||
from requests import Response
|
from requests import Response
|
||||||
from torrentool.api import Torrent
|
from torrentool.api import Torrent
|
||||||
|
|
||||||
from app.core.config import settings
|
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.core.metainfo import MetaInfo
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.schemas.types import MediaType, SystemConfigKey
|
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):
|
def __init__(self):
|
||||||
self.system_config = SystemConfigOper()
|
self.system_config = SystemConfigOper()
|
||||||
|
|
||||||
@@ -123,6 +129,8 @@ class TorrentHelper:
|
|||||||
elif req.status_code == 429:
|
elif req.status_code == 429:
|
||||||
return None, None, "", [], "触发站点流控,请稍后重试"
|
return None, None, "", [], "触发站点流控,请稍后重试"
|
||||||
else:
|
else:
|
||||||
|
# 把错误的种子记下来,避免重复使用
|
||||||
|
self.add_invalid(url)
|
||||||
return None, None, "", [], f"下载种子出错,状态码:{req.status_code}"
|
return None, None, "", [], f"下载种子出错,状态码:{req.status_code}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -276,3 +284,202 @@ class TorrentHelper:
|
|||||||
continue
|
continue
|
||||||
episodes = list(set(episodes).union(set(meta.episode_list)))
|
episodes = list(set(episodes).union(set(meta.episode_list)))
|
||||||
return episodes
|
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} "
|
||||||
|
f"{torrent_info.description} "
|
||||||
|
f"{' '.join(torrent_info.labels or [])} "
|
||||||
|
f"{torrent_info.volume_factor}")
|
||||||
|
|
||||||
|
# 最少做种人数
|
||||||
|
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
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo,
|
||||||
|
torrent: TorrentInfo, logerror: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
检查种子是否匹配媒体信息
|
||||||
|
:param mediainfo: 需要匹配的媒体信息
|
||||||
|
:param torrent_meta: 种子识别信息
|
||||||
|
:param torrent: 种子信息
|
||||||
|
:param logerror: 是否记录错误日志
|
||||||
|
"""
|
||||||
|
# 要匹配的媒体标题、原标题
|
||||||
|
media_titles = {
|
||||||
|
StringUtils.clear_upper(mediainfo.title),
|
||||||
|
StringUtils.clear_upper(mediainfo.original_title)
|
||||||
|
} - {""}
|
||||||
|
# 要匹配的媒体别名、译名
|
||||||
|
media_names = {StringUtils.clear_upper(name) for name in mediainfo.names if name}
|
||||||
|
# 识别的种子中英文名
|
||||||
|
meta_names = {
|
||||||
|
StringUtils.clear_upper(torrent_meta.cn_name),
|
||||||
|
StringUtils.clear_upper(torrent_meta.en_name)
|
||||||
|
} - {""}
|
||||||
|
# 比对种子识别类型
|
||||||
|
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
|
||||||
|
if logerror:
|
||||||
|
logger.warn(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value},'
|
||||||
|
f'不匹配 {mediainfo.type.value}')
|
||||||
|
return False
|
||||||
|
# 比对种子在站点中的类型
|
||||||
|
if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
|
||||||
|
if logerror:
|
||||||
|
logger.warn(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category},'
|
||||||
|
f'不匹配 {mediainfo.type.value}')
|
||||||
|
return False
|
||||||
|
# 比对年份
|
||||||
|
if mediainfo.year:
|
||||||
|
if mediainfo.type == MediaType.TV:
|
||||||
|
# 剧集年份,每季的年份可能不同
|
||||||
|
if torrent_meta.year and torrent_meta.year not in [year for year in
|
||||||
|
mediainfo.season_years.values()]:
|
||||||
|
if logerror:
|
||||||
|
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.season_years}')
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# 电影年份,上下浮动1年
|
||||||
|
if torrent_meta.year not in [str(int(mediainfo.year) - 1),
|
||||||
|
mediainfo.year,
|
||||||
|
str(int(mediainfo.year) + 1)]:
|
||||||
|
if logerror:
|
||||||
|
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.year}')
|
||||||
|
return False
|
||||||
|
# 比对标题和原语种标题
|
||||||
|
if meta_names.intersection(media_titles):
|
||||||
|
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||||
|
return True
|
||||||
|
# 比对别名和译名
|
||||||
|
if media_names:
|
||||||
|
if meta_names.intersection(media_names):
|
||||||
|
logger.info(f'{mediainfo.title} 通过别名或译名匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||||
|
return True
|
||||||
|
# 标题拆分
|
||||||
|
if torrent_meta.org_string:
|
||||||
|
titles = [StringUtils.clear_upper(t) for t in re.split(r'[\s/【】.\[\]\-]+',
|
||||||
|
torrent_meta.org_string) if t]
|
||||||
|
# 在标题中判断是否存在标题、原语种标题
|
||||||
|
if media_titles.intersection(titles):
|
||||||
|
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||||
|
return True
|
||||||
|
# 在副标题中判断是否存在标题、原语种标题、别名、译名
|
||||||
|
if torrent.description:
|
||||||
|
subtitles = {StringUtils.clear_upper(t) for t in re.split(r'[\s/|]+',
|
||||||
|
torrent.description) if t}
|
||||||
|
if media_titles.intersection(subtitles) or media_names.intersection(subtitles):
|
||||||
|
logger.info(f'{mediainfo.title} 通过副标题匹配到资源:{torrent.site_name} - {torrent.title},'
|
||||||
|
f'副标题:{torrent.description}')
|
||||||
|
return True
|
||||||
|
# 未匹配
|
||||||
|
if logerror:
|
||||||
|
logger.warn(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}')
|
||||||
|
return False
|
||||||
|
|||||||
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)
|
||||||
174
app/log.py
174
app/log.py
@@ -1,6 +1,8 @@
|
|||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -26,32 +28,156 @@ class CustomFormatter(logging.Formatter):
|
|||||||
def format(self, record):
|
def format(self, record):
|
||||||
seperator = " " * (8 - len(record.levelname))
|
seperator = " " * (8 - len(record.levelname))
|
||||||
record.leveltext = level_name_colors[record.levelno](record.levelname + ":") + seperator
|
record.leveltext = level_name_colors[record.levelno](record.levelname + ":") + seperator
|
||||||
if record.filename == "__init__.py":
|
|
||||||
record.filename = Path(record.pathname).parent.name
|
|
||||||
return super().format(record)
|
return super().format(record)
|
||||||
|
|
||||||
|
|
||||||
# DEBUG
|
class LoggerManager:
|
||||||
logger = logging.getLogger()
|
"""
|
||||||
if settings.DEBUG:
|
日志管理
|
||||||
logger.setLevel(logging.DEBUG)
|
"""
|
||||||
else:
|
# 管理所有的Logger
|
||||||
logger.setLevel(logging.INFO)
|
_loggers: Dict[str, Any] = {}
|
||||||
|
# 默认日志文件
|
||||||
|
_default_log_file = "moviepilot.log"
|
||||||
|
|
||||||
# 终端日志
|
@staticmethod
|
||||||
console_handler = logging.StreamHandler()
|
def __get_caller():
|
||||||
console_handler.setLevel(logging.DEBUG)
|
"""
|
||||||
console_formatter = CustomFormatter("%(leveltext)s%(filename)s - %(message)s")
|
获取调用者的文件名称与插件名称(如果是插件调用内置的模块, 也能写入到插件日志文件中)
|
||||||
console_handler.setFormatter(console_formatter)
|
"""
|
||||||
logger.addHandler(console_handler)
|
# 调用者文件名称
|
||||||
|
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
|
||||||
file_handler = RotatingFileHandler(filename=settings.LOG_PATH / 'moviepilot.log',
|
def __setup_logger(log_file: str):
|
||||||
mode='w',
|
"""
|
||||||
maxBytes=5 * 1024 * 1024,
|
设置日志
|
||||||
backupCount=3,
|
log_file:日志文件相对路径
|
||||||
encoding='utf-8')
|
"""
|
||||||
file_handler.setLevel(logging.INFO)
|
log_file_path = settings.LOG_PATH / log_file
|
||||||
file_formater = CustomFormatter("【%(levelname)s】%(asctime)s - %(filename)s - %(message)s")
|
if not log_file_path.parent.exists():
|
||||||
file_handler.setFormatter(file_formater)
|
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
logger.addHandler(file_handler)
|
|
||||||
|
# 创建新实例
|
||||||
|
_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()
|
||||||
|
|||||||
62
app/main.py
62
app/main.py
@@ -19,12 +19,15 @@ if SystemUtils.is_frozen():
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.module import ModuleManager
|
from app.core.module import ModuleManager
|
||||||
from app.core.plugin import PluginManager
|
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.thread import ThreadHelper
|
||||||
from app.helper.display import DisplayHelper
|
from app.helper.display import DisplayHelper
|
||||||
|
from app.helper.resource import ResourceHelper
|
||||||
from app.helper.sites import SitesHelper
|
from app.helper.sites import SitesHelper
|
||||||
|
from app.helper.message import MessageHelper
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.command import Command
|
from app.command import Command, CommandChian
|
||||||
|
from app.schemas import Notification, NotificationType
|
||||||
|
|
||||||
# App
|
# App
|
||||||
App = FastAPI(title=settings.PROJECT_NAME,
|
App = FastAPI(title=settings.PROJECT_NAME,
|
||||||
@@ -50,17 +53,22 @@ def init_routers():
|
|||||||
"""
|
"""
|
||||||
from app.api.apiv1 import api_router
|
from app.api.apiv1 import api_router
|
||||||
from app.api.servarr import arr_router
|
from app.api.servarr import arr_router
|
||||||
|
from app.api.servcookie import cookie_router
|
||||||
# API路由
|
# API路由
|
||||||
App.include_router(api_router, prefix=settings.API_V1_STR)
|
App.include_router(api_router, prefix=settings.API_V1_STR)
|
||||||
# Radarr、Sonarr路由
|
# Radarr、Sonarr路由
|
||||||
App.include_router(arr_router, prefix="/api/v3")
|
App.include_router(arr_router, prefix="/api/v3")
|
||||||
|
# CookieCloud路由
|
||||||
|
App.include_router(cookie_router, prefix="/cookiecloud")
|
||||||
|
|
||||||
|
|
||||||
def start_frontend():
|
def start_frontend():
|
||||||
"""
|
"""
|
||||||
启动前端服务
|
启动前端服务
|
||||||
"""
|
"""
|
||||||
if not SystemUtils.is_frozen():
|
# 仅Windows可执行文件支持内嵌nginx
|
||||||
|
if not SystemUtils.is_frozen() \
|
||||||
|
or not SystemUtils.is_windows():
|
||||||
return
|
return
|
||||||
# 临时Nginx目录
|
# 临时Nginx目录
|
||||||
nginx_path = settings.ROOT_PATH / 'nginx'
|
nginx_path = settings.ROOT_PATH / 'nginx'
|
||||||
@@ -73,27 +81,20 @@ def start_frontend():
|
|||||||
SystemUtils.move(nginx_path, run_nginx_dir)
|
SystemUtils.move(nginx_path, run_nginx_dir)
|
||||||
# 启动Nginx
|
# 启动Nginx
|
||||||
import subprocess
|
import subprocess
|
||||||
if SystemUtils.is_windows():
|
subprocess.Popen("start nginx.exe",
|
||||||
subprocess.Popen("start nginx.exe",
|
cwd=run_nginx_dir,
|
||||||
cwd=run_nginx_dir,
|
shell=True)
|
||||||
shell=True)
|
|
||||||
else:
|
|
||||||
subprocess.Popen("nohup ./nginx &",
|
|
||||||
cwd=run_nginx_dir,
|
|
||||||
shell=True)
|
|
||||||
|
|
||||||
|
|
||||||
def stop_frontend():
|
def stop_frontend():
|
||||||
"""
|
"""
|
||||||
停止前端服务
|
停止前端服务
|
||||||
"""
|
"""
|
||||||
if not SystemUtils.is_frozen():
|
if not SystemUtils.is_frozen() \
|
||||||
|
or not SystemUtils.is_windows():
|
||||||
return
|
return
|
||||||
import subprocess
|
import subprocess
|
||||||
if SystemUtils.is_windows():
|
subprocess.Popen(f"taskkill /f /im nginx.exe", shell=True)
|
||||||
subprocess.Popen(f"taskkill /f /im nginx.exe", shell=True)
|
|
||||||
else:
|
|
||||||
subprocess.Popen(f"killall nginx", shell=True)
|
|
||||||
|
|
||||||
|
|
||||||
def start_tray():
|
def start_tray():
|
||||||
@@ -104,6 +105,9 @@ def start_tray():
|
|||||||
if not SystemUtils.is_frozen():
|
if not SystemUtils.is_frozen():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not SystemUtils.is_windows():
|
||||||
|
return
|
||||||
|
|
||||||
def open_web():
|
def open_web():
|
||||||
"""
|
"""
|
||||||
调用浏览器打开前端页面
|
调用浏览器打开前端页面
|
||||||
@@ -139,6 +143,22 @@ def start_tray():
|
|||||||
threading.Thread(target=TrayIcon.run, daemon=True).start()
|
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")
|
@App.on_event("shutdown")
|
||||||
def shutdown_server():
|
def shutdown_server():
|
||||||
"""
|
"""
|
||||||
@@ -165,14 +185,20 @@ def start_module():
|
|||||||
"""
|
"""
|
||||||
启动模块
|
启动模块
|
||||||
"""
|
"""
|
||||||
|
# 初始化超级管理员
|
||||||
|
init_super_user()
|
||||||
# 虚拟显示
|
# 虚拟显示
|
||||||
DisplayHelper()
|
DisplayHelper()
|
||||||
# 站点管理
|
# 站点管理
|
||||||
SitesHelper()
|
SitesHelper()
|
||||||
|
# 资源包检测
|
||||||
|
ResourceHelper()
|
||||||
# 加载模块
|
# 加载模块
|
||||||
ModuleManager()
|
ModuleManager()
|
||||||
|
# 安装在线插件
|
||||||
|
PluginManager().install_online_plugin()
|
||||||
# 加载插件
|
# 加载插件
|
||||||
PluginManager()
|
PluginManager().start()
|
||||||
# 启动定时服务
|
# 启动定时服务
|
||||||
Scheduler()
|
Scheduler()
|
||||||
# 启动事件消费
|
# 启动事件消费
|
||||||
@@ -181,6 +207,8 @@ def start_module():
|
|||||||
init_routers()
|
init_routers()
|
||||||
# 启动前端服务
|
# 启动前端服务
|
||||||
start_frontend()
|
start_frontend()
|
||||||
|
# 检查认证状态
|
||||||
|
check_auth()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ class _ModuleBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def test(self) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
模块测试, 返回测试结果和错误信息
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def checkMessage(channel_type: MessageChannel):
|
def checkMessage(channel_type: MessageChannel):
|
||||||
"""
|
"""
|
||||||
@@ -60,6 +67,8 @@ def checkMessage(channel_type: MessageChannel):
|
|||||||
return None
|
return None
|
||||||
if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"):
|
if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"):
|
||||||
return None
|
return None
|
||||||
|
if channel_type == MessageChannel.VoceChat and not switch.get("vocechat"):
|
||||||
|
return None
|
||||||
return func(self, message, *args, **kwargs)
|
return func(self, message, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
93
app/modules/bangumi/__init__.py
Normal file
93
app/modules/bangumi/__init__.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from typing import List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
from app.core.context import MediaInfo
|
||||||
|
from app.log import logger
|
||||||
|
from app.modules import _ModuleBase
|
||||||
|
from app.modules.bangumi.bangumi import BangumiApi
|
||||||
|
from app.utils.http import RequestUtils
|
||||||
|
|
||||||
|
|
||||||
|
class BangumiModule(_ModuleBase):
|
||||||
|
bangumiapi: BangumiApi = None
|
||||||
|
|
||||||
|
def init_module(self) -> None:
|
||||||
|
self.bangumiapi = BangumiApi()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test(self) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
测试模块连接性
|
||||||
|
"""
|
||||||
|
with RequestUtils().get_res("https://api.bgm.tv/") as ret:
|
||||||
|
if ret and ret.status_code == 200:
|
||||||
|
return True, ""
|
||||||
|
elif ret:
|
||||||
|
return False, f"无法连接Bangumi,错误码:{ret.status_code}"
|
||||||
|
return False, "Bangumi网络连接失败"
|
||||||
|
|
||||||
|
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def recognize_media(self, bangumiid: int = None,
|
||||||
|
**kwargs) -> Optional[MediaInfo]:
|
||||||
|
"""
|
||||||
|
识别媒体信息
|
||||||
|
:param bangumiid: 识别的Bangumi ID
|
||||||
|
:return: 识别的媒体信息,包括剧集信息
|
||||||
|
"""
|
||||||
|
if not bangumiid:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 直接查询详情
|
||||||
|
info = self.bangumi_info(bangumiid=bangumiid)
|
||||||
|
if info:
|
||||||
|
# 赋值TMDB信息并返回
|
||||||
|
mediainfo = MediaInfo(bangumi_info=info)
|
||||||
|
logger.info(f"{bangumiid} Bangumi识别结果:{mediainfo.type.value} "
|
||||||
|
f"{mediainfo.title_year}")
|
||||||
|
return mediainfo
|
||||||
|
else:
|
||||||
|
logger.info(f"{bangumiid} 未匹配到Bangumi媒体信息")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
获取Bangumi信息
|
||||||
|
:param bangumiid: BangumiID
|
||||||
|
:return: Bangumi信息
|
||||||
|
"""
|
||||||
|
if not bangumiid:
|
||||||
|
return None
|
||||||
|
logger.info(f"开始获取Bangumi信息:{bangumiid} ...")
|
||||||
|
return self.bangumiapi.detail(bangumiid)
|
||||||
|
|
||||||
|
def bangumi_calendar(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||||
|
"""
|
||||||
|
获取Bangumi每日放送
|
||||||
|
:param page: 页码
|
||||||
|
:param count: 每页数量
|
||||||
|
"""
|
||||||
|
return self.bangumiapi.calendar(page, count)
|
||||||
|
|
||||||
|
def bangumi_credits(self, bangumiid: int, page: int = 1, count: int = 20) -> List[dict]:
|
||||||
|
"""
|
||||||
|
根据TMDBID查询电影演职员表
|
||||||
|
:param bangumiid: BangumiID
|
||||||
|
:param page: 页码
|
||||||
|
:param count: 数量
|
||||||
|
"""
|
||||||
|
persons = self.bangumiapi.persons(bangumiid) or []
|
||||||
|
if persons:
|
||||||
|
return persons[(page - 1) * count: page * count]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def bangumi_recommend(self, bangumiid: int) -> List[dict]:
|
||||||
|
"""
|
||||||
|
根据BangumiID查询推荐电影
|
||||||
|
:param bangumiid: BangumiID
|
||||||
|
"""
|
||||||
|
return self.bangumiapi.subjects(bangumiid) or []
|
||||||
165
app/modules/bangumi/bangumi.py
Normal file
165
app/modules/bangumi/bangumi.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.utils.http import RequestUtils
|
||||||
|
|
||||||
|
|
||||||
|
class BangumiApi(object):
|
||||||
|
"""
|
||||||
|
https://bangumi.github.io/api/
|
||||||
|
"""
|
||||||
|
|
||||||
|
_urls = {
|
||||||
|
"calendar": "calendar",
|
||||||
|
"detail": "v0/subjects/%s",
|
||||||
|
"persons": "v0/subjects/%s/persons",
|
||||||
|
"subjects": "v0/subjects/%s/subjects",
|
||||||
|
"characters": "v0/subjects/%s/characters"
|
||||||
|
}
|
||||||
|
_base_url = "https://api.bgm.tv/"
|
||||||
|
_req = RequestUtils(session=requests.Session())
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@lru_cache(maxsize=128)
|
||||||
|
def __invoke(cls, url, **kwargs):
|
||||||
|
req_url = cls._base_url + url
|
||||||
|
params = {}
|
||||||
|
if kwargs:
|
||||||
|
params.update(kwargs)
|
||||||
|
resp = cls._req.get_res(url=req_url, params=params)
|
||||||
|
try:
|
||||||
|
return resp.json() if resp else None
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def calendar(self, page: int = 1, count: int = 30):
|
||||||
|
"""
|
||||||
|
获取每日放送,返回items
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"weekday": {
|
||||||
|
"en": "Mon",
|
||||||
|
"cn": "星期一",
|
||||||
|
"ja": "月耀日",
|
||||||
|
"id": 1
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 350235,
|
||||||
|
"url": "http://bgm.tv/subject/350235",
|
||||||
|
"type": 2,
|
||||||
|
"name": "月が導く異世界道中 第二幕",
|
||||||
|
"name_cn": "月光下的异世界之旅 第二幕",
|
||||||
|
"summary": "",
|
||||||
|
"air_date": "2024-01-08",
|
||||||
|
"air_weekday": 1,
|
||||||
|
"rating": {
|
||||||
|
"total": 257,
|
||||||
|
"count": {
|
||||||
|
"1": 1,
|
||||||
|
"2": 1,
|
||||||
|
"3": 4,
|
||||||
|
"4": 15,
|
||||||
|
"5": 51,
|
||||||
|
"6": 111,
|
||||||
|
"7": 49,
|
||||||
|
"8": 13,
|
||||||
|
"9": 5,
|
||||||
|
"10": 7
|
||||||
|
},
|
||||||
|
"score": 6.1
|
||||||
|
},
|
||||||
|
"rank": 6125,
|
||||||
|
"images": {
|
||||||
|
"large": "http://lain.bgm.tv/pic/cover/l/3c/a5/350235_A0USf.jpg",
|
||||||
|
"common": "http://lain.bgm.tv/pic/cover/c/3c/a5/350235_A0USf.jpg",
|
||||||
|
"medium": "http://lain.bgm.tv/pic/cover/m/3c/a5/350235_A0USf.jpg",
|
||||||
|
"small": "http://lain.bgm.tv/pic/cover/s/3c/a5/350235_A0USf.jpg",
|
||||||
|
"grid": "http://lain.bgm.tv/pic/cover/g/3c/a5/350235_A0USf.jpg"
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"doing": 920
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 358561,
|
||||||
|
"url": "http://bgm.tv/subject/358561",
|
||||||
|
"type": 2,
|
||||||
|
"name": "大宇宙时代",
|
||||||
|
"name_cn": "大宇宙时代",
|
||||||
|
"summary": "",
|
||||||
|
"air_date": "2024-01-22",
|
||||||
|
"air_weekday": 1,
|
||||||
|
"rating": {
|
||||||
|
"total": 2,
|
||||||
|
"count": {
|
||||||
|
"1": 0,
|
||||||
|
"2": 0,
|
||||||
|
"3": 0,
|
||||||
|
"4": 0,
|
||||||
|
"5": 1,
|
||||||
|
"6": 1,
|
||||||
|
"7": 0,
|
||||||
|
"8": 0,
|
||||||
|
"9": 0,
|
||||||
|
"10": 0
|
||||||
|
},
|
||||||
|
"score": 5.5
|
||||||
|
},
|
||||||
|
"images": {
|
||||||
|
"large": "http://lain.bgm.tv/pic/cover/l/71/66/358561_UzsLu.jpg",
|
||||||
|
"common": "http://lain.bgm.tv/pic/cover/c/71/66/358561_UzsLu.jpg",
|
||||||
|
"medium": "http://lain.bgm.tv/pic/cover/m/71/66/358561_UzsLu.jpg",
|
||||||
|
"small": "http://lain.bgm.tv/pic/cover/s/71/66/358561_UzsLu.jpg",
|
||||||
|
"grid": "http://lain.bgm.tv/pic/cover/g/71/66/358561_UzsLu.jpg"
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"doing": 9
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
ret_list = []
|
||||||
|
result = self.__invoke(self._urls["calendar"], _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||||
|
if result:
|
||||||
|
for item in result:
|
||||||
|
ret_list.extend(item.get("items") or [])
|
||||||
|
return ret_list[(page - 1) * count: page * count]
|
||||||
|
|
||||||
|
def detail(self, bid: int):
|
||||||
|
"""
|
||||||
|
获取番剧详情
|
||||||
|
"""
|
||||||
|
return self.__invoke(self._urls["detail"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||||
|
|
||||||
|
def persons(self, bid: int):
|
||||||
|
"""
|
||||||
|
获取番剧人物
|
||||||
|
"""
|
||||||
|
ret_list = []
|
||||||
|
result = self.__invoke(self._urls["characters"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||||
|
if result:
|
||||||
|
for item in result:
|
||||||
|
character_id = item.get("id")
|
||||||
|
actors = item.get("actors")
|
||||||
|
if character_id and actors and actors[0]:
|
||||||
|
actor_info = actors[0]
|
||||||
|
actor_info.update({'career': [item.get('name')]})
|
||||||
|
ret_list.append(actor_info)
|
||||||
|
return ret_list
|
||||||
|
|
||||||
|
def subjects(self, bid: int):
|
||||||
|
"""
|
||||||
|
获取关联条目信息
|
||||||
|
"""
|
||||||
|
return self.__invoke(self._urls["subjects"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||||
@@ -2,37 +2,150 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Tuple, Union
|
from typing import List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import cn2an
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.core.meta import MetaBase
|
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.log import logger
|
||||||
from app.modules import _ModuleBase
|
from app.modules import _ModuleBase
|
||||||
from app.modules.douban.apiv2 import DoubanApi
|
from app.modules.douban.apiv2 import DoubanApi
|
||||||
|
from app.modules.douban.douban_cache import DoubanCache
|
||||||
from app.modules.douban.scraper import DoubanScraper
|
from app.modules.douban.scraper import DoubanScraper
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
from app.utils.common import retry
|
from app.utils.common import retry
|
||||||
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
|
|
||||||
|
|
||||||
class DoubanModule(_ModuleBase):
|
class DoubanModule(_ModuleBase):
|
||||||
doubanapi: DoubanApi = None
|
doubanapi: DoubanApi = None
|
||||||
scraper: DoubanScraper = None
|
scraper: DoubanScraper = None
|
||||||
|
cache: DoubanCache = None
|
||||||
|
|
||||||
def init_module(self) -> None:
|
def init_module(self) -> None:
|
||||||
self.doubanapi = DoubanApi()
|
self.doubanapi = DoubanApi()
|
||||||
self.scraper = DoubanScraper()
|
self.scraper = DoubanScraper()
|
||||||
|
self.cache = DoubanCache()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
pass
|
self.doubanapi.close()
|
||||||
|
|
||||||
|
def test(self) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
测试模块连接性
|
||||||
|
"""
|
||||||
|
with RequestUtils().get_res("https://movie.douban.com/") as ret:
|
||||||
|
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]]:
|
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||||
pass
|
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 not doubanid and not meta:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if meta and not doubanid \
|
||||||
|
and 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:
|
||||||
|
info = {}
|
||||||
|
# 使用中英文名分别识别,去重去空,但要保持顺序
|
||||||
|
names = list(dict.fromkeys([k for k in [meta.cn_name, meta.en_name] if k]))
|
||||||
|
for name in names:
|
||||||
|
if meta.begin_season:
|
||||||
|
logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...")
|
||||||
|
else:
|
||||||
|
logger.info(f"正在识别 {name} ...")
|
||||||
|
# 匹配豆瓣信息
|
||||||
|
match_info = self.match_doubaninfo(name=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
|
||||||
|
)
|
||||||
|
if info:
|
||||||
|
break
|
||||||
|
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 doubanid: 豆瓣ID
|
||||||
|
:param mtype: 媒体类型
|
||||||
:return: 豆瓣信息
|
:return: 豆瓣信息
|
||||||
"""
|
"""
|
||||||
"""
|
"""
|
||||||
@@ -300,22 +413,40 @@ class DoubanModule(_ModuleBase):
|
|||||||
"interest_cmt_earlier_tip_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:
|
if not doubanid:
|
||||||
return None
|
return None
|
||||||
logger.info(f"开始获取豆瓣信息:{doubanid} ...")
|
logger.info(f"开始获取豆瓣信息:{doubanid} ...")
|
||||||
douban_info = self.doubanapi.movie_detail(doubanid)
|
if mtype == MediaType.TV:
|
||||||
if douban_info:
|
return __douban_tv()
|
||||||
celebrities = self.doubanapi.movie_celebrities(doubanid)
|
elif mtype == MediaType.MOVIE:
|
||||||
if celebrities:
|
return __douban_movie()
|
||||||
douban_info["directors"] = celebrities.get("directors")
|
|
||||||
douban_info["actors"] = celebrities.get("actors")
|
|
||||||
else:
|
else:
|
||||||
douban_info = self.doubanapi.tv_detail(doubanid)
|
return __douban_movie() or __douban_tv()
|
||||||
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
|
|
||||||
|
|
||||||
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
|
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
|
||||||
page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||||
@@ -379,6 +510,26 @@ class DoubanModule(_ModuleBase):
|
|||||||
return []
|
return []
|
||||||
return infos.get("subject_collection_items")
|
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]]:
|
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
搜索媒体信息
|
搜索媒体信息
|
||||||
@@ -386,7 +537,7 @@ class DoubanModule(_ModuleBase):
|
|||||||
:reutrn: 媒体信息
|
:reutrn: 媒体信息
|
||||||
"""
|
"""
|
||||||
# 未启用豆瓣搜索时返回None
|
# 未启用豆瓣搜索时返回None
|
||||||
if settings.SEARCH_SOURCE != "douban":
|
if settings.RECOGNIZE_SOURCE != "douban":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not meta.name:
|
if not meta.name:
|
||||||
@@ -397,22 +548,29 @@ class DoubanModule(_ModuleBase):
|
|||||||
# 返回数据
|
# 返回数据
|
||||||
ret_medias = []
|
ret_medias = []
|
||||||
for item_obj in result.get("items"):
|
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
|
continue
|
||||||
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
|
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
|
||||||
continue
|
continue
|
||||||
ret_medias.append(MediaInfo(douban_info=item_obj.get("target")))
|
ret_medias.append(MediaInfo(douban_info=item_obj.get("target")))
|
||||||
|
# 将搜索词中的季写入标题中
|
||||||
|
if ret_medias and meta.begin_season:
|
||||||
|
# 小写数据转大写
|
||||||
|
season_str = cn2an.an2cn(meta.begin_season, "low")
|
||||||
|
for media in ret_medias:
|
||||||
|
if media.type == MediaType.TV:
|
||||||
|
media.title = f"{media.title} 第{season_str}季"
|
||||||
|
media.season = meta.begin_season
|
||||||
return ret_medias
|
return ret_medias
|
||||||
|
|
||||||
@retry(Exception, 5, 3, 3, logger=logger)
|
@retry(Exception, 5, 3, 3, logger=logger)
|
||||||
def match_doubaninfo(self, name: str, imdbid: str = None,
|
def match_doubaninfo(self, name: str, imdbid: str = None,
|
||||||
mtype: str = None, year: str = None, season: int = None) -> dict:
|
mtype: MediaType = None, year: str = None, season: int = None) -> dict:
|
||||||
"""
|
"""
|
||||||
搜索和匹配豆瓣信息
|
搜索和匹配豆瓣信息
|
||||||
:param name: 名称
|
:param name: 名称
|
||||||
:param imdbid: IMDB ID
|
:param imdbid: IMDB ID
|
||||||
:param mtype: 类型 电影/电视剧
|
:param mtype: 类型
|
||||||
:param year: 年份
|
:param year: 年份
|
||||||
:param season: 季号
|
:param season: 季号
|
||||||
"""
|
"""
|
||||||
@@ -441,9 +599,9 @@ class DoubanModule(_ModuleBase):
|
|||||||
type_name = item_obj.get("type_name")
|
type_name = item_obj.get("type_name")
|
||||||
if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]:
|
if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]:
|
||||||
continue
|
continue
|
||||||
if mtype and mtype != type_name:
|
if mtype and mtype.value != type_name:
|
||||||
continue
|
continue
|
||||||
if mtype == MediaType.TV and not season:
|
if mtype and mtype == MediaType.TV and not season:
|
||||||
season = 1
|
season = 1
|
||||||
item = item_obj.get("target")
|
item = item_obj.get("target")
|
||||||
title = item.get("title")
|
title = item.get("title")
|
||||||
@@ -470,39 +628,84 @@ class DoubanModule(_ModuleBase):
|
|||||||
return []
|
return []
|
||||||
return infos.get("subject_collection_items")
|
return infos.get("subject_collection_items")
|
||||||
|
|
||||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str) -> None:
|
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
|
||||||
|
metainfo: MetaBase = None, force_nfo: bool = False, force_img: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
刮削元数据
|
刮削元数据
|
||||||
:param path: 媒体文件路径
|
:param path: 媒体文件路径
|
||||||
:param mediainfo: 识别的媒体信息
|
:param mediainfo: 识别的媒体信息
|
||||||
:param transfer_type: 传输类型
|
:param transfer_type: 传输类型
|
||||||
|
:param metainfo: 源文件的识别元数据
|
||||||
|
:param force_nfo: 是否强制刮削nfo
|
||||||
|
:param force_img: 是否强制刮削图片
|
||||||
:return: 成功或失败
|
:return: 成功或失败
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __get_mediainfo(_meta: MetaBase, _mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||||
|
"""
|
||||||
|
获取豆瓣媒体信息
|
||||||
|
"""
|
||||||
|
if not _meta.name:
|
||||||
|
return None
|
||||||
|
# 查询豆瓣详情
|
||||||
|
if not _mediainfo.douban_id:
|
||||||
|
# 根据TMDB名称查询豆瓣数据
|
||||||
|
_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 None
|
||||||
|
_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(f"未获取到 {_mediainfo.douban_id} 的豆瓣媒体信息,无法刮削!")
|
||||||
|
return None
|
||||||
|
# 豆瓣媒体信息
|
||||||
|
_doubanmedia = MediaInfo(douban_info=_doubaninfo)
|
||||||
|
# 补充图片
|
||||||
|
self.obtain_images(_doubanmedia)
|
||||||
|
return _doubanmedia
|
||||||
|
|
||||||
if settings.SCRAP_SOURCE != "douban":
|
if settings.SCRAP_SOURCE != "douban":
|
||||||
return None
|
return None
|
||||||
if SystemUtils.is_bluray_dir(path):
|
if SystemUtils.is_bluray_dir(path):
|
||||||
# 蓝光原盘
|
# 蓝光原盘
|
||||||
logger.info(f"开始刮削蓝光原盘:{path} ...")
|
logger.info(f"开始刮削蓝光原盘:{path} ...")
|
||||||
meta = MetaInfo(path.stem)
|
# 优先使用传入metainfo
|
||||||
if not meta.name:
|
meta = metainfo or MetaInfo(path.name)
|
||||||
return
|
|
||||||
# 根据名称查询豆瓣数据
|
|
||||||
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
|
|
||||||
imdbid=mediainfo.imdb_id,
|
|
||||||
mtype=mediainfo.type.value,
|
|
||||||
year=mediainfo.year,
|
|
||||||
season=meta.begin_season)
|
|
||||||
if not doubaninfo:
|
|
||||||
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
|
|
||||||
return
|
|
||||||
# 查询豆瓣详情
|
|
||||||
doubaninfo = self.douban_info(doubaninfo.get("id"))
|
|
||||||
# 刮削路径
|
# 刮削路径
|
||||||
scrape_path = path / path.name
|
scrape_path = path / path.name
|
||||||
|
# 媒体信息
|
||||||
|
doubanmedia = __get_mediainfo(_meta=meta, _mediainfo=mediainfo)
|
||||||
|
if not doubanmedia:
|
||||||
|
return
|
||||||
|
# 刮削
|
||||||
self.scraper.gen_scraper_files(meta=meta,
|
self.scraper.gen_scraper_files(meta=meta,
|
||||||
mediainfo=MediaInfo(douban_info=doubaninfo),
|
mediainfo=doubanmedia,
|
||||||
file_path=scrape_path,
|
file_path=scrape_path,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type,
|
||||||
|
force_nfo=force_nfo,
|
||||||
|
force_img=force_img)
|
||||||
|
elif path.is_file():
|
||||||
|
# 刮削单个文件
|
||||||
|
logger.info(f"开始刮削媒体库文件:{path} ...")
|
||||||
|
# 优先使用传入metainfo
|
||||||
|
meta = metainfo or MetaInfoPath(path)
|
||||||
|
# 媒体信息
|
||||||
|
doubanmedia = __get_mediainfo(_meta=meta, _mediainfo=mediainfo)
|
||||||
|
if not doubanmedia:
|
||||||
|
return
|
||||||
|
# 刮削
|
||||||
|
self.scraper.gen_scraper_files(meta=meta,
|
||||||
|
mediainfo=doubanmedia,
|
||||||
|
file_path=path,
|
||||||
|
transfer_type=transfer_type,
|
||||||
|
force_nfo=force_nfo,
|
||||||
|
force_img=force_img)
|
||||||
else:
|
else:
|
||||||
# 目录下的所有文件
|
# 目录下的所有文件
|
||||||
for file in SystemUtils.list_files(path, settings.RMT_MEDIAEXT):
|
for file in SystemUtils.list_files(path, settings.RMT_MEDIAEXT):
|
||||||
@@ -510,25 +713,102 @@ class DoubanModule(_ModuleBase):
|
|||||||
continue
|
continue
|
||||||
logger.info(f"开始刮削媒体库文件:{file} ...")
|
logger.info(f"开始刮削媒体库文件:{file} ...")
|
||||||
try:
|
try:
|
||||||
meta = MetaInfo(file.stem)
|
meta = MetaInfoPath(file)
|
||||||
if not meta.name:
|
# 豆瓣媒体信息
|
||||||
continue
|
doubanmedia = __get_mediainfo(_meta=meta, _mediainfo=mediainfo)
|
||||||
# 根据名称查询豆瓣数据
|
if not doubanmedia:
|
||||||
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
|
return
|
||||||
imdbid=mediainfo.imdb_id,
|
|
||||||
mtype=mediainfo.type.value,
|
|
||||||
year=mediainfo.year,
|
|
||||||
season=meta.begin_season)
|
|
||||||
if not doubaninfo:
|
|
||||||
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
|
|
||||||
break
|
|
||||||
# 查询豆瓣详情
|
|
||||||
doubaninfo = self.douban_info(doubaninfo.get("id"))
|
|
||||||
# 刮削
|
# 刮削
|
||||||
self.scraper.gen_scraper_files(meta=meta,
|
self.scraper.gen_scraper_files(meta=meta,
|
||||||
mediainfo=MediaInfo(douban_info=doubaninfo),
|
mediainfo=doubanmedia,
|
||||||
file_path=file,
|
file_path=file,
|
||||||
transfer_type=transfer_type)
|
transfer_type=transfer_type,
|
||||||
|
force_nfo=force_nfo,
|
||||||
|
force_img=force_img)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"刮削文件 {file} 失败,原因:{str(e)}")
|
logger.error(f"刮削文件 {file} 失败,原因:{str(e)}")
|
||||||
logger.info(f"{path} 刮削完成")
|
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 []
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ class DoubanApi(metaclass=Singleton):
|
|||||||
},
|
},
|
||||||
data={
|
data={
|
||||||
"apikey": "0ab215a8b1977939201640fa14c66bab",
|
"apikey": "0ab215a8b1977939201640fa14c66bab",
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
req_url = self._api_url + url
|
req_url = self._api_url + url
|
||||||
@@ -427,6 +427,60 @@ class DoubanApi(metaclass=Singleton):
|
|||||||
return self.__invoke(self._urls["doulist_items"] % subject_id,
|
return self.__invoke(self._urls["doulist_items"] % subject_id,
|
||||||
start=start, count=count, _ts=ts)
|
start=start, count=count, _ts=ts)
|
||||||
|
|
||||||
def __del__(self):
|
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 close(self):
|
||||||
if self._session:
|
if self._session:
|
||||||
self._session.close()
|
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,4 +1,3 @@
|
|||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union
|
from typing import Union
|
||||||
from xml.dom import minidom
|
from xml.dom import minidom
|
||||||
@@ -14,45 +13,70 @@ from app.utils.system import SystemUtils
|
|||||||
|
|
||||||
|
|
||||||
class DoubanScraper:
|
class DoubanScraper:
|
||||||
|
|
||||||
_transfer_type = settings.TRANSFER_TYPE
|
_transfer_type = settings.TRANSFER_TYPE
|
||||||
|
_force_nfo = False
|
||||||
|
_force_img = False
|
||||||
|
|
||||||
def gen_scraper_files(self, meta: MetaBase, mediainfo: MediaInfo,
|
def gen_scraper_files(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||||
file_path: Path, transfer_type: str):
|
file_path: Path, transfer_type: str,
|
||||||
|
force_nfo: bool = False, force_img: bool = False):
|
||||||
"""
|
"""
|
||||||
生成刮削文件
|
生成刮削文件
|
||||||
:param meta: 元数据
|
:param meta: 元数据
|
||||||
:param mediainfo: 媒体信息
|
:param mediainfo: 媒体信息
|
||||||
:param file_path: 文件路径或者目录路径
|
:param file_path: 文件路径或者目录路径
|
||||||
:param transfer_type: 转输类型
|
:param transfer_type: 转输类型
|
||||||
|
:param force_nfo: 强制生成NFO
|
||||||
|
:param force_img: 强制生成图片
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if not mediainfo or not file_path:
|
||||||
|
return
|
||||||
|
|
||||||
self._transfer_type = transfer_type
|
self._transfer_type = transfer_type
|
||||||
|
self._force_nfo = force_nfo
|
||||||
|
self._force_img = force_img
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 电影
|
# 电影
|
||||||
if mediainfo.type == MediaType.MOVIE:
|
if mediainfo.type == MediaType.MOVIE:
|
||||||
# 强制或者不已存在时才处理
|
# 强制或者不已存在时才处理
|
||||||
if not file_path.with_name("movie.nfo").exists() \
|
if self._force_nfo or (not file_path.with_name("movie.nfo").exists()
|
||||||
and not file_path.with_suffix(".nfo").exists():
|
and not file_path.with_suffix(".nfo").exists()):
|
||||||
# 生成电影描述文件
|
# 生成电影描述文件
|
||||||
self.__gen_movie_nfo_file(mediainfo=mediainfo,
|
self.__gen_movie_nfo_file(mediainfo=mediainfo,
|
||||||
file_path=file_path)
|
file_path=file_path)
|
||||||
# 生成电影图片
|
# 生成电影图片
|
||||||
self.__save_image(url=mediainfo.poster_path,
|
image_path = file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}")
|
||||||
file_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:
|
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,
|
self.__gen_tv_nfo_file(mediainfo=mediainfo,
|
||||||
dir_path=file_path.parents[1])
|
dir_path=file_path.parents[1])
|
||||||
# 生成根目录图片
|
# 生成根目录图片
|
||||||
self.__save_image(url=mediainfo.poster_path,
|
image_path = file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}")
|
||||||
file_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
|
# 季目录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,
|
self.__gen_tv_season_nfo_file(mediainfo=mediainfo,
|
||||||
season=meta.begin_season,
|
season=meta.begin_season,
|
||||||
season_path=file_path.parent)
|
season_path=file_path.parent)
|
||||||
@@ -61,10 +85,6 @@ class DoubanScraper:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
|
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
|
||||||
# 添加时间
|
|
||||||
DomUtils.add_node(doc, root, "dateadded",
|
|
||||||
time.strftime('%Y-%m-%d %H:%M:%S',
|
|
||||||
time.localtime(time.time())))
|
|
||||||
# 简介
|
# 简介
|
||||||
xplot = DomUtils.add_node(doc, root, "plot")
|
xplot = DomUtils.add_node(doc, root, "plot")
|
||||||
xplot.appendChild(doc.createCDATASection(mediainfo.overview or ""))
|
xplot.appendChild(doc.createCDATASection(mediainfo.overview or ""))
|
||||||
@@ -144,8 +164,6 @@ class DoubanScraper:
|
|||||||
logger.info(f"正在生成季NFO文件:{season_path.name}")
|
logger.info(f"正在生成季NFO文件:{season_path.name}")
|
||||||
doc = minidom.Document()
|
doc = minidom.Document()
|
||||||
root = DomUtils.add_node(doc, doc, "season")
|
root = DomUtils.add_node(doc, doc, "season")
|
||||||
# 添加时间
|
|
||||||
DomUtils.add_node(doc, root, "dateadded", time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))
|
|
||||||
# 简介
|
# 简介
|
||||||
xplot = DomUtils.add_node(doc, root, "plot")
|
xplot = DomUtils.add_node(doc, root, "plot")
|
||||||
xplot.appendChild(doc.createCDATASection(mediainfo.overview or ""))
|
xplot.appendChild(doc.createCDATASection(mediainfo.overview or ""))
|
||||||
@@ -167,8 +185,6 @@ class DoubanScraper:
|
|||||||
"""
|
"""
|
||||||
下载图片并保存
|
下载图片并保存
|
||||||
"""
|
"""
|
||||||
if file_path.exists():
|
|
||||||
return
|
|
||||||
if not url:
|
if not url:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -177,15 +193,15 @@ class DoubanScraper:
|
|||||||
url = url.replace("/format/webp", "/format/jpg")
|
url = url.replace("/format/webp", "/format/jpg")
|
||||||
file_path.with_suffix(".jpg")
|
file_path.with_suffix(".jpg")
|
||||||
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
|
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
|
||||||
r = RequestUtils().get_res(url=url)
|
with RequestUtils().get_res(url=url) as r:
|
||||||
if r:
|
if r:
|
||||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||||
self.__save_remove_file(file_path, r.content)
|
self.__save_remove_file(file_path, r.content)
|
||||||
|
else:
|
||||||
|
file_path.write_bytes(r.content)
|
||||||
|
logger.info(f"图片已保存:{file_path}")
|
||||||
else:
|
else:
|
||||||
file_path.write_bytes(r.content)
|
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
|
||||||
logger.info(f"图片已保存:{file_path}")
|
|
||||||
else:
|
|
||||||
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"{file_path.stem}图片下载失败:{str(err)}")
|
logger.error(f"{file_path.stem}图片下载失败:{str(err)}")
|
||||||
|
|
||||||
@@ -193,8 +209,6 @@ class DoubanScraper:
|
|||||||
"""
|
"""
|
||||||
保存NFO
|
保存NFO
|
||||||
"""
|
"""
|
||||||
if file_path.exists():
|
|
||||||
return
|
|
||||||
xml_str = doc.toprettyxml(indent=" ", encoding="utf-8")
|
xml_str = doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||||
self.__save_remove_file(file_path, xml_str)
|
self.__save_remove_file(file_path, xml_str)
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ class EmbyModule(_ModuleBase):
|
|||||||
def stop(self):
|
def stop(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def test(self) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
测试模块连接性
|
||||||
|
"""
|
||||||
|
if self.emby.is_inactive():
|
||||||
|
self.emby.reconnect()
|
||||||
|
if not self.emby.get_user():
|
||||||
|
return False, "无法连接Emby,请检查参数配置"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||||
return "MEDIASERVER", "emby"
|
return "MEDIASERVER", "emby"
|
||||||
|
|
||||||
@@ -103,13 +113,13 @@ class EmbyModule(_ModuleBase):
|
|||||||
media_statistic.user_count = self.emby.get_user_count()
|
media_statistic.user_count = self.emby.get_user_count()
|
||||||
return [media_statistic]
|
return [media_statistic]
|
||||||
|
|
||||||
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
|
def mediaserver_librarys(self, server: str = None, username: str = None) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||||
"""
|
"""
|
||||||
媒体库列表
|
媒体库列表
|
||||||
"""
|
"""
|
||||||
if server != "emby":
|
if server and server != "emby":
|
||||||
return None
|
return None
|
||||||
return self.emby.get_librarys()
|
return self.emby.get_librarys(username)
|
||||||
|
|
||||||
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
|
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
|
||||||
"""
|
"""
|
||||||
@@ -141,3 +151,29 @@ class EmbyModule(_ModuleBase):
|
|||||||
season=season,
|
season=season,
|
||||||
episodes=episodes
|
episodes=episodes
|
||||||
) for season, episodes in seasoninfo.items()]
|
) for season, episodes in seasoninfo.items()]
|
||||||
|
|
||||||
|
def mediaserver_playing(self, count: int = 20,
|
||||||
|
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||||
|
"""
|
||||||
|
获取媒体服务器正在播放信息
|
||||||
|
"""
|
||||||
|
if server and server != "emby":
|
||||||
|
return []
|
||||||
|
return self.emby.get_resume(num=count, username=username)
|
||||||
|
|
||||||
|
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取媒体库播放地址
|
||||||
|
"""
|
||||||
|
if server != "emby":
|
||||||
|
return None
|
||||||
|
return self.emby.get_play_url(item_id)
|
||||||
|
|
||||||
|
def mediaserver_latest(self, count: int = 20,
|
||||||
|
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||||
|
"""
|
||||||
|
获取媒体服务器最新入库条目
|
||||||
|
"""
|
||||||
|
if server and server != "emby":
|
||||||
|
return []
|
||||||
|
return self.emby.get_latest(num=count, username=username)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Union, Dict, Generator, Tuple
|
from typing import List, Optional, Union, Dict, Generator, Tuple
|
||||||
|
|
||||||
@@ -10,10 +11,9 @@ from app.core.config import settings
|
|||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.singleton import Singleton
|
|
||||||
|
|
||||||
|
|
||||||
class Emby(metaclass=Singleton):
|
class Emby:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._host = settings.EMBY_HOST
|
self._host = settings.EMBY_HOST
|
||||||
@@ -22,9 +22,16 @@ class Emby(metaclass=Singleton):
|
|||||||
self._host += "/"
|
self._host += "/"
|
||||||
if not self._host.startswith("http"):
|
if not self._host.startswith("http"):
|
||||||
self._host = "http://" + self._host
|
self._host = "http://" + self._host
|
||||||
|
self._playhost = settings.EMBY_PLAY_HOST
|
||||||
|
if self._playhost:
|
||||||
|
if not self._playhost.endswith("/"):
|
||||||
|
self._playhost += "/"
|
||||||
|
if not self._playhost.startswith("http"):
|
||||||
|
self._playhost = "http://" + self._playhost
|
||||||
self._apikey = settings.EMBY_API_KEY
|
self._apikey = settings.EMBY_API_KEY
|
||||||
self.user = self.get_user(settings.SUPERUSER)
|
self.user = self.get_user(settings.SUPERUSER)
|
||||||
self.folders = self.get_emby_folders()
|
self.folders = self.get_emby_folders()
|
||||||
|
self.serverid = self.get_server_id()
|
||||||
|
|
||||||
def is_inactive(self) -> bool:
|
def is_inactive(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -49,42 +56,84 @@ class Emby(metaclass=Singleton):
|
|||||||
return []
|
return []
|
||||||
req_url = "%semby/Library/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
|
req_url = "%semby/Library/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().get_res(req_url)
|
with RequestUtils().get_res(req_url) as res:
|
||||||
if res:
|
if res:
|
||||||
return res.json()
|
return res.json()
|
||||||
else:
|
else:
|
||||||
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
|
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
|
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def __get_emby_librarys(self) -> List[dict]:
|
def get_emby_virtual_folders(self) -> List[dict]:
|
||||||
|
"""
|
||||||
|
获取Emby媒体库所有路径列表(包含共享路径)
|
||||||
|
"""
|
||||||
|
if not self._host or not self._apikey:
|
||||||
|
return []
|
||||||
|
req_url = "%semby/Library/VirtualFolders/Query?api_key=%s" % (self._host, self._apikey)
|
||||||
|
try:
|
||||||
|
with RequestUtils().get_res(req_url) as res:
|
||||||
|
if res:
|
||||||
|
library_items = res.json().get("Items")
|
||||||
|
librarys = []
|
||||||
|
for library_item in library_items:
|
||||||
|
library_name = library_item.get('Name')
|
||||||
|
pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')
|
||||||
|
library_paths = []
|
||||||
|
for path in pathInfos:
|
||||||
|
if path.get('NetworkPath'):
|
||||||
|
library_paths.append(path.get('NetworkPath'))
|
||||||
|
else:
|
||||||
|
library_paths.append(path.get('Path'))
|
||||||
|
|
||||||
|
if library_name and library_paths:
|
||||||
|
librarys.append({
|
||||||
|
'Name': library_name,
|
||||||
|
'Path': library_paths
|
||||||
|
})
|
||||||
|
return librarys
|
||||||
|
else:
|
||||||
|
logger.error(f"Library/VirtualFolders/Query 未获取到返回数据")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e))
|
||||||
|
return []
|
||||||
|
|
||||||
|
def __get_emby_librarys(self, username: str = None) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
获取Emby媒体库列表
|
获取Emby媒体库列表
|
||||||
"""
|
"""
|
||||||
if not self._host or not self._apikey:
|
if not self._host or not self._apikey:
|
||||||
return []
|
return []
|
||||||
req_url = f"{self._host}emby/Users/{self.user}/Views?api_key={self._apikey}"
|
if username:
|
||||||
|
user = self.get_user(username)
|
||||||
|
else:
|
||||||
|
user = self.user
|
||||||
|
req_url = f"{self._host}emby/Users/{user}/Views?api_key={self._apikey}"
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().get_res(req_url)
|
with RequestUtils().get_res(req_url) as res:
|
||||||
if res:
|
if res:
|
||||||
return res.json().get("Items")
|
return res.json().get("Items")
|
||||||
else:
|
else:
|
||||||
logger.error(f"User/Views 未获取到返回数据")
|
logger.error(f"User/Views 未获取到返回数据")
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接User/Views 出错:" + str(e))
|
logger.error(f"连接User/Views 出错:" + str(e))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_librarys(self) -> List[schemas.MediaServerLibrary]:
|
def get_librarys(self, username: str = None) -> List[schemas.MediaServerLibrary]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器所有媒体库列表
|
获取媒体服务器所有媒体库列表
|
||||||
"""
|
"""
|
||||||
if not self._host or not self._apikey:
|
if not self._host or not self._apikey:
|
||||||
return []
|
return []
|
||||||
libraries = []
|
libraries = []
|
||||||
for library in self.__get_emby_librarys() or []:
|
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
|
||||||
|
for library in self.__get_emby_librarys(username) or []:
|
||||||
|
if library.get("Name") in black_list:
|
||||||
|
continue
|
||||||
match library.get("CollectionType"):
|
match library.get("CollectionType"):
|
||||||
case "movies":
|
case "movies":
|
||||||
library_type = MediaType.MOVIE.value
|
library_type = MediaType.MOVIE.value
|
||||||
@@ -92,13 +141,17 @@ class Emby(metaclass=Singleton):
|
|||||||
library_type = MediaType.TV.value
|
library_type = MediaType.TV.value
|
||||||
case _:
|
case _:
|
||||||
continue
|
continue
|
||||||
|
image = self.__get_local_image_by_id(library.get("Id"))
|
||||||
libraries.append(
|
libraries.append(
|
||||||
schemas.MediaServerLibrary(
|
schemas.MediaServerLibrary(
|
||||||
server="emby",
|
server="emby",
|
||||||
id=library.get("Id"),
|
id=library.get("Id"),
|
||||||
name=library.get("Name"),
|
name=library.get("Name"),
|
||||||
path=library.get("Path"),
|
path=library.get("Path"),
|
||||||
type=library_type
|
type=library_type,
|
||||||
|
image=image,
|
||||||
|
link=f'{self._playhost or self._host}web/index.html'
|
||||||
|
f'#!/videos?serverId={self.serverid}&parentId={library.get("Id")}'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return libraries
|
return libraries
|
||||||
@@ -111,20 +164,20 @@ class Emby(metaclass=Singleton):
|
|||||||
return None
|
return None
|
||||||
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
|
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().get_res(req_url)
|
with RequestUtils().get_res(req_url) as res:
|
||||||
if res:
|
if res:
|
||||||
users = res.json()
|
users = res.json()
|
||||||
# 先查询是否有与当前用户名称匹配的
|
# 先查询是否有与当前用户名称匹配的
|
||||||
if user_name:
|
if user_name:
|
||||||
|
for user in users:
|
||||||
|
if user.get("Name") == user_name:
|
||||||
|
return user.get("Id")
|
||||||
|
# 查询管理员
|
||||||
for user in users:
|
for user in users:
|
||||||
if user.get("Name") == user_name:
|
if user.get("Policy", {}).get("IsAdministrator"):
|
||||||
return user.get("Id")
|
return user.get("Id")
|
||||||
# 查询管理员
|
else:
|
||||||
for user in users:
|
logger.error(f"Users 未获取到返回数据")
|
||||||
if user.get("Policy", {}).get("IsAdministrator"):
|
|
||||||
return user.get("Id")
|
|
||||||
else:
|
|
||||||
logger.error(f"Users 未获取到返回数据")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Users出错:" + str(e))
|
logger.error(f"连接Users出错:" + str(e))
|
||||||
return None
|
return None
|
||||||
@@ -174,11 +227,11 @@ class Emby(metaclass=Singleton):
|
|||||||
return None
|
return None
|
||||||
req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey)
|
req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().get_res(req_url)
|
with RequestUtils().get_res(req_url) as res:
|
||||||
if res:
|
if res:
|
||||||
return res.json().get("Id")
|
return res.json().get("Id")
|
||||||
else:
|
else:
|
||||||
logger.error(f"System/Info 未获取到返回数据")
|
logger.error(f"System/Info 未获取到返回数据")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
||||||
logger.error(f"连接System/Info出错:" + str(e))
|
logger.error(f"连接System/Info出错:" + str(e))
|
||||||
@@ -192,12 +245,12 @@ class Emby(metaclass=Singleton):
|
|||||||
return 0
|
return 0
|
||||||
req_url = "%semby/Users/Query?api_key=%s" % (self._host, self._apikey)
|
req_url = "%semby/Users/Query?api_key=%s" % (self._host, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().get_res(req_url)
|
with RequestUtils().get_res(req_url) as res:
|
||||||
if res:
|
if res:
|
||||||
return res.json().get("TotalRecordCount")
|
return res.json().get("TotalRecordCount")
|
||||||
else:
|
else:
|
||||||
logger.error(f"Users/Query 未获取到返回数据")
|
logger.error(f"Users/Query 未获取到返回数据")
|
||||||
return 0
|
return 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Users/Query出错:" + str(e))
|
logger.error(f"连接Users/Query出错:" + str(e))
|
||||||
return 0
|
return 0
|
||||||
@@ -211,17 +264,17 @@ class Emby(metaclass=Singleton):
|
|||||||
return schemas.Statistic()
|
return schemas.Statistic()
|
||||||
req_url = "%semby/Items/Counts?api_key=%s" % (self._host, self._apikey)
|
req_url = "%semby/Items/Counts?api_key=%s" % (self._host, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().get_res(req_url)
|
with RequestUtils().get_res(req_url) as res:
|
||||||
if res:
|
if res:
|
||||||
result = res.json()
|
result = res.json()
|
||||||
return schemas.Statistic(
|
return schemas.Statistic(
|
||||||
movie_count=result.get("MovieCount") or 0,
|
movie_count=result.get("MovieCount") or 0,
|
||||||
tv_count=result.get("SeriesCount") or 0,
|
tv_count=result.get("SeriesCount") or 0,
|
||||||
episode_count=result.get("EpisodeCount") or 0
|
episode_count=result.get("EpisodeCount") or 0
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(f"Items/Counts 未获取到返回数据")
|
logger.error(f"Items/Counts 未获取到返回数据")
|
||||||
return schemas.Statistic()
|
return schemas.Statistic()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Items/Counts出错:" + str(e))
|
logger.error(f"连接Items/Counts出错:" + str(e))
|
||||||
return schemas.Statistic()
|
return schemas.Statistic()
|
||||||
@@ -246,14 +299,14 @@ class Emby(metaclass=Singleton):
|
|||||||
"&api_key=%s") % (
|
"&api_key=%s") % (
|
||||||
self._host, name, self._apikey)
|
self._host, name, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().get_res(req_url)
|
with RequestUtils().get_res(req_url) as res:
|
||||||
if res:
|
if res:
|
||||||
res_items = res.json().get("Items")
|
res_items = res.json().get("Items")
|
||||||
if res_items:
|
if res_items:
|
||||||
for res_item in res_items:
|
for res_item in res_items:
|
||||||
if res_item.get('Name') == name and (
|
if res_item.get('Name') == name and (
|
||||||
not year or str(res_item.get('ProductionYear')) == str(year)):
|
not year or str(res_item.get('ProductionYear')) == str(year)):
|
||||||
return res_item.get('Id')
|
return res_item.get('Id')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Items出错:" + str(e))
|
logger.error(f"连接Items出错:" + str(e))
|
||||||
return None
|
return None
|
||||||
@@ -276,36 +329,36 @@ class Emby(metaclass=Singleton):
|
|||||||
"&Recursive=true&SearchTerm=%s&Limit=10&IncludeSearchTypes=false&api_key=%s" % (
|
"&Recursive=true&SearchTerm=%s&Limit=10&IncludeSearchTypes=false&api_key=%s" % (
|
||||||
self._host, title, self._apikey)
|
self._host, title, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().get_res(req_url)
|
with RequestUtils().get_res(req_url) as res:
|
||||||
if res:
|
if res:
|
||||||
res_items = res.json().get("Items")
|
res_items = res.json().get("Items")
|
||||||
if res_items:
|
if res_items:
|
||||||
ret_movies = []
|
ret_movies = []
|
||||||
for res_item in res_items:
|
for res_item in res_items:
|
||||||
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
|
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
|
||||||
mediaserver_item = schemas.MediaServerItem(
|
mediaserver_item = schemas.MediaServerItem(
|
||||||
server="emby",
|
server="emby",
|
||||||
library=res_item.get("ParentId"),
|
library=res_item.get("ParentId"),
|
||||||
item_id=res_item.get("Id"),
|
item_id=res_item.get("Id"),
|
||||||
item_type=res_item.get("Type"),
|
item_type=res_item.get("Type"),
|
||||||
title=res_item.get("Name"),
|
title=res_item.get("Name"),
|
||||||
original_title=res_item.get("OriginalTitle"),
|
original_title=res_item.get("OriginalTitle"),
|
||||||
year=res_item.get("ProductionYear"),
|
year=res_item.get("ProductionYear"),
|
||||||
tmdbid=int(item_tmdbid) if item_tmdbid else None,
|
tmdbid=int(item_tmdbid) if item_tmdbid else None,
|
||||||
imdbid=res_item.get("ProviderIds", {}).get("Imdb"),
|
imdbid=res_item.get("ProviderIds", {}).get("Imdb"),
|
||||||
tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"),
|
tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"),
|
||||||
path=res_item.get("Path")
|
path=res_item.get("Path")
|
||||||
)
|
)
|
||||||
if tmdb_id and item_tmdbid:
|
if tmdb_id and item_tmdbid:
|
||||||
if str(item_tmdbid) != str(tmdb_id):
|
if str(item_tmdbid) != str(tmdb_id):
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
|
ret_movies.append(mediaserver_item)
|
||||||
|
continue
|
||||||
|
if (mediaserver_item.title == title
|
||||||
|
and (not year or str(mediaserver_item.year) == str(year))):
|
||||||
ret_movies.append(mediaserver_item)
|
ret_movies.append(mediaserver_item)
|
||||||
continue
|
return ret_movies
|
||||||
if (mediaserver_item.title == title
|
|
||||||
and (not year or str(mediaserver_item.year) == str(year))):
|
|
||||||
ret_movies.append(mediaserver_item)
|
|
||||||
return ret_movies
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Items出错:" + str(e))
|
logger.error(f"连接Items出错:" + str(e))
|
||||||
return None
|
return None
|
||||||
@@ -348,25 +401,25 @@ class Emby(metaclass=Singleton):
|
|||||||
try:
|
try:
|
||||||
req_url = "%semby/Shows/%s/Episodes?Season=%s&IsMissing=false&api_key=%s" % (
|
req_url = "%semby/Shows/%s/Episodes?Season=%s&IsMissing=false&api_key=%s" % (
|
||||||
self._host, item_id, season, self._apikey)
|
self._host, item_id, season, self._apikey)
|
||||||
res_json = RequestUtils().get_res(req_url)
|
with RequestUtils().get_res(req_url) as res_json:
|
||||||
if res_json:
|
if res_json:
|
||||||
tv_item = res_json.json()
|
tv_item = res_json.json()
|
||||||
res_items = tv_item.get("Items")
|
res_items = tv_item.get("Items")
|
||||||
season_episodes = {}
|
season_episodes = {}
|
||||||
for res_item in res_items:
|
for res_item in res_items:
|
||||||
season_index = res_item.get("ParentIndexNumber")
|
season_index = res_item.get("ParentIndexNumber")
|
||||||
if not season_index:
|
if not season_index:
|
||||||
continue
|
continue
|
||||||
if season and season != season_index:
|
if season and season != season_index:
|
||||||
continue
|
continue
|
||||||
episode_index = res_item.get("IndexNumber")
|
episode_index = res_item.get("IndexNumber")
|
||||||
if not episode_index:
|
if not episode_index:
|
||||||
continue
|
continue
|
||||||
if season_index not in season_episodes:
|
if season_index not in season_episodes:
|
||||||
season_episodes[season_index] = []
|
season_episodes[season_index] = []
|
||||||
season_episodes[season_index].append(episode_index)
|
season_episodes[season_index].append(episode_index)
|
||||||
# 返回
|
# 返回
|
||||||
return tv_item.get("Id"), season_episodes
|
return item_id, season_episodes
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Shows/Id/Episodes出错:" + str(e))
|
logger.error(f"连接Shows/Id/Episodes出错:" + str(e))
|
||||||
return None, None
|
return None, None
|
||||||
@@ -383,20 +436,44 @@ class Emby(metaclass=Singleton):
|
|||||||
return None
|
return None
|
||||||
req_url = "%semby/Items/%s/RemoteImages?api_key=%s" % (self._host, item_id, self._apikey)
|
req_url = "%semby/Items/%s/RemoteImages?api_key=%s" % (self._host, item_id, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().get_res(req_url)
|
res = RequestUtils(timeout=10).get_res(req_url)
|
||||||
if res:
|
if res:
|
||||||
images = res.json().get("Images")
|
images = res.json().get("Images")
|
||||||
for image in images:
|
if images:
|
||||||
if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type:
|
for image in images:
|
||||||
return image.get("Url")
|
if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type:
|
||||||
else:
|
return image.get("Url")
|
||||||
logger.error(f"Items/RemoteImages 未获取到返回数据")
|
# 数据为空
|
||||||
return None
|
logger.info(f"Items/RemoteImages 未获取到返回数据,采用本地图片")
|
||||||
|
return self.generate_external_image_link(item_id, image_type)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Items/Id/RemoteImages出错:" + str(e))
|
logger.error(f"连接Items/Id/RemoteImages出错:" + str(e))
|
||||||
return None
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def generate_external_image_link(self, item_id: str, image_type: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
根据ItemId和imageType查询本地对应图片
|
||||||
|
:param item_id: 在Emby中的ID
|
||||||
|
:param image_type: 图片类型,如Backdrop、Primary
|
||||||
|
:return: 图片对应在外网播放器中的URL
|
||||||
|
"""
|
||||||
|
if not self._playhost:
|
||||||
|
logger.error("Emby外网播放地址未能获取或为空")
|
||||||
|
return None
|
||||||
|
|
||||||
|
req_url = "%sItems/%s/Images/%s" % (self._playhost, item_id, image_type)
|
||||||
|
try:
|
||||||
|
with RequestUtils().get_res(req_url) as res:
|
||||||
|
if res and res.status_code != 404:
|
||||||
|
logger.info(f"影片图片链接:{res.url}")
|
||||||
|
return res.url
|
||||||
|
else:
|
||||||
|
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"连接Items/Id/Images出错:" + str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
def __refresh_emby_library_by_id(self, item_id: str) -> bool:
|
def __refresh_emby_library_by_id(self, item_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
通知Emby刷新一个项目的媒体库
|
通知Emby刷新一个项目的媒体库
|
||||||
@@ -405,11 +482,11 @@ class Emby(metaclass=Singleton):
|
|||||||
return False
|
return False
|
||||||
req_url = "%semby/Items/%s/Refresh?Recursive=true&api_key=%s" % (self._host, item_id, self._apikey)
|
req_url = "%semby/Items/%s/Refresh?Recursive=true&api_key=%s" % (self._host, item_id, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().post_res(req_url)
|
with RequestUtils().post_res(req_url) as res:
|
||||||
if res:
|
if res:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.info(f"刷新媒体库对象 {item_id} 失败,无法连接Emby!")
|
logger.info(f"刷新媒体库对象 {item_id} 失败,无法连接Emby!")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Items/Id/Refresh出错:" + str(e))
|
logger.error(f"连接Items/Id/Refresh出错:" + str(e))
|
||||||
return False
|
return False
|
||||||
@@ -423,11 +500,11 @@ class Emby(metaclass=Singleton):
|
|||||||
return False
|
return False
|
||||||
req_url = "%semby/Library/Refresh?api_key=%s" % (self._host, self._apikey)
|
req_url = "%semby/Library/Refresh?api_key=%s" % (self._host, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().post_res(req_url)
|
with RequestUtils().post_res(req_url) as res:
|
||||||
if res:
|
if res:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.info(f"刷新媒体库失败,无法连接Emby!")
|
logger.info(f"刷新媒体库失败,无法连接Emby!")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Library/Refresh出错:" + str(e))
|
logger.error(f"连接Library/Refresh出错:" + str(e))
|
||||||
return False
|
return False
|
||||||
@@ -482,7 +559,7 @@ class Emby(metaclass=Singleton):
|
|||||||
if item_path.is_relative_to(subfolder_path):
|
if item_path.is_relative_to(subfolder_path):
|
||||||
return folder.get("Id")
|
return folder.get("Id")
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(str(err))
|
logger.debug(f"匹配子目录出错:{err} - {traceback.format_exc()}")
|
||||||
# 如果找不到,只要路径中有分类目录名就命中
|
# 如果找不到,只要路径中有分类目录名就命中
|
||||||
for folder in self.folders:
|
for folder in self.folders:
|
||||||
for subfolder in folder.get("SubFolders"):
|
for subfolder in folder.get("SubFolders"):
|
||||||
@@ -502,23 +579,23 @@ class Emby(metaclass=Singleton):
|
|||||||
return None
|
return None
|
||||||
req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._host, self.user, itemid, self._apikey)
|
req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._host, self.user, itemid, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().get_res(req_url)
|
with RequestUtils().get_res(req_url) as res:
|
||||||
if res and res.status_code == 200:
|
if res and res.status_code == 200:
|
||||||
item = res.json()
|
item = res.json()
|
||||||
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||||
return schemas.MediaServerItem(
|
return schemas.MediaServerItem(
|
||||||
server="emby",
|
server="emby",
|
||||||
library=item.get("ParentId"),
|
library=item.get("ParentId"),
|
||||||
item_id=item.get("Id"),
|
item_id=item.get("Id"),
|
||||||
item_type=item.get("Type"),
|
item_type=item.get("Type"),
|
||||||
title=item.get("Name"),
|
title=item.get("Name"),
|
||||||
original_title=item.get("OriginalTitle"),
|
original_title=item.get("OriginalTitle"),
|
||||||
year=item.get("ProductionYear"),
|
year=item.get("ProductionYear"),
|
||||||
tmdbid=int(tmdbid) if tmdbid else None,
|
tmdbid=int(tmdbid) if tmdbid else None,
|
||||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||||
path=item.get("Path")
|
path=item.get("Path")
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Items/Id出错:" + str(e))
|
logger.error(f"连接Items/Id出错:" + str(e))
|
||||||
return None
|
return None
|
||||||
@@ -533,17 +610,17 @@ class Emby(metaclass=Singleton):
|
|||||||
yield None
|
yield None
|
||||||
req_url = "%semby/Users/%s/Items?ParentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
|
req_url = "%semby/Users/%s/Items?ParentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
|
||||||
try:
|
try:
|
||||||
res = RequestUtils().get_res(req_url)
|
with RequestUtils().get_res(req_url) as res:
|
||||||
if res and res.status_code == 200:
|
if res and res.status_code == 200:
|
||||||
results = res.json().get("Items") or []
|
results = res.json().get("Items") or []
|
||||||
for result in results:
|
for result in results:
|
||||||
if not result:
|
if not result:
|
||||||
continue
|
continue
|
||||||
if result.get("Type") in ["Movie", "Series"]:
|
if result.get("Type") in ["Movie", "Series"]:
|
||||||
yield self.get_iteminfo(result.get("Id"))
|
yield self.get_iteminfo(result.get("Id"))
|
||||||
elif "Folder" in result.get("Type"):
|
elif "Folder" in result.get("Type"):
|
||||||
for item in self.get_items(parent=result.get('Id')):
|
for item in self.get_items(parent=result.get('Id')):
|
||||||
yield item
|
yield item
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Users/Items出错:" + str(e))
|
logger.error(f"连接Users/Items出错:" + str(e))
|
||||||
yield None
|
yield None
|
||||||
@@ -846,7 +923,7 @@ class Emby(metaclass=Singleton):
|
|||||||
eventItem.overview = message.get('Item', {}).get('Overview')
|
eventItem.overview = message.get('Item', {}).get('Overview')
|
||||||
eventItem.percentage = message.get('TranscodingInfo', {}).get('CompletionPercentage')
|
eventItem.percentage = message.get('TranscodingInfo', {}).get('CompletionPercentage')
|
||||||
if not eventItem.percentage:
|
if not eventItem.percentage:
|
||||||
if message.get('PlaybackInfo', {}).get('PositionTicks'):
|
if message.get('PlaybackInfo', {}).get('PositionTicks') and message.get('Item', {}).get('RunTimeTicks'):
|
||||||
eventItem.percentage = message.get('PlaybackInfo', {}).get('PositionTicks') / \
|
eventItem.percentage = message.get('PlaybackInfo', {}).get('PositionTicks') / \
|
||||||
message.get('Item', {}).get('RunTimeTicks') * 100
|
message.get('Item', {}).get('RunTimeTicks') * 100
|
||||||
if message.get('Session'):
|
if message.get('Session'):
|
||||||
@@ -879,9 +956,9 @@ class Emby(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
if not self._host or not self._apikey:
|
if not self._host or not self._apikey:
|
||||||
return None
|
return None
|
||||||
url = url.replace("[HOST]", self._host) \
|
url = url.replace("[HOST]", self._host or '') \
|
||||||
.replace("[APIKEY]", self._apikey) \
|
.replace("[APIKEY]", self._apikey or '') \
|
||||||
.replace("[USER]", self.user)
|
.replace("[USER]", self.user or '')
|
||||||
try:
|
try:
|
||||||
return RequestUtils(content_type="application/json").get_res(url=url)
|
return RequestUtils(content_type="application/json").get_res(url=url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -897,9 +974,9 @@ class Emby(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
if not self._host or not self._apikey:
|
if not self._host or not self._apikey:
|
||||||
return None
|
return None
|
||||||
url = url.replace("[HOST]", self._host) \
|
url = url.replace("[HOST]", self._host or '') \
|
||||||
.replace("[APIKEY]", self._apikey) \
|
.replace("[APIKEY]", self._apikey or '') \
|
||||||
.replace("[USER]", self.user)
|
.replace("[USER]", self.user or '')
|
||||||
try:
|
try:
|
||||||
return RequestUtils(
|
return RequestUtils(
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -907,3 +984,160 @@ class Emby(metaclass=Singleton):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"连接Emby出错:" + str(e))
|
logger.error(f"连接Emby出错:" + str(e))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_play_url(self, item_id: str) -> str:
|
||||||
|
"""
|
||||||
|
拼装媒体播放链接
|
||||||
|
:param item_id: 媒体的的ID
|
||||||
|
"""
|
||||||
|
return f"{self._playhost or self._host}web/index.html#!" \
|
||||||
|
f"/item?id={item_id}&context=home&serverId={self.serverid}"
|
||||||
|
|
||||||
|
def __get_backdrop_url(self, item_id: str, image_tag: str) -> str:
|
||||||
|
"""
|
||||||
|
获取Emby的Backdrop图片地址
|
||||||
|
:param: item_id: 在Emby中的ID
|
||||||
|
:param: image_tag: 图片的tag
|
||||||
|
:param: remote 是否远程使用,TG微信等客户端调用应为True
|
||||||
|
:param: inner 是否NT内部调用,为True是会使用NT中转
|
||||||
|
"""
|
||||||
|
if not self._host or not self._apikey:
|
||||||
|
return ""
|
||||||
|
if not image_tag or not item_id:
|
||||||
|
return ""
|
||||||
|
return f"{self._host}Items/{item_id}/" \
|
||||||
|
f"Images/Backdrop?tag={image_tag}&fillWidth=666&api_key={self._apikey}"
|
||||||
|
|
||||||
|
def __get_local_image_by_id(self, item_id: str) -> str:
|
||||||
|
"""
|
||||||
|
根据ItemId从媒体服务器查询本地图片地址
|
||||||
|
:param: item_id: 在Emby中的ID
|
||||||
|
:param: remote 是否远程使用,TG微信等客户端调用应为True
|
||||||
|
:param: inner 是否NT内部调用,为True是会使用NT中转
|
||||||
|
"""
|
||||||
|
if not self._host or not self._apikey:
|
||||||
|
return ""
|
||||||
|
return "%sItems/%s/Images/Primary" % (self._host, item_id)
|
||||||
|
|
||||||
|
def get_resume(self, num: int = 12, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
|
||||||
|
"""
|
||||||
|
获得继续观看
|
||||||
|
"""
|
||||||
|
if not self._host or not self._apikey:
|
||||||
|
return None
|
||||||
|
if username:
|
||||||
|
user = self.get_user(username)
|
||||||
|
else:
|
||||||
|
user = self.user
|
||||||
|
req_url = (f"{self._host}Users/{user}/Items/Resume?"
|
||||||
|
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
|
||||||
|
try:
|
||||||
|
with RequestUtils().get_res(req_url) as res:
|
||||||
|
if res:
|
||||||
|
result = res.json().get("Items") or []
|
||||||
|
ret_resume = []
|
||||||
|
# 用户媒体库文件夹列表(排除黑名单)
|
||||||
|
library_folders = self.get_user_library_folders()
|
||||||
|
for item in result:
|
||||||
|
if len(ret_resume) == num:
|
||||||
|
break
|
||||||
|
if item.get("Type") not in ["Movie", "Episode"]:
|
||||||
|
continue
|
||||||
|
item_path = item.get("Path")
|
||||||
|
if item_path and library_folders and not any(
|
||||||
|
str(item_path).startswith(folder) for folder in library_folders):
|
||||||
|
continue
|
||||||
|
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
|
||||||
|
link = self.get_play_url(item.get("Id"))
|
||||||
|
if item_type == MediaType.MOVIE.value:
|
||||||
|
title = item.get("Name")
|
||||||
|
subtitle = item.get("ProductionYear")
|
||||||
|
else:
|
||||||
|
title = f'{item.get("SeriesName")}'
|
||||||
|
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
|
||||||
|
if item_type == MediaType.MOVIE.value:
|
||||||
|
if item.get("BackdropImageTags"):
|
||||||
|
image = self.__get_backdrop_url(item_id=item.get("Id"),
|
||||||
|
image_tag=item.get("BackdropImageTags")[0])
|
||||||
|
else:
|
||||||
|
image = self.__get_local_image_by_id(item.get("Id"))
|
||||||
|
else:
|
||||||
|
image = self.__get_backdrop_url(item_id=item.get("SeriesId"),
|
||||||
|
image_tag=item.get("SeriesPrimaryImageTag"))
|
||||||
|
if not image:
|
||||||
|
image = self.__get_local_image_by_id(item.get("SeriesId"))
|
||||||
|
ret_resume.append(schemas.MediaServerPlayItem(
|
||||||
|
id=item.get("Id"),
|
||||||
|
title=title,
|
||||||
|
subtitle=subtitle,
|
||||||
|
type=item_type,
|
||||||
|
image=image,
|
||||||
|
link=link,
|
||||||
|
percent=item.get("UserData", {}).get("PlayedPercentage")
|
||||||
|
))
|
||||||
|
return ret_resume
|
||||||
|
else:
|
||||||
|
logger.error(f"Users/Items/Resume 未获取到返回数据")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"连接Users/Items/Resume出错:" + str(e))
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_latest(self, num: int = 20, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
|
||||||
|
"""
|
||||||
|
获得最近更新
|
||||||
|
"""
|
||||||
|
if not self._host or not self._apikey:
|
||||||
|
return None
|
||||||
|
if username:
|
||||||
|
user = self.get_user(username)
|
||||||
|
else:
|
||||||
|
user = self.user
|
||||||
|
req_url = (f"{self._host}Users/{user}/Items/Latest?"
|
||||||
|
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
|
||||||
|
try:
|
||||||
|
with RequestUtils().get_res(req_url) as res:
|
||||||
|
if res:
|
||||||
|
result = res.json() or []
|
||||||
|
ret_latest = []
|
||||||
|
# 用户媒体库文件夹列表(排除黑名单)
|
||||||
|
library_folders = self.get_user_library_folders()
|
||||||
|
for item in result:
|
||||||
|
if len(ret_latest) == num:
|
||||||
|
break
|
||||||
|
if item.get("Type") not in ["Movie", "Series"]:
|
||||||
|
continue
|
||||||
|
item_path = item.get("Path")
|
||||||
|
if item_path and library_folders and not any(
|
||||||
|
str(item_path).startswith(folder) for folder in library_folders):
|
||||||
|
continue
|
||||||
|
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
|
||||||
|
link = self.get_play_url(item.get("Id"))
|
||||||
|
image = self.__get_local_image_by_id(item_id=item.get("Id"))
|
||||||
|
ret_latest.append(schemas.MediaServerPlayItem(
|
||||||
|
id=item.get("Id"),
|
||||||
|
title=item.get("Name"),
|
||||||
|
subtitle=item.get("ProductionYear"),
|
||||||
|
type=item_type,
|
||||||
|
image=image,
|
||||||
|
link=link
|
||||||
|
))
|
||||||
|
return ret_latest
|
||||||
|
else:
|
||||||
|
logger.error(f"Users/Items/Latest 未获取到返回数据")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"连接Users/Items/Latest出错:" + str(e))
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_user_library_folders(self):
|
||||||
|
"""
|
||||||
|
获取Emby媒体库文件夹列表(排除黑名单)
|
||||||
|
"""
|
||||||
|
if not self._host or not self._apikey:
|
||||||
|
return []
|
||||||
|
library_folders = []
|
||||||
|
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
|
||||||
|
for library in self.get_emby_virtual_folders() or []:
|
||||||
|
if library.get("Name") in black_list:
|
||||||
|
continue
|
||||||
|
library_folders += [folder for folder in library.get("Path")]
|
||||||
|
return library_folders
|
||||||
|
|||||||
@@ -317,6 +317,17 @@ class FanartModule(_ModuleBase):
|
|||||||
def stop(self):
|
def stop(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def test(self) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
测试模块连接性
|
||||||
|
"""
|
||||||
|
with RequestUtils().get_res("https://webservice.fanart.tv") as ret:
|
||||||
|
if ret and ret.status_code == 200:
|
||||||
|
return True, ""
|
||||||
|
elif ret:
|
||||||
|
return False, f"无法连接fanart,错误码:{ret.status_code}"
|
||||||
|
return False, "fanart网络连接失败"
|
||||||
|
|
||||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||||
return "FANART_API_KEY", True
|
return "FANART_API_KEY", True
|
||||||
|
|
||||||
@@ -326,17 +337,21 @@ class FanartModule(_ModuleBase):
|
|||||||
:param mediainfo: 识别的媒体信息
|
:param mediainfo: 识别的媒体信息
|
||||||
:return: 更新后的媒体信息
|
:return: 更新后的媒体信息
|
||||||
"""
|
"""
|
||||||
|
if not settings.FANART_ENABLE:
|
||||||
|
return None
|
||||||
|
if not mediainfo.tmdb_id and not mediainfo.tvdb_id:
|
||||||
|
return None
|
||||||
if mediainfo.type == MediaType.MOVIE:
|
if mediainfo.type == MediaType.MOVIE:
|
||||||
result = self.__request_fanart(mediainfo.type, mediainfo.tmdb_id)
|
result = self.__request_fanart(mediainfo.type, mediainfo.tmdb_id)
|
||||||
else:
|
else:
|
||||||
if mediainfo.tvdb_id:
|
if mediainfo.tvdb_id:
|
||||||
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
|
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
|
||||||
else:
|
else:
|
||||||
logger.info(f"{mediainfo.title_year} 没有tvdbid,无法获取Fanart图片")
|
logger.info(f"{mediainfo.title_year} 没有tvdbid,无法获取fanart图片")
|
||||||
return
|
return None
|
||||||
if not result or result.get('status') == 'error':
|
if not result or result.get('status') == 'error':
|
||||||
logger.warn(f"没有获取到 {mediainfo.title_year} 的Fanart图片数据")
|
logger.warn(f"没有获取到 {mediainfo.title_year} 的fanart图片数据")
|
||||||
return
|
return None
|
||||||
# 获取所有图片
|
# 获取所有图片
|
||||||
for name, images in result.items():
|
for name, images in result.items():
|
||||||
if not images:
|
if not images:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user