mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-07 16:53:03 +08:00
Compare commits
1347 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c1be70c85 | ||
|
|
b8e0c0db9e | ||
|
|
7b7fb6cc82 | ||
|
|
62512ba215 | ||
|
|
e1beb64c01 | ||
|
|
c81f26ddad | ||
|
|
340114c2a1 | ||
|
|
cd7767b331 | ||
|
|
25289dad8a | ||
|
|
47c6917129 | ||
|
|
6379cda148 | ||
|
|
91a124ab8f | ||
|
|
2357a7135e | ||
|
|
da0b3b3de9 | ||
|
|
6664fb1716 | ||
|
|
1206f24fa9 | ||
|
|
ffb5823e84 | ||
|
|
d45a7fb262 | ||
|
|
918d192c0f | ||
|
|
f7cd6eac50 | ||
|
|
88f4428ff0 | ||
|
|
069ea22ba2 | ||
|
|
8fac8c5307 | ||
|
|
2285befebb | ||
|
|
1cd0648e4e | ||
|
|
0b7ba285c6 | ||
|
|
30446c4526 | ||
|
|
9b843c9ed2 | ||
|
|
2ce1c3bef8 | ||
|
|
e463094dc7 | ||
|
|
71a9fe10f4 | ||
|
|
ba146e13ef | ||
|
|
c060d7e3e0 | ||
|
|
ba96678822 | ||
|
|
4f6354f383 | ||
|
|
2766e80346 | ||
|
|
7cc3777a60 | ||
|
|
cb1dd9f17d | ||
|
|
31f342fe4f | ||
|
|
e90359eb08 | ||
|
|
58b0768a30 | ||
|
|
3b04506893 | ||
|
|
354165aa0a | ||
|
|
343109836f | ||
|
|
fcadac2adb | ||
|
|
5e7dcdfe97 | ||
|
|
2ec9a57391 | ||
|
|
973c545723 | ||
|
|
fd62eecfef | ||
|
|
b5ca7058c2 | ||
|
|
57a48f099f | ||
|
|
4699f511bf | ||
|
|
cd8f7e72e0 | ||
|
|
78803fa284 | ||
|
|
2e8d75df16 | ||
|
|
7e3bbfd960 | ||
|
|
1734d53b3c | ||
|
|
f37540f4e5 | ||
|
|
addb9d836a | ||
|
|
4184d8c7ac | ||
|
|
724c15a68c | ||
|
|
499bdf9b48 | ||
|
|
41cd1ccda1 | ||
|
|
b9521cb3a9 | ||
|
|
1f40663b90 | ||
|
|
5261ed7c4c | ||
|
|
aa8768b18a | ||
|
|
aad07433f4 | ||
|
|
4a7630079b | ||
|
|
44a6ee1994 | ||
|
|
56bd6e69ed | ||
|
|
d1e04588d0 | ||
|
|
21cdaef6d5 | ||
|
|
a1723d18fb | ||
|
|
9e065138e9 | ||
|
|
1c73c92bfd | ||
|
|
bcd560d74e | ||
|
|
02339562ed | ||
|
|
e5804378c2 | ||
|
|
da1c8a162d | ||
|
|
d457a23a1f | ||
|
|
b6154e58b8 | ||
|
|
5f18776c61 | ||
|
|
68b0b9ec7a | ||
|
|
0f5036972e | ||
|
|
0b199b8421 | ||
|
|
a59730f6eb | ||
|
|
c6c84fe65b | ||
|
|
03c757bba6 | ||
|
|
bfeb8d238a | ||
|
|
daf0c08c4b | ||
|
|
d12c1b9ac4 | ||
|
|
bc242f4fd4 | ||
|
|
a240c1bca9 | ||
|
|
219aa6c574 | ||
|
|
abca1b481a | ||
|
|
db72fd2ef5 | ||
|
|
31cca58943 | ||
|
|
c06a4b759c | ||
|
|
f05a23a490 | ||
|
|
1e0f2ffde0 | ||
|
|
06df42ee3d | ||
|
|
65ee1638f7 | ||
|
|
87eefe7673 | ||
|
|
5c124d3988 | ||
|
|
8c69ce624f | ||
|
|
bb73acdde5 | ||
|
|
993bc3775b | ||
|
|
3d2ff28bcd | ||
|
|
9b78deb802 | ||
|
|
dadc525d0b | ||
|
|
22b2140c94 | ||
|
|
f07496a4a0 | ||
|
|
1b2938cbc8 | ||
|
|
d4d2f58830 | ||
|
|
b3113e13ec | ||
|
|
055c8e26f0 | ||
|
|
2a7a7239d7 | ||
|
|
2fa40dac3f | ||
|
|
6b4fbd7dc2 | ||
|
|
5b0bb19717 | ||
|
|
843dfc430a | ||
|
|
69cb07c527 | ||
|
|
89e8a64734 | ||
|
|
5eb2dec32d | ||
|
|
db0ea7d6c4 | ||
|
|
1eb85003de | ||
|
|
cca170f84a | ||
|
|
c8c016caa8 | ||
|
|
45d5874026 | ||
|
|
69b1ce60ff | ||
|
|
3ff3e4b106 | ||
|
|
dc50a68b01 | ||
|
|
968cfd8654 | ||
|
|
cf28d93be6 | ||
|
|
be08d6ebb5 | ||
|
|
4bc24f3b00 | ||
|
|
15833f94cf | ||
|
|
aeb297efcf | ||
|
|
d48c6b98e8 | ||
|
|
b79ccfafed | ||
|
|
c87ba59552 | ||
|
|
91fd71c858 | ||
|
|
6f64e67538 | ||
|
|
bd7a0b072f | ||
|
|
01ca001c97 | ||
|
|
324ad2a87c | ||
|
|
d9ad2630f0 | ||
|
|
83958a4a48 | ||
|
|
f6a6efdc42 | ||
|
|
1bbe7657b9 | ||
|
|
38189753b5 | ||
|
|
5b0e658617 | ||
|
|
b6cf54d57f | ||
|
|
e8058c8813 | ||
|
|
784868048d | ||
|
|
2bf9779f2f | ||
|
|
d98ceea381 | ||
|
|
1ab2da74b9 | ||
|
|
086b1f1403 | ||
|
|
19608fa98e | ||
|
|
b0d17deda1 | ||
|
|
4c979c458e | ||
|
|
c5e93169ad | ||
|
|
1e2ca294de | ||
|
|
7165c4a275 | ||
|
|
cbe81ba33c | ||
|
|
fdbfae953d | ||
|
|
c7ba274877 | ||
|
|
8b15a16ca1 | ||
|
|
9f2c8d3811 | ||
|
|
7343dfbed8 | ||
|
|
90f74d8d2b | ||
|
|
7e3e0e1178 | ||
|
|
d890e38a10 | ||
|
|
e505b5c85f | ||
|
|
6230f55116 | ||
|
|
c8d0c14ebc | ||
|
|
6ac8455c74 | ||
|
|
143b21631f | ||
|
|
d760facad8 | ||
|
|
3a1a4c5cfe | ||
|
|
c3045e2cd4 | ||
|
|
1efb9af7ab | ||
|
|
e03471159a | ||
|
|
a92e493742 | ||
|
|
225d413ed1 | ||
|
|
184e4ba7d5 | ||
|
|
917cae27b1 | ||
|
|
60e0463051 | ||
|
|
c15022c7d5 | ||
|
|
2a84e3a606 | ||
|
|
fddbbd5714 | ||
|
|
51b8f7c713 | ||
|
|
e97c246741 | ||
|
|
9a81f55ac0 | ||
|
|
a38b702acc | ||
|
|
e4e0605e92 | ||
|
|
8875a8f12c | ||
|
|
4dd1deefa5 | ||
|
|
1f6dc93ea3 | ||
|
|
426e920fff | ||
|
|
1f6bbce326 | ||
|
|
41f89a35fa | ||
|
|
099d7874d7 | ||
|
|
e2367103a1 | ||
|
|
37f8ba7d72 | ||
|
|
c20bd84edd | ||
|
|
b4ee0d2487 | ||
|
|
420fa7645f | ||
|
|
5bb1e72760 | ||
|
|
e2a007b62a | ||
|
|
210813367f | ||
|
|
770a50764e | ||
|
|
e339a22aa4 | ||
|
|
913afed378 | ||
|
|
db3efb4452 | ||
|
|
840351acb7 | ||
|
|
da76a7f299 | ||
|
|
cbd999f88d | ||
|
|
2fa8a266c5 | ||
|
|
08aa749a53 | ||
|
|
2379f04d2a | ||
|
|
0e73598d1c | ||
|
|
964e6eb0e8 | ||
|
|
0430e6c6d4 | ||
|
|
db88358eca | ||
|
|
723e9b0018 | ||
|
|
f3db27a8da | ||
|
|
0fb7a73fc9 | ||
|
|
418e6bd085 | ||
|
|
5a5c4ace6b | ||
|
|
c2c8214075 | ||
|
|
e5d2ade6e6 | ||
|
|
e32b6e07b4 | ||
|
|
cc69d3b8d1 | ||
|
|
1dd3af44b5 | ||
|
|
8ab233baef | ||
|
|
104138b9a7 | ||
|
|
0c8fd5121a | ||
|
|
61f26d331b | ||
|
|
97817cd808 | ||
|
|
45bcc63c06 | ||
|
|
00779d0f10 | ||
|
|
d657bf8ed8 | ||
|
|
4fcdd05e6a | ||
|
|
e6916946a9 | ||
|
|
acd7013dc6 | ||
|
|
039d876e3f | ||
|
|
3fc2c7d6cc | ||
|
|
109164b673 | ||
|
|
673a03e656 | ||
|
|
1e976e6d96 | ||
|
|
8efba30adb | ||
|
|
713d44eac3 | ||
|
|
aea44c1d97 | ||
|
|
1e61e60d73 | ||
|
|
a0e4b4a56e | ||
|
|
983f8fcb03 | ||
|
|
6afdde7dc1 | ||
|
|
6873de7243 | ||
|
|
ee4d6d0db3 | ||
|
|
dee1212a76 | ||
|
|
ceda69aedd | ||
|
|
75ea7d7601 | ||
|
|
8b75d2312c | ||
|
|
ca51880798 | ||
|
|
8b708e8939 | ||
|
|
b6ff9f7196 | ||
|
|
67229fd032 | ||
|
|
d382eab355 | ||
|
|
d8f10e9ac4 | ||
|
|
749aaeb003 | ||
|
|
c5a3bbcecf | ||
|
|
27ac41531b | ||
|
|
423c9af786 | ||
|
|
232759829e | ||
|
|
71f7bc7b1b | ||
|
|
ae4f03e272 | ||
|
|
acb5a7e50b | ||
|
|
c8749b3c9c | ||
|
|
49647e3bb5 | ||
|
|
48d353aa90 | ||
|
|
edec18cacb | ||
|
|
cd8661abc1 | ||
|
|
5f6310f5d6 | ||
|
|
42d955b175 | ||
|
|
21541bc468 | ||
|
|
f14f4e1e9b | ||
|
|
6d1de8a2e4 | ||
|
|
0053d31f84 | ||
|
|
f077a9684b | ||
|
|
2428d58e93 | ||
|
|
5340e3a0a7 | ||
|
|
70dd8f0f1d | ||
|
|
8fa76504c3 | ||
|
|
0899cb4e1d | ||
|
|
ee7a2a70a6 | ||
|
|
d57d1ac15e | ||
|
|
68c29d89c9 | ||
|
|
721648ffdf | ||
|
|
8437f39bf6 | ||
|
|
48b15c60e7 | ||
|
|
e350122125 | ||
|
|
0cce97f373 | ||
|
|
d8cacc0811 | ||
|
|
7abaf70bb8 | ||
|
|
232fe4d15e | ||
|
|
d6d12c0335 | ||
|
|
8e4f12804b | ||
|
|
c21ba5c521 | ||
|
|
dfa3d47261 | ||
|
|
924f59afff | ||
|
|
673b282d6c | ||
|
|
1c761f89e5 | ||
|
|
f61cd969b9 | ||
|
|
e39a130306 | ||
|
|
13b6ea985e | ||
|
|
2f1e55fa1e | ||
|
|
776f629771 | ||
|
|
d9e9edb2c4 | ||
|
|
753c074e59 | ||
|
|
d92c82775a | ||
|
|
215cc09c1f | ||
|
|
7f302c13c7 | ||
|
|
de6a094d10 | ||
|
|
a94e1a8314 | ||
|
|
f5efdd665b | ||
|
|
43e25e8717 | ||
|
|
a8026fefc1 | ||
|
|
fdb36957c9 | ||
|
|
ea433ff807 | ||
|
|
8902fb50d6 | ||
|
|
b6aa013eb3 | ||
|
|
034b43bf70 | ||
|
|
59e9032286 | ||
|
|
52a98efd0a | ||
|
|
90cc91aa7f | ||
|
|
1973a26e83 | ||
|
|
6519ad25ca | ||
|
|
cacfde8166 | ||
|
|
df85873726 | ||
|
|
dfea294cc9 | ||
|
|
d35b855404 | ||
|
|
7a1cbf70e3 | ||
|
|
f260990b86 | ||
|
|
6affbe9b55 | ||
|
|
dbe3a10697 | ||
|
|
3c25306a5d | ||
|
|
17f4d49731 | ||
|
|
e213b5cc64 | ||
|
|
65e5dad44b | ||
|
|
62ad38ea5d | ||
|
|
f98f4c1f77 | ||
|
|
e9f02b58b7 | ||
|
|
05495e481d | ||
|
|
5bb2167b78 | ||
|
|
b4e0ed66cf | ||
|
|
70a0563435 | ||
|
|
955912b832 | ||
|
|
b65ee75b3d | ||
|
|
f642493a38 | ||
|
|
7f1bfb1e07 | ||
|
|
8931e2e016 | ||
|
|
0465fa77c2 | ||
|
|
575d503cb9 | ||
|
|
a4fdbdb9ad | ||
|
|
b9cb781a4e | ||
|
|
a3adf867b7 | ||
|
|
d52cbd2f74 | ||
|
|
8d0003db94 | ||
|
|
b775e89e77 | ||
|
|
0e14b097ba | ||
|
|
51848b8d8d | ||
|
|
72658c3e60 | ||
|
|
036cb6f3b0 | ||
|
|
1a86d96bfa | ||
|
|
f67db38a25 | ||
|
|
028d18826a | ||
|
|
29a605f265 | ||
|
|
4b6959470d | ||
|
|
600767d2bf | ||
|
|
3efbd47ffd | ||
|
|
d17e85217b | ||
|
|
e608089805 | ||
|
|
b852acec28 | ||
|
|
2a3ea8315d | ||
|
|
9271ee833c | ||
|
|
570d4ad1a3 | ||
|
|
dccdf3231a | ||
|
|
b8ee777fd2 | ||
|
|
a2fd3a8d90 | ||
|
|
bbffb1420b | ||
|
|
8ea0a32879 | ||
|
|
8c27b8c33e | ||
|
|
5c61b22c2f | ||
|
|
9da9d765a0 | ||
|
|
f64363728e | ||
|
|
378777dc7c | ||
|
|
6156b9a481 | ||
|
|
8c516c5691 | ||
|
|
bf9a149898 | ||
|
|
277cde8db2 | ||
|
|
e06bdaf53e | ||
|
|
da367bd138 | ||
|
|
d336bcbf1f | ||
|
|
a8aedba6ff | ||
|
|
9ede86c6a3 | ||
|
|
1468f2b082 | ||
|
|
e04ae70f89 | ||
|
|
7f7d2c9ba8 | ||
|
|
d73deef8dc | ||
|
|
f93a1540af | ||
|
|
c8bd9cb716 | ||
|
|
2ed13c7e5b | ||
|
|
647c0929c5 | ||
|
|
a61533a131 | ||
|
|
bc5e682308 | ||
|
|
25a481df12 | ||
|
|
764c10fae4 | ||
|
|
d8249d4e38 | ||
|
|
0e3e42b398 | ||
|
|
7d3b64dcf9 | ||
|
|
2c8d525796 | ||
|
|
4869f071ab | ||
|
|
3029eeaf6f | ||
|
|
33fb692aee | ||
|
|
6a075d144f | ||
|
|
aa23315599 | ||
|
|
8d0bb35505 | ||
|
|
32e76bc6ce | ||
|
|
6c02766000 | ||
|
|
52ef390464 | ||
|
|
43a557601e | ||
|
|
82ff7fc090 | ||
|
|
db40b5105b | ||
|
|
b2a379b84b | ||
|
|
97cbd816fe | ||
|
|
7de3bb2a91 | ||
|
|
3a8a2bcab4 | ||
|
|
eb1adbe992 | ||
|
|
b55966d42b | ||
|
|
451ca9cb5a | ||
|
|
1e2c607ced | ||
|
|
5ff7da0d19 | ||
|
|
8e06c6f8e6 | ||
|
|
4497cd3904 | ||
|
|
2945679a94 | ||
|
|
1eaf7e3c85 | ||
|
|
8146b680c6 | ||
|
|
99e667382f | ||
|
|
4c03759d3f | ||
|
|
8593a6cdd0 | ||
|
|
cd18c31618 | ||
|
|
f29c918700 | ||
|
|
0f0c3e660b | ||
|
|
1cf4639db3 | ||
|
|
f5da9b5780 | ||
|
|
e4c87c8a96 | ||
|
|
4b4bf153f0 | ||
|
|
ec227d0d56 | ||
|
|
53c8c50779 | ||
|
|
07b4c8b462 | ||
|
|
f3cfc5b9f0 | ||
|
|
634e5a4c55 | ||
|
|
332b154f15 | ||
|
|
b446d4db28 | ||
|
|
ce0397a140 | ||
|
|
f278cccef3 | ||
|
|
cbf1dbcd2e | ||
|
|
037c6b02fa | ||
|
|
5f44e4322d | ||
|
|
6cebe97d6d | ||
|
|
82ec146446 | ||
|
|
3928c352c6 | ||
|
|
0ba36d21a9 | ||
|
|
6152727e9b | ||
|
|
53c02fa706 | ||
|
|
c7800df801 | ||
|
|
562c1de0c9 | ||
|
|
e2c90639f3 | ||
|
|
92e175a8d1 | ||
|
|
cf7bca75f6 | ||
|
|
24a173f075 | ||
|
|
8d695dda55 | ||
|
|
93eec6c4b8 | ||
|
|
a2cc1a2926 | ||
|
|
11729d0eca | ||
|
|
978819be38 | ||
|
|
23c9862eb3 | ||
|
|
a9f18ea3ef | ||
|
|
574257edf8 | ||
|
|
bb4438ac42 | ||
|
|
0baf6e5fe7 | ||
|
|
d8a53da8ee | ||
|
|
9555ac6305 | ||
|
|
4dd5ea8e2f | ||
|
|
8068523d88 | ||
|
|
27dd681d9f | ||
|
|
152f814fb6 | ||
|
|
2700e639f1 | ||
|
|
c440ce3045 | ||
|
|
2829a3cb4e | ||
|
|
a487091be8 | ||
|
|
e7524774da | ||
|
|
3918c876c5 | ||
|
|
f07f87735c | ||
|
|
b7566e8fe8 | ||
|
|
73eba90f2f | ||
|
|
62e74f6fd1 | ||
|
|
4375e48840 | ||
|
|
a1d6e94e90 | ||
|
|
1f44e13ff0 | ||
|
|
d2992f9ced | ||
|
|
950337bccc | ||
|
|
757c3be359 | ||
|
|
269ab9adfc | ||
|
|
bd241a5164 | ||
|
|
3d92b57f24 | ||
|
|
70d8cb3697 | ||
|
|
9e4ec5841c | ||
|
|
682f4fe608 | ||
|
|
ce8a077e07 | ||
|
|
d5f63bcdb3 | ||
|
|
5c3756fd1b | ||
|
|
99939e1a3d | ||
|
|
56742ace11 | ||
|
|
742cb7a8da | ||
|
|
98327d1750 | ||
|
|
b944306302 | ||
|
|
02ab1d4111 | ||
|
|
28552fb0ce | ||
|
|
bf52fcb2ec | ||
|
|
bab1f73480 | ||
|
|
c06001d921 | ||
|
|
0fa49bb9c6 | ||
|
|
bf23fe6ce2 | ||
|
|
7c6137b742 | ||
|
|
3823a7c9b6 | ||
|
|
a944975be2 | ||
|
|
6da65d3b03 | ||
|
|
0d938f2dca | ||
|
|
4fa9bb3c1f | ||
|
|
2f5b22a81f | ||
|
|
fcd5ca3fda | ||
|
|
c18247f3b1 | ||
|
|
f8fbfdbba7 | ||
|
|
21addfb947 | ||
|
|
8672bd12c4 | ||
|
|
be8054e81e | ||
|
|
82f46c6010 | ||
|
|
95a827e8a2 | ||
|
|
c534e3dcb8 | ||
|
|
9f5e1b8dd7 | ||
|
|
c86ed20c34 | ||
|
|
c32c37e66a | ||
|
|
7b100d3cdb | ||
|
|
95a2362885 | ||
|
|
d8b14b9a9f | ||
|
|
c45953f63a | ||
|
|
e3d3087a5d | ||
|
|
e162bd1168 | ||
|
|
db5d81d7f0 | ||
|
|
f737f1287b | ||
|
|
1ffa5178db | ||
|
|
49cb43488c | ||
|
|
fd7a6f8ddd | ||
|
|
7979ce0f0a | ||
|
|
2ba5d9484d | ||
|
|
23b981c5ac | ||
|
|
86ab2c8c05 | ||
|
|
9ea0bc609a | ||
|
|
5366c2844a | ||
|
|
eac4d703c7 | ||
|
|
8ed87294e2 | ||
|
|
b343c601be | ||
|
|
e56d7006b4 | ||
|
|
1b7bcd7784 | ||
|
|
4cb9025b6c | ||
|
|
f8864ab053 | ||
|
|
64eba46a67 | ||
|
|
35d9cc1d40 | ||
|
|
3036107dac | ||
|
|
214089b4ea | ||
|
|
95b7ba28e4 | ||
|
|
880272f96e | ||
|
|
7ed26fadb6 | ||
|
|
f0d25a02a6 | ||
|
|
162ba9307d | ||
|
|
49dae92b8e | ||
|
|
b484a52b6d | ||
|
|
d754091a7c | ||
|
|
e2febc24ae | ||
|
|
d0677edaaa | ||
|
|
f0aaecd0c7 | ||
|
|
3518940fec | ||
|
|
2e5c92ae0c | ||
|
|
4ad699dbe6 | ||
|
|
931be9e6aa | ||
|
|
9656d6fbd0 | ||
|
|
c7cbb13044 | ||
|
|
327d30dcc2 | ||
|
|
e4e2079917 | ||
|
|
0427506572 | ||
|
|
ea168edb43 | ||
|
|
aa039c6c05 | ||
|
|
3de998051a | ||
|
|
69ade1ae37 | ||
|
|
1d6133e3b1 | ||
|
|
203a111d1a | ||
|
|
0a20234268 | ||
|
|
7f8e50f83d | ||
|
|
443ef7d41b | ||
|
|
059ae6595d | ||
|
|
19c3dad338 | ||
|
|
81bc51c972 | ||
|
|
6c17868744 | ||
|
|
a18040ccfa | ||
|
|
0835a75503 | ||
|
|
3ee32757e5 | ||
|
|
344abfa8d8 | ||
|
|
906b2a3485 | ||
|
|
e0d2b87ed3 | ||
|
|
83a8c8b42b | ||
|
|
d840ed6c5a | ||
|
|
0112087be4 | ||
|
|
7320084e11 | ||
|
|
23929f5eaa | ||
|
|
c002d4619a | ||
|
|
f60a909bba | ||
|
|
c2c22e3968 | ||
|
|
f10299b2de | ||
|
|
1d3563ed97 | ||
|
|
f3eb2caa4e | ||
|
|
2364dacd52 | ||
|
|
883f7451c3 | ||
|
|
a534c9bca1 | ||
|
|
b14202a324 | ||
|
|
a6fae48f07 | ||
|
|
963caf2afe | ||
|
|
50b0268531 | ||
|
|
f484b64be3 | ||
|
|
349535557f | ||
|
|
de4973a270 | ||
|
|
e42d2baf8a | ||
|
|
eac435b233 | ||
|
|
447b8564e9 | ||
|
|
97cee657bd | ||
|
|
fe894754cf | ||
|
|
9ffb1d1931 | ||
|
|
a16bd30903 | ||
|
|
13f9ea8be4 | ||
|
|
304af5e980 | ||
|
|
dc180c09e9 | ||
|
|
8e20e26565 | ||
|
|
11075a4012 | ||
|
|
a9300faaf8 | ||
|
|
504827b7e5 | ||
|
|
e180130b38 | ||
|
|
faaee09827 | ||
|
|
99334795b6 | ||
|
|
8c9c59ef64 | ||
|
|
7a112000c9 | ||
|
|
1424087d5a | ||
|
|
984f4731cd | ||
|
|
3a3de64b0f | ||
|
|
0911854e9d | ||
|
|
2af8b6f445 | ||
|
|
bbfd8ca3f5 | ||
|
|
b4ed2880f7 | ||
|
|
5f18a21e86 | ||
|
|
5d188e3877 | ||
|
|
90f113a292 | ||
|
|
eecfe58297 | ||
|
|
079a747210 | ||
|
|
4be8c70f23 | ||
|
|
d9aee4df77 | ||
|
|
225de87d4d | ||
|
|
2ce7cedfbd | ||
|
|
cfb163d904 | ||
|
|
de7c9be11b | ||
|
|
841209adc9 | ||
|
|
e48d51fe6e | ||
|
|
9d436ec7ed | ||
|
|
fb2b29d088 | ||
|
|
1c46b0bc20 | ||
|
|
81d0e4696a | ||
|
|
f9a287b52b | ||
|
|
0f0072abea | ||
|
|
312933a259 | ||
|
|
288854b8f1 | ||
|
|
7f5991aa34 | ||
|
|
361df95d50 | ||
|
|
fc1ade32d7 | ||
|
|
b74c7531d9 | ||
|
|
7e3be3325a | ||
|
|
7dab7fbe66 | ||
|
|
62c06b6593 | ||
|
|
000b62969f | ||
|
|
b4473bb4a7 | ||
|
|
2c0e06d599 | ||
|
|
d2c55e8ed3 | ||
|
|
714abaa25a | ||
|
|
0017eb987b | ||
|
|
e5a0894692 | ||
|
|
a8e00e9f0f | ||
|
|
77a4c271ae | ||
|
|
014b77c3c7 | ||
|
|
076e241056 | ||
|
|
7ce57cc67a | ||
|
|
da0343283a | ||
|
|
d5f7f1ba91 | ||
|
|
8761c82afe | ||
|
|
13023141bc | ||
|
|
4dd2038625 | ||
|
|
06a32b0e9d | ||
|
|
c91ab7a76b | ||
|
|
0344aa6a49 | ||
|
|
a748c9d750 | ||
|
|
038dc372b7 | ||
|
|
bc8198fb8a | ||
|
|
f42275bd83 | ||
|
|
6bd86a724e | ||
|
|
fc96cfe8a0 | ||
|
|
a9f25fe7d6 | ||
|
|
f740fed5f2 | ||
|
|
a6d1bd12a2 | ||
|
|
e8ab20acf2 | ||
|
|
ccfe193800 | ||
|
|
bdccedca59 | ||
|
|
9abb1488df | ||
|
|
195fc1bdc3 | ||
|
|
2a9129f470 | ||
|
|
acbfc0cc6e | ||
|
|
bfb0c75e95 | ||
|
|
161a2ddae8 | ||
|
|
99621cfd66 | ||
|
|
e6e7234215 | ||
|
|
5b7b329279 | ||
|
|
3abb2c8674 | ||
|
|
39de89254f | ||
|
|
ac941968cb | ||
|
|
96f603bfd1 | ||
|
|
677e38c62d | ||
|
|
72fce20905 | ||
|
|
1eb41c20d5 | ||
|
|
dd0c1d331f | ||
|
|
12760a70a1 | ||
|
|
525d17270f | ||
|
|
bc9959f5ab | ||
|
|
94a8cd5128 | ||
|
|
5a1b2c4938 | ||
|
|
851a2ac03a | ||
|
|
34d7707f53 | ||
|
|
0aac7f62a3 | ||
|
|
34379b92d0 | ||
|
|
250999f9f5 | ||
|
|
2b3832222b | ||
|
|
c5f6d0e721 | ||
|
|
dbb0cf15b8 | ||
|
|
ab202ba951 | ||
|
|
e2c13aa7ed | ||
|
|
c1ab19f3cf | ||
|
|
beebfb2e19 | ||
|
|
cfca90aa7d | ||
|
|
19fe0a32c8 | ||
|
|
76659f8837 | ||
|
|
2254715190 | ||
|
|
ae1a5460d4 | ||
|
|
27d9f910ff | ||
|
|
28db4881d7 | ||
|
|
7c76c3ccd6 | ||
|
|
007bd24374 | ||
|
|
c8dc30287c | ||
|
|
360184bbd1 | ||
|
|
e8ed2454a1 | ||
|
|
923ecf29b8 | ||
|
|
a8f8bf5872 | ||
|
|
bedcd94020 | ||
|
|
959d4da1f8 | ||
|
|
861453c1a8 | ||
|
|
2f4072da0d | ||
|
|
411b5e0ca6 | ||
|
|
3f03963811 | ||
|
|
d43f81e118 | ||
|
|
b97dbd2515 | ||
|
|
c6a20a9ed3 | ||
|
|
27f0f29eef | ||
|
|
223508ae72 | ||
|
|
bce0a4b8cd | ||
|
|
65412a4263 | ||
|
|
0233b78c8e | ||
|
|
b0b25e4cfa | ||
|
|
806288d587 | ||
|
|
97265fc43b | ||
|
|
41ca50d0d4 | ||
|
|
9d02206fd9 | ||
|
|
ba2293eb30 | ||
|
|
8b9e28975d | ||
|
|
22ae8b8f87 | ||
|
|
187e352cbd | ||
|
|
23ef8ad28d | ||
|
|
1dadf56c42 | ||
|
|
52640b80c0 | ||
|
|
fe25f8f48f | ||
|
|
7f59572d8b | ||
|
|
90fc4c6bad | ||
|
|
16b6c0da33 | ||
|
|
488a691f29 | ||
|
|
bcbfe2ccd5 | ||
|
|
bd9a1d7ec7 | ||
|
|
9331ba64d6 | ||
|
|
21e5cb0a03 | ||
|
|
1a8e0c9ecb | ||
|
|
16fc0d31cd | ||
|
|
a622ada58b | ||
|
|
ee9c4948d3 | ||
|
|
cf28e1d963 | ||
|
|
089ec36160 | ||
|
|
04ce774c22 | ||
|
|
99c1422f37 | ||
|
|
b583a60f23 | ||
|
|
7be2910809 | ||
|
|
30de524319 | ||
|
|
c431d5e759 | ||
|
|
184b62b024 | ||
|
|
2751770350 | ||
|
|
75d98aee8e | ||
|
|
48120b9406 | ||
|
|
0e302d7959 | ||
|
|
59cd176f44 | ||
|
|
619f728f09 | ||
|
|
6e8002acc4 | ||
|
|
8a4a6174f7 | ||
|
|
ee6c4823d3 | ||
|
|
14dcb73d06 | ||
|
|
e15107e5ec | ||
|
|
0167a9462e | ||
|
|
7fa1d342ab | ||
|
|
05b9988e1d | ||
|
|
1c09e61219 | ||
|
|
35f0ad7a83 | ||
|
|
7ae1d6763a | ||
|
|
460e859795 | ||
|
|
4b88ec6460 | ||
|
|
27ee13bb7e | ||
|
|
e6cdd337c3 | ||
|
|
7d8dd12131 | ||
|
|
0800e3a136 | ||
|
|
9b0f1a2a04 | ||
|
|
9de3cb0f92 | ||
|
|
c053a8291c | ||
|
|
a0ddfe173b | ||
|
|
17843a7c71 | ||
|
|
324ae5c883 | ||
|
|
ef03989c3f | ||
|
|
63412ddd42 | ||
|
|
30ce32608a | ||
|
|
74799ad096 | ||
|
|
31176f99c8 | ||
|
|
b9439c05ec | ||
|
|
435a04da0c | ||
|
|
0040b266a5 | ||
|
|
645de137f2 | ||
|
|
1883607118 | ||
|
|
4ccae1dac7 | ||
|
|
ff75db310f | ||
|
|
5788520401 | ||
|
|
570dddc120 | ||
|
|
ea31072ae5 | ||
|
|
5eca5a6011 | ||
|
|
67d5357227 | ||
|
|
a0d04ff488 | ||
|
|
f83787508f | ||
|
|
20aba7eb17 | ||
|
|
0cdea3318c | ||
|
|
4dc2c18075 | ||
|
|
74e97abac4 | ||
|
|
b1db95a925 | ||
|
|
9dac9850b6 | ||
|
|
abe091254a | ||
|
|
d2e5367dc6 | ||
|
|
8ccd1f5fe4 | ||
|
|
50bc865dd2 | ||
|
|
74a6ee7066 | ||
|
|
89e76bcb48 | ||
|
|
c55f6baf67 | ||
|
|
ae154489e1 | ||
|
|
fdc79033ce | ||
|
|
9a8aa5e632 | ||
|
|
6b81f3ce5f | ||
|
|
aeaddfe36b | ||
|
|
20c1f30877 | ||
|
|
52ce6ff38e | ||
|
|
c692a3c80e | ||
|
|
491009636a | ||
|
|
ed16ee14ea | ||
|
|
7f2ed09267 | ||
|
|
c0976897ef | ||
|
|
85b55aa924 | ||
|
|
91d0f76783 | ||
|
|
741badf9e6 | ||
|
|
ca1f3ac377 | ||
|
|
e13e1c9ca3 | ||
|
|
06ad042443 | ||
|
|
9d333b855c | ||
|
|
f46e2acd56 | ||
|
|
5ac4d3f4ae | ||
|
|
1614eebc47 | ||
|
|
b50599b71f | ||
|
|
0459025bf8 | ||
|
|
0bd37da8c7 | ||
|
|
da969dde53 | ||
|
|
33fdd6cafa | ||
|
|
2fe68766eb | ||
|
|
205348697c | ||
|
|
9b3533c1da | ||
|
|
c3584e838e | ||
|
|
16d8b3fb58 | ||
|
|
686bbdc16b | ||
|
|
65b17e4f2b | ||
|
|
23c6898789 | ||
|
|
df2a1be2a2 | ||
|
|
2db628a2ba | ||
|
|
b6c40436c9 | ||
|
|
a8a70cac08 | ||
|
|
3eefbf97b1 | ||
|
|
3c423e0838 | ||
|
|
99cde43954 | ||
|
|
fa3a787bf7 | ||
|
|
c776dc8036 | ||
|
|
1ef068351d | ||
|
|
6abe0a1862 | ||
|
|
ff13045f52 | ||
|
|
59c09681cb | ||
|
|
f664cf6fa5 | ||
|
|
01a847a9c2 | ||
|
|
6da655f67f | ||
|
|
21df7dced1 | ||
|
|
7fc257ea79 | ||
|
|
24f170ff72 | ||
|
|
39999c9ee4 | ||
|
|
27a5188e4e | ||
|
|
a5af0786aa | ||
|
|
e9c9cfaa72 | ||
|
|
8ca4ea0f3f | ||
|
|
86e1f9a9d6 | ||
|
|
b36ceda585 | ||
|
|
27a3e6c6db | ||
|
|
a731327c00 | ||
|
|
737c00978e | ||
|
|
18bcb3a067 | ||
|
|
f49f55576f | ||
|
|
1bef4f9a4d | ||
|
|
ab1df59f7a | ||
|
|
bcd235521e | ||
|
|
31a2eac302 | ||
|
|
7e6b7e5dd5 | ||
|
|
9ec9f48425 | ||
|
|
a3bec43eab | ||
|
|
f429b6397e | ||
|
|
9d6e7dc288 | ||
|
|
a27c09c1e8 | ||
|
|
ceb0697c73 | ||
|
|
6ad6a08bf1 | ||
|
|
fac6ad7116 | ||
|
|
7d8cda0457 | ||
|
|
33fc3fd63b | ||
|
|
8d39cc87f7 | ||
|
|
d0b1348c96 | ||
|
|
0afc38f6b8 | ||
|
|
264896ba17 | ||
|
|
08decf0b82 | ||
|
|
98381265e6 | ||
|
|
d323159719 | ||
|
|
7ef21e1d1c | ||
|
|
2d6b2ab7d7 | ||
|
|
a1e6fd88a9 | ||
|
|
e72ff867fc | ||
|
|
8512641984 | ||
|
|
f1aa64d191 | ||
|
|
347262538f | ||
|
|
82510d60ca | ||
|
|
6104cd04c3 | ||
|
|
44eb58426a | ||
|
|
078b60cc1e | ||
|
|
21e120a4f8 | ||
|
|
439b834aa8 | ||
|
|
ddbe8324be | ||
|
|
8ffe93113b | ||
|
|
8b31b7cb8a | ||
|
|
e09e21caa9 | ||
|
|
20b145c679 | ||
|
|
c5730cf1ad | ||
|
|
f16b038463 | ||
|
|
c08beec232 | ||
|
|
946361e0ae | ||
|
|
97cf65a231 | ||
|
|
d7eb6ac15d | ||
|
|
075afdbb77 | ||
|
|
2ac047504a | ||
|
|
c44aa50ef5 | ||
|
|
7ffafb49c4 | ||
|
|
9b7d57a853 | ||
|
|
ac19b3b512 | ||
|
|
b030317186 | ||
|
|
b506059874 | ||
|
|
cf7ba6e17f | ||
|
|
b7ce5663a3 | ||
|
|
58fa8064ad | ||
|
|
ed48f56526 | ||
|
|
896eb13f7d | ||
|
|
b8cd1c46c1 | ||
|
|
c5e84273c0 | ||
|
|
f21653ffb7 | ||
|
|
65c8116cc9 | ||
|
|
5e442433e5 | ||
|
|
7041347e76 | ||
|
|
810c205709 | ||
|
|
ec7035990a | ||
|
|
da6d9bb2bd | ||
|
|
e009043c63 | ||
|
|
79020e9338 | ||
|
|
2020244cae | ||
|
|
43fe8f25f8 | ||
|
|
9522888a60 | ||
|
|
70c183ae2b | ||
|
|
5d56eb9bef | ||
|
|
a461414a04 | ||
|
|
5737c3dca6 | ||
|
|
57ea50e59c | ||
|
|
7f630e8460 | ||
|
|
108e8502e1 | ||
|
|
4aa986d122 | ||
|
|
60239bbfc4 | ||
|
|
93ef3b1f1a | ||
|
|
d9ed135be4 | ||
|
|
e83fe0aabe | ||
|
|
4be7426ae7 | ||
|
|
0ce5ef7f56 | ||
|
|
c2c0946423 | ||
|
|
63049f61f7 | ||
|
|
1918b0f192 | ||
|
|
a3ad49b1fa | ||
|
|
bed63d1e2b | ||
|
|
4a8e739686 | ||
|
|
d502f33041 | ||
|
|
4a0ecf36c7 | ||
|
|
afb9e49755 | ||
|
|
18f65e5597 | ||
|
|
22b69f7dac | ||
|
|
15df062825 | ||
|
|
ed607d3895 | ||
|
|
f9b0db623d | ||
|
|
740cf12c11 | ||
|
|
4c4bf698b1 | ||
|
|
dc74e749c9 | ||
|
|
fa52c542d7 | ||
|
|
850d480c7c | ||
|
|
a92cc9dce9 | ||
|
|
4944a0a456 | ||
|
|
13c40058a8 | ||
|
|
1410c03c26 | ||
|
|
2f38b3040d | ||
|
|
79411a7350 | ||
|
|
ee94c2af32 | ||
|
|
d46e5c8d86 | ||
|
|
95cd10bfba | ||
|
|
59ed08b92d | ||
|
|
2b9f7bca51 | ||
|
|
a860a8c02b | ||
|
|
f2cbb8d2f7 | ||
|
|
ea61599589 | ||
|
|
0b59c95f63 | ||
|
|
66d4308810 | ||
|
|
f2648df2ad | ||
|
|
d20f68e897 | ||
|
|
338021645d | ||
|
|
a0a11842cb | ||
|
|
f5832d6a25 | ||
|
|
8fa6d9de39 | ||
|
|
e662338d6f | ||
|
|
2c1d6817dd | ||
|
|
5d4a3fec1f | ||
|
|
6603a30e7e | ||
|
|
81d08ca517 | ||
|
|
e04506a614 | ||
|
|
39756512ae | ||
|
|
71c29ea5e7 | ||
|
|
87ce266b14 | ||
|
|
ed6d856c24 | ||
|
|
d3ecbef946 | ||
|
|
7b24f5eb21 | ||
|
|
e1f82e338a | ||
|
|
a835d34a01 | ||
|
|
79d70c9977 | ||
|
|
aea82723cb | ||
|
|
d47ff0b31a | ||
|
|
affcb9d5c3 | ||
|
|
9be2686733 | ||
|
|
7126fed2b5 | ||
|
|
5bc4330e1c | ||
|
|
b25ac7116e | ||
|
|
8896867bb3 | ||
|
|
ba7c9eec7b | ||
|
|
9b95fde8d1 | ||
|
|
2851f16395 | ||
|
|
0d63dfb931 | ||
|
|
37558e3135 | ||
|
|
96021e42a2 | ||
|
|
c32b845515 | ||
|
|
147d980c54 | ||
|
|
f91c43dde9 | ||
|
|
4cf5cb06a0 | ||
|
|
8e4b4c3144 | ||
|
|
c302013696 | ||
|
|
37cb94c59d | ||
|
|
01f7c6bc2b | ||
|
|
8bd6ccb0de | ||
|
|
ed8895dfbb | ||
|
|
a55632051b | ||
|
|
7e347a458d | ||
|
|
cce71f23e2 | ||
|
|
d68461a127 | ||
|
|
1bd12a9411 | ||
|
|
4086ba4763 | ||
|
|
6a9cdf71d7 | ||
|
|
a9644c4f86 | ||
|
|
cf62ad5e8e | ||
|
|
f8ed16666c | ||
|
|
37926b4c19 | ||
|
|
b080a2003f | ||
|
|
ab0008be86 | ||
|
|
4a42b0d000 | ||
|
|
e3d4b19dac | ||
|
|
403d600db4 | ||
|
|
835e6e8891 | ||
|
|
eec25113b5 | ||
|
|
a7c4161f91 | ||
|
|
799eb9e6ef | ||
|
|
88993cb67b | ||
|
|
0dc9c98c06 | ||
|
|
c1c91cec44 | ||
|
|
19b6927320 | ||
|
|
0889ebc8b8 | ||
|
|
fb249c0ea5 | ||
|
|
feb22ff0a7 | ||
|
|
3c95156ce1 | ||
|
|
8b6dca6a46 | ||
|
|
43907eea26 | ||
|
|
67145a80d0 | ||
|
|
0b3138fec6 | ||
|
|
b84896b4f9 | ||
|
|
efd046d2f8 | ||
|
|
06fcf817bb | ||
|
|
16a94d9054 | ||
|
|
5bf502188d | ||
|
|
5269b4bc82 | ||
|
|
e3f8ed9886 | ||
|
|
74de554fb0 | ||
|
|
b41de1a982 | ||
|
|
25f7d9ccdd | ||
|
|
9646745181 | ||
|
|
1317d9c4f0 | ||
|
|
351029a842 | ||
|
|
15e1fb61ac | ||
|
|
1889a829b5 | ||
|
|
53a14fce38 | ||
|
|
d9ed7b09c7 | ||
|
|
4dcb18f00e | ||
|
|
0a52fe0a7a | ||
|
|
e5a4d11cf9 | ||
|
|
6c233f13de | ||
|
|
00aee3496c | ||
|
|
77ae40e3d6 | ||
|
|
68cba44476 | ||
|
|
b86d06f632 | ||
|
|
0b7cf305a0 | ||
|
|
21ae36bc3a | ||
|
|
4e2d9e9165 | ||
|
|
6cee308894 | ||
|
|
b8f4cd5fea | ||
|
|
aa1557ad9e | ||
|
|
f03da6daca | ||
|
|
30eb4385d4 | ||
|
|
4c9afcc1a8 | ||
|
|
dd47432a45 | ||
|
|
0ba6974bd6 | ||
|
|
827d8f6d84 | ||
|
|
943a462c69 | ||
|
|
a1bc773fb5 | ||
|
|
ac169b7d22 | ||
|
|
eecbbfea3a | ||
|
|
635ddb044e | ||
|
|
1a6123489d | ||
|
|
4e69195a8d | ||
|
|
e48c8ee652 | ||
|
|
7df07b86b9 | ||
|
|
5e2ad34864 | ||
|
|
e9a147d43c | ||
|
|
a340ee045e | ||
|
|
12405f3c34 | ||
|
|
1e465ee231 | ||
|
|
f06c24c23e | ||
|
|
4b93ee4843 | ||
|
|
c022e05ab9 | ||
|
|
c2a0d9d657 | ||
|
|
6fcf2c2f1f | ||
|
|
bc37daef58 | ||
|
|
fab5995c4e | ||
|
|
0ba8aa75f5 | ||
|
|
e24b3ed07a | ||
|
|
f9bddcb406 | ||
|
|
247b3b24a1 | ||
|
|
759c18acda | ||
|
|
b2462c5950 | ||
|
|
3d947f712c | ||
|
|
89d917e487 | ||
|
|
28b0a20b26 | ||
|
|
6d4396f4ba | ||
|
|
75dd0f27cf | ||
|
|
cb9be86c10 | ||
|
|
0b8f021505 | ||
|
|
f2d3b1c13f | ||
|
|
6f24c6ba49 | ||
|
|
c5a9df88dc | ||
|
|
20b2df364a | ||
|
|
e89103b96f | ||
|
|
49f1c9c10b | ||
|
|
b320c84c4c | ||
|
|
e916b84ee5 | ||
|
|
18633a3b41 | ||
|
|
0683498497 | ||
|
|
7468fa4f1e | ||
|
|
ab2b33a9fd | ||
|
|
8bedac023b | ||
|
|
7893b41175 | ||
|
|
ab73dbb3cd | ||
|
|
cb042dbe68 | ||
|
|
bba0d363d7 | ||
|
|
8635d8c53f | ||
|
|
dae6894e8b | ||
|
|
b76991a027 | ||
|
|
de61c43db4 | ||
|
|
890afc2a72 | ||
|
|
8d4e1f3af6 | ||
|
|
85507a4fff | ||
|
|
6d395f9866 | ||
|
|
c589f42181 | ||
|
|
87bb121060 | ||
|
|
42cd35ab3c | ||
|
|
669da0d882 | ||
|
|
9ac1346f80 | ||
|
|
f6981734d0 | ||
|
|
cb6aa61b6b | ||
|
|
2ed9cfcc9a | ||
|
|
2e796f41cb | ||
|
|
7d13e43c6f | ||
|
|
db684de6e9 | ||
|
|
510ef59aa0 | ||
|
|
d56083a29e | ||
|
|
8aed2b334e | ||
|
|
3bf27f224c | ||
|
|
dc9a54e74f | ||
|
|
79dc194dd6 | ||
|
|
8e12249201 | ||
|
|
4fa8f5b248 | ||
|
|
3089c0c524 | ||
|
|
ba1ca0819e | ||
|
|
4666b9051d | ||
|
|
56c524a822 | ||
|
|
43e8df1b9f | ||
|
|
dbc465f6e5 | ||
|
|
bfbd3c527c | ||
|
|
412405f69b | ||
|
|
12b74eb04f | ||
|
|
2305a6287a | ||
|
|
68245be081 | ||
|
|
29e01294bd | ||
|
|
d35bee54a6 | ||
|
|
bf63be18e4 | ||
|
|
3dc7adc61a | ||
|
|
047d1e0afd | ||
|
|
7c017faf31 | ||
|
|
7a59565761 | ||
|
|
9afb904d40 | ||
|
|
8189de589a | ||
|
|
c458d7525d | ||
|
|
5c7bd95f6b | ||
|
|
70c4509682 | ||
|
|
f34e36c571 | ||
|
|
5054ffe7e4 | ||
|
|
ed30933ca2 | ||
|
|
2a4111ecce | ||
|
|
5bc8709605 | ||
|
|
efa2edf869 | ||
|
|
5c1e972feb | ||
|
|
8c23e7a7b7 | ||
|
|
57183f8cdc | ||
|
|
0481b49c04 | ||
|
|
7eb9b5e92d | ||
|
|
2a409d83d4 | ||
|
|
785a3f5de8 | ||
|
|
7c17c1c73b | ||
|
|
0ea429782c | ||
|
|
7a8f880dbe | ||
|
|
0a86b72110 | ||
|
|
cb5c06ee7e | ||
|
|
9f22ce5cc0 | ||
|
|
86e1fbc28a | ||
|
|
a5c5f7c718 | ||
|
|
ff5d94782f | ||
|
|
58a1bd2c86 | ||
|
|
f78ba6afb0 | ||
|
|
331f3455f8 | ||
|
|
ad0241b7f1 | ||
|
|
d9508533e1 | ||
|
|
6d2059447e | ||
|
|
11d4f27268 | ||
|
|
a29f987649 | ||
|
|
3e692c790e | ||
|
|
35cc214492 | ||
|
|
bae7bff70d | ||
|
|
71ef6f6a61 | ||
|
|
a8e161661c | ||
|
|
2b07766f9a | ||
|
|
adeb5361ab | ||
|
|
bd6e43c41d | ||
|
|
450289c7b7 | ||
|
|
aa93c560e5 | ||
|
|
22b1ebe1cf | ||
|
|
84bcf15e9b | ||
|
|
5b66803f6d | ||
|
|
88cbde47da | ||
|
|
03b96fa88b | ||
|
|
397a8a9536 | ||
|
|
1da0a706a3 | ||
|
|
4f2a110b5f | ||
|
|
bb356ffcee | ||
|
|
6c986416ca | ||
|
|
951ec138ef | ||
|
|
23e779ed94 | ||
|
|
29fccd3887 | ||
|
|
1bef723332 | ||
|
|
3c41fed0ef | ||
|
|
5947d0e6d0 | ||
|
|
0e4fa86372 |
@@ -1,3 +1,84 @@
|
||||
# Ignore git
|
||||
# Git
|
||||
.github
|
||||
.git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
README.md
|
||||
LICENSE
|
||||
|
||||
# Development files
|
||||
.pylintrc
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.hypothesis/
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Test files
|
||||
tests/
|
||||
test_*
|
||||
*_test.py
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
|
||||
# Other
|
||||
app.ico
|
||||
frozen.spec
|
||||
2
.github/ISSUE_TEMPLATE/rfc.yml
vendored
2
.github/ISSUE_TEMPLATE/rfc.yml
vendored
@@ -10,7 +10,7 @@ body:
|
||||
目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论;
|
||||
以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突),
|
||||
因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。
|
||||
|
||||
|
||||
如果仅希望讨论是否添加或改进某功能本身,请使用 -> [Issue: 功能改进](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.yml&title=%5BFeature+Request%5D%3A+)
|
||||
- type: textarea
|
||||
id: background
|
||||
|
||||
60
.github/workflows/beta.yml
vendored
Normal file
60
.github/workflows/beta.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: MoviePilot Builder Beta
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Docker Image
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Release version
|
||||
id: release_version
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/moviepilot-v2
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=beta
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set Up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
||||
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
||||
31
.github/workflows/build.yml
vendored
31
.github/workflows/build.yml
vendored
@@ -25,7 +25,10 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USERNAME }}/moviepilot-v2
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/moviepilot-v2
|
||||
${{ secrets.DOCKER_USERNAME }}/moviepilot
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.app_version }}
|
||||
type=raw,value=latest
|
||||
@@ -42,11 +45,18 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
file: docker/Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64/v8
|
||||
@@ -56,10 +66,22 @@ jobs:
|
||||
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
||||
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
||||
|
||||
- name: Get existing release body
|
||||
id: get_release_body
|
||||
continue-on-error: true
|
||||
run: |
|
||||
release_body=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ env.app_version }}" | \
|
||||
jq -r '.body // ""')
|
||||
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$release_body" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Delete Release
|
||||
uses: dev-drprasad/delete-tag-and-release@v1.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
tag_name: ${{ env.app_version }}
|
||||
tag_name: v${{ env.app_version }}
|
||||
delete_release: true
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -68,8 +90,9 @@ jobs:
|
||||
with:
|
||||
tag_name: v${{ env.app_version }}
|
||||
name: v${{ env.app_version }}
|
||||
body: ${{ env.RELEASE_BODY }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
make_latest: false
|
||||
make_latest: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
32
.github/workflows/issues.yml
vendored
Normal file
32
.github/workflows/issues.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
schedule:
|
||||
# Github Action 只支持 UTC 时间。
|
||||
# '0 18 * * *' 对应 UTC 时间的 18:00,也就是中国时区 (UTC+8) 的第二天凌晨 02:00。
|
||||
- cron: "0 18 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
# 标记 stale 标签时间
|
||||
days-before-issue-stale: 30
|
||||
# 关闭 issues 标签时间
|
||||
days-before-issue-close: 14
|
||||
# 自定义标签名
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "此问题已过时,因为它已打开 30 天且没有任何活动。"
|
||||
close-issue-message: "此问题已关闭,因为它在标记为 stale 后,已处于无更新状态 14 天。"
|
||||
# 忽略所有的 Pull Request,只处理 Issue
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
# 排除带有RFC标签的issue
|
||||
exempt-issue-labels: "RFC"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
91
.github/workflows/pylint.yml
vendored
Normal file
91
.github/workflows/pylint.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: Pylint Code Quality Check
|
||||
|
||||
on:
|
||||
# 允许手动触发
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
pylint:
|
||||
runs-on: ubuntu-latest
|
||||
name: Pylint Code Quality Check
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/requirements.in') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
pip install pylint
|
||||
# 安装项目依赖
|
||||
if [ -f requirements.txt ]; then
|
||||
echo "📦 安装 requirements.txt 中的依赖..."
|
||||
pip install -r requirements.txt
|
||||
elif [ -f requirements.in ]; then
|
||||
echo "📦 安装 requirements.in 中的依赖..."
|
||||
pip install -r requirements.in
|
||||
else
|
||||
echo "⚠️ 未找到依赖文件,仅安装 pylint"
|
||||
fi
|
||||
|
||||
- name: Verify pylint config
|
||||
run: |
|
||||
# 检查项目中的pylint配置文件是否存在
|
||||
if [ -f .pylintrc ]; then
|
||||
echo "✅ 找到项目配置文件: .pylintrc"
|
||||
echo "配置文件内容预览:"
|
||||
head -10 .pylintrc
|
||||
else
|
||||
echo "❌ 未找到 .pylintrc 配置文件"
|
||||
exit 1
|
||||
fi
|
||||
- name: Run pylint
|
||||
run: |
|
||||
# 运行pylint,检查主要的Python文件
|
||||
echo "🚀 运行 Pylint 错误检查..."
|
||||
|
||||
# 检查主要目录 - 只关注错误,如果有错误则退出
|
||||
echo "📂 检查 app/ 目录..."
|
||||
pylint app/ --output-format=colorized --reports=yes --score=yes
|
||||
|
||||
# 检查根目录的Python文件
|
||||
echo "📂 检查根目录 Python 文件..."
|
||||
for file in $(find . -name "*.py" -not -path "./.*" -not -path "./.venv/*" -not -path "./build/*" -not -path "./dist/*" -not -path "./tests/*" -not -path "./docs/*" -not -path "./__pycache__/*" -maxdepth 1); do
|
||||
echo "检查文件: $file"
|
||||
pylint "$file" --output-format=colorized || exit 1
|
||||
done
|
||||
|
||||
# 生成详细报告
|
||||
echo "📊 生成 Pylint 详细报告..."
|
||||
pylint app/ --output-format=json > pylint-report.json || true
|
||||
|
||||
# 显示评分(仅供参考)
|
||||
echo "📈 Pylint 评分(仅供参考):"
|
||||
pylint app/ --score=yes --reports=no | tail -2 || true
|
||||
|
||||
- name: Upload pylint report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: pylint-report
|
||||
path: pylint-report.json
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "🎉 Pylint 检查完成!"
|
||||
echo "✅ 没有发现语法错误或严重问题"
|
||||
echo "📊 详细报告已保存为构建工件"
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
.idea/
|
||||
*.c
|
||||
*.so
|
||||
*.pyd
|
||||
build/
|
||||
cython_cache/
|
||||
dist/
|
||||
nginx/
|
||||
test.py
|
||||
@@ -20,4 +23,8 @@ config/cache/
|
||||
*.pyc
|
||||
*.log
|
||||
.vscode
|
||||
venv
|
||||
venv
|
||||
|
||||
# Pylint
|
||||
pylint-report.json
|
||||
.pylint.d/
|
||||
83
.pylintrc
Normal file
83
.pylintrc
Normal file
@@ -0,0 +1,83 @@
|
||||
[MASTER]
|
||||
# 指定Python路径
|
||||
init-hook='import sys; sys.path.append(".")'
|
||||
|
||||
# 忽略的文件和目录
|
||||
ignore=.git,__pycache__,.venv,build,dist,tests,docs
|
||||
|
||||
# 并行作业数量
|
||||
jobs=0
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
# 只关注错误级别的问题,禁用警告、约定和重构建议
|
||||
# E = Error (错误) - 会导致构建失败
|
||||
# W = Warning (警告) - 仅显示,不会失败
|
||||
# R = Refactor (重构建议) - 仅显示,不会失败
|
||||
# C = Convention (约定) - 仅显示,不会失败
|
||||
# I = Information (信息) - 仅显示,不会失败
|
||||
|
||||
# 禁用大部分警告、约定和重构建议,只保留错误和重要警告
|
||||
disable=all
|
||||
enable=error,
|
||||
syntax-error,
|
||||
undefined-variable,
|
||||
used-before-assignment,
|
||||
unreachable,
|
||||
return-outside-function,
|
||||
yield-outside-function,
|
||||
continue-in-finally,
|
||||
nonlocal-without-binding,
|
||||
undefined-loop-variable,
|
||||
redefined-builtin,
|
||||
not-callable,
|
||||
assignment-from-no-return,
|
||||
no-value-for-parameter,
|
||||
too-many-function-args,
|
||||
unexpected-keyword-arg,
|
||||
redundant-keyword-arg,
|
||||
import-error,
|
||||
relative-beyond-top-level
|
||||
|
||||
[REPORTS]
|
||||
# 设置报告格式
|
||||
output-format=colorized
|
||||
reports=yes
|
||||
score=yes
|
||||
|
||||
[FORMAT]
|
||||
# 最大行长度
|
||||
max-line-length=120
|
||||
# 缩进大小
|
||||
indent-string=' '
|
||||
|
||||
[DESIGN]
|
||||
# 最大参数数量
|
||||
max-args=10
|
||||
# 最大本地变量数量
|
||||
max-locals=20
|
||||
# 最大分支数量
|
||||
max-branches=15
|
||||
# 最大语句数量
|
||||
max-statements=50
|
||||
# 最大父类数量
|
||||
max-parents=7
|
||||
# 最大属性数量
|
||||
max-attributes=10
|
||||
# 最小公共方法数量
|
||||
min-public-methods=1
|
||||
# 最大公共方法数量
|
||||
max-public-methods=25
|
||||
|
||||
[SIMILARITIES]
|
||||
# 最小相似行数
|
||||
min-similarity-lines=6
|
||||
# 忽略注释
|
||||
ignore-comments=yes
|
||||
# 忽略文档字符串
|
||||
ignore-docstrings=yes
|
||||
# 忽略导入
|
||||
ignore-imports=yes
|
||||
|
||||
[TYPECHECK]
|
||||
# 生成缺失成员提示的类列表
|
||||
generated-members=requests.packages.urllib3
|
||||
91
Dockerfile
91
Dockerfile
@@ -1,91 +0,0 @@
|
||||
FROM python:3.11.4-slim-bookworm
|
||||
ENV LANG="C.UTF-8" \
|
||||
TZ="Asia/Shanghai" \
|
||||
HOME="/moviepilot" \
|
||||
CONFIG_DIR="/config" \
|
||||
TERM="xterm" \
|
||||
DISPLAY=:987 \
|
||||
PUID=0 \
|
||||
PGID=0 \
|
||||
UMASK=000 \
|
||||
PORT=3001 \
|
||||
NGINX_PORT=3000 \
|
||||
MOVIEPILOT_AUTO_UPDATE=release
|
||||
WORKDIR "/app"
|
||||
RUN apt-get update -y \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get -y install \
|
||||
musl-dev \
|
||||
nginx \
|
||||
gettext-base \
|
||||
locales \
|
||||
procps \
|
||||
gosu \
|
||||
bash \
|
||||
wget \
|
||||
curl \
|
||||
busybox \
|
||||
dumb-init \
|
||||
jq \
|
||||
fuse3 \
|
||||
rsync \
|
||||
ffmpeg \
|
||||
nano \
|
||||
&& \
|
||||
if [ "$(uname -m)" = "x86_64" ]; \
|
||||
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
|
||||
elif [ "$(uname -m)" = "aarch64" ]; \
|
||||
then ln -s /usr/lib/aarch64-linux-musl/libc.so /lib/libc.musl-aarch64.so.1; \
|
||||
fi \
|
||||
&& curl https://rclone.org/install.sh | bash \
|
||||
&& curl --insecure -fsSL https://raw.githubusercontent.com/DDS-Derek/Aria2-Pro-Core/master/aria2-install.sh | bash \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf \
|
||||
/tmp/* \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY requirements.in requirements.in
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y build-essential \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install Cython pip-tools \
|
||||
&& pip-compile requirements.in \
|
||||
&& pip install -r requirements.txt \
|
||||
&& playwright install-deps chromium \
|
||||
&& apt-get remove -y build-essential \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf \
|
||||
/tmp/* \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY . .
|
||||
RUN cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
||||
&& cp -f /app/update /usr/local/bin/mp_update \
|
||||
&& cp -f /app/entrypoint /entrypoint \
|
||||
&& cp -f /app/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf \
|
||||
&& chmod +x /entrypoint /usr/local/bin/mp_update \
|
||||
&& mkdir -p ${HOME} \
|
||||
&& groupadd -r moviepilot -g 918 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \
|
||||
&& python_ver=$(python3 -V | awk '{print $2}') \
|
||||
&& echo "/app/" > /usr/local/lib/python${python_ver%.*}/site-packages/app.pth \
|
||||
&& echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \
|
||||
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
|
||||
&& locale-gen zh_CN.UTF-8 \
|
||||
&& FRONTEND_VERSION=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" /app/version.py) \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
|
||||
&& mv /dist /public \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
||||
&& mv -f /tmp/MoviePilot-Plugins-main/plugins.v2/* /app/app/plugins/ \
|
||||
&& cat /tmp/MoviePilot-Plugins-main/package.json | jq -r 'to_entries[] | select(.value.v2 == true) | .key' | awk '{print tolower($0)}' | \
|
||||
while read -r i; do if [ ! -d "/app/app/plugins/$i" ]; then mv "/tmp/MoviePilot-Plugins-main/plugins/$i" "/app/app/plugins/"; else echo "跳过 $i"; fi; done \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
||||
&& mv -f /tmp/MoviePilot-Resources-main/resources/* /app/app/helper/ \
|
||||
&& rm -rf /tmp/*
|
||||
EXPOSE 3000
|
||||
VOLUME [ "/config" ]
|
||||
ENTRYPOINT [ "/entrypoint" ]
|
||||
48
README.md
48
README.md
@@ -18,13 +18,57 @@
|
||||
|
||||
## 主要特性
|
||||
|
||||
- 前后端分离,基于FastApi + Vue3,前端项目地址:[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend),API:http://localhost:3001/docs
|
||||
- 前后端分离,基于FastApi + Vue3。
|
||||
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
|
||||
- 重新设计了用户界面,更加美观易用。
|
||||
|
||||
## 安装使用
|
||||
|
||||
访问官方Wiki:https://wiki.movie-pilot.org
|
||||
官方Wiki:https://wiki.movie-pilot.org
|
||||
|
||||
## 参与开发
|
||||
|
||||
API文档:https://api.movie-pilot.org
|
||||
|
||||
本地运行需要 `Python 3.12`、`Node JS v20.12.1`
|
||||
|
||||
- 克隆主项目 [MoviePilot](https://github.com/jxxghp/MoviePilot)
|
||||
```shell
|
||||
git clone https://github.com/jxxghp/MoviePilot
|
||||
```
|
||||
- 克隆资源项目 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) ,将 `resources` 目录下对应平台及版本的库 `.so`/`.pyd`/`.bin` 文件复制到 `app/helper` 目录
|
||||
```shell
|
||||
git clone https://github.com/jxxghp/MoviePilot-Resources
|
||||
```
|
||||
- 安装后端依赖,设置`app`为源代码根目录,运行 `main.py` 启动后端服务,默认监听端口:`3001`,API文档地址:`http://localhost:3001/docs`
|
||||
```shell
|
||||
pip install -r requirements.txt
|
||||
python3 main.py
|
||||
```
|
||||
- 克隆前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)
|
||||
```shell
|
||||
git clone https://github.com/jxxghp/MoviePilot-Frontend
|
||||
```
|
||||
- 安装前端依赖,运行前端项目,访问:`http://localhost:5173`
|
||||
```shell
|
||||
yarn
|
||||
yarn dev
|
||||
```
|
||||
- 参考 [插件开发指引](https://wiki.movie-pilot.org/zh/plugindev) 在 `app/plugins` 目录下开发插件代码
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)
|
||||
- [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources)
|
||||
- [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)
|
||||
- [MoviePilot-Server](https://github.com/jxxghp/MoviePilot-Server)
|
||||
- [MoviePilot-Wiki](https://github.com/jxxghp/MoviePilot-Wiki)
|
||||
|
||||
## 免责申明
|
||||
|
||||
- 本软件仅供学习交流使用,任何人不得将本软件用于商业用途,任何人不得将本软件用于违法犯罪活动,软件对用户行为不知情,一切责任由使用者承担。
|
||||
- 本软件代码开源,基于开源代码进行修改,人为去除相关限制导致软件被分发、传播并造成责任事件的,需由代码修改发布者承担全部责任,不建议对用户认证机制进行规避或修改并公开发布。
|
||||
- 本项目不接受捐赠,没有在任何地方发布捐赠信息页面,软件本身不收费也不提供任何收费相关服务,请仔细辨别避免误导。
|
||||
|
||||
## 贡献者
|
||||
|
||||
|
||||
106
app/actions/__init__.py
Normal file
106
app/actions/__init__.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Union
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas import ActionContext, ActionParams
|
||||
|
||||
|
||||
class ActionChain(ChainBase):
|
||||
pass
|
||||
|
||||
|
||||
class BaseAction(ABC):
|
||||
"""
|
||||
工作流动作基类
|
||||
"""
|
||||
|
||||
# 动作ID
|
||||
_action_id = None
|
||||
# 完成标志
|
||||
_done_flag = False
|
||||
# 执行信息
|
||||
_message = ""
|
||||
# 缓存键值
|
||||
_cache_key = "WorkflowCache-%s"
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
self._action_id = action_id
|
||||
self.systemconfigoper = SystemConfigOper()
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(cls) -> str: # noqa
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(cls) -> str: # noqa
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
@abstractmethod
|
||||
def data(cls) -> dict: # noqa
|
||||
pass
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
"""
|
||||
判断动作是否完成
|
||||
"""
|
||||
return self._done_flag
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def success(self) -> bool:
|
||||
"""
|
||||
判断动作是否成功
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
"""
|
||||
执行信息
|
||||
"""
|
||||
return self._message
|
||||
|
||||
def job_done(self, message: str = None):
|
||||
"""
|
||||
标记动作完成
|
||||
"""
|
||||
self._message = message
|
||||
self._done_flag = True
|
||||
|
||||
def check_cache(self, workflow_id: int, key: str) -> bool:
|
||||
"""
|
||||
检查是否处理过
|
||||
"""
|
||||
workflow_key = self._cache_key % workflow_id
|
||||
workflow_cache = self.systemconfigoper.get(workflow_key) or {}
|
||||
action_cache = workflow_cache.get(self._action_id) or []
|
||||
return key in action_cache
|
||||
|
||||
def save_cache(self, workflow_id: int, data: Union[list, str]):
|
||||
"""
|
||||
保存缓存
|
||||
"""
|
||||
workflow_key = self._cache_key % workflow_id
|
||||
workflow_cache = self.systemconfigoper.get(workflow_key) or {}
|
||||
action_cache = workflow_cache.get(self._action_id) or []
|
||||
if isinstance(data, list):
|
||||
action_cache.extend(data)
|
||||
else:
|
||||
action_cache.append(data)
|
||||
workflow_cache[self._action_id] = action_cache
|
||||
self.systemconfigoper.set(workflow_key, workflow_cache)
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, workflow_id: int, params: ActionParams, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
执行动作
|
||||
"""
|
||||
raise NotImplementedError
|
||||
116
app/actions/add_download.py
Normal file
116
app/actions/add_download.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.config import global_vars
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.schemas import ActionParams, ActionContext, DownloadTask, MediaType
|
||||
|
||||
|
||||
class AddDownloadParams(ActionParams):
|
||||
"""
|
||||
添加下载资源参数
|
||||
"""
|
||||
downloader: Optional[str] = Field(default=None, description="下载器")
|
||||
save_path: Optional[str] = Field(default=None, description="保存路径")
|
||||
labels: Optional[str] = Field(default=None, description="标签(,分隔)")
|
||||
only_lack: Optional[bool] = Field(default=False, description="仅下载缺失的资源")
|
||||
|
||||
|
||||
class AddDownloadAction(BaseAction):
|
||||
"""
|
||||
添加下载资源
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._added_downloads = []
|
||||
self._has_error = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "添加下载"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "根据资源列表添加下载任务"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return AddDownloadParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return not self._has_error
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
将上下文中的torrents添加到下载任务中
|
||||
"""
|
||||
params = AddDownloadParams(**params)
|
||||
_started = False
|
||||
for t in context.torrents:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
# 检查缓存
|
||||
cache_key = f"{t.torrent_info.site}-{t.torrent_info.title}"
|
||||
if self.check_cache(workflow_id, cache_key):
|
||||
logger.info(f"{t.torrent_info.title} 已添加过下载,跳过")
|
||||
continue
|
||||
if not t.meta_info:
|
||||
t.meta_info = MetaInfo(title=t.torrent_info.title, subtitle=t.torrent_info.description)
|
||||
if not t.media_info:
|
||||
t.media_info = MediaChain().recognize_media(meta=t.meta_info)
|
||||
if not t.media_info:
|
||||
self._has_error = True
|
||||
logger.warning(f"{t.torrent_info.title} 未识别到媒体信息,无法下载")
|
||||
continue
|
||||
if params.only_lack:
|
||||
exists_info = DownloadChain().media_exists(t.media_info)
|
||||
if exists_info:
|
||||
if t.media_info.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
logger.warning(f"{t.torrent_info.title} 媒体库中已存在,跳过")
|
||||
continue
|
||||
else:
|
||||
# 电视剧
|
||||
exists_seasons = exists_info.seasons or {}
|
||||
if len(t.meta_info.season_list) > 1:
|
||||
# 多季不下载
|
||||
logger.warning(f"{t.meta_info.title} 有多季,跳过")
|
||||
continue
|
||||
else:
|
||||
exists_episodes = exists_seasons.get(t.meta_info.begin_season)
|
||||
if exists_episodes:
|
||||
if set(t.meta_info.episode_list).issubset(exists_episodes):
|
||||
logger.warning(
|
||||
f"{t.meta_info.title} 第 {t.meta_info.begin_season} 季第 {t.meta_info.episode_list} 集已存在,跳过")
|
||||
continue
|
||||
|
||||
_started = True
|
||||
did = DownloadChain().download_single(context=t,
|
||||
downloader=params.downloader,
|
||||
save_path=params.save_path,
|
||||
label=params.labels)
|
||||
if did:
|
||||
self._added_downloads.append(did)
|
||||
# 保存缓存
|
||||
self.save_cache(workflow_id, cache_key)
|
||||
|
||||
if self._added_downloads:
|
||||
logger.info(f"已添加 {len(self._added_downloads)} 个下载任务")
|
||||
context.downloads.extend(
|
||||
[DownloadTask(download_id=did, downloader=params.downloader) for did in self._added_downloads]
|
||||
)
|
||||
elif _started:
|
||||
self._has_error = True
|
||||
|
||||
self.job_done(f"已添加 {len(self._added_downloads)} 个下载任务")
|
||||
return context
|
||||
88
app/actions/add_subscribe.py
Normal file
88
app/actions/add_subscribe.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from app.actions import BaseAction
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.context import MediaInfo
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.log import logger
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
|
||||
|
||||
class AddSubscribeParams(ActionParams):
|
||||
"""
|
||||
添加订阅参数
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class AddSubscribeAction(BaseAction):
|
||||
"""
|
||||
添加订阅
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._added_subscribes = []
|
||||
self._has_error = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "添加订阅"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "根据媒体列表添加订阅"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return AddSubscribeParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return not self._has_error
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
将medias中的信息添加订阅,如果订阅不存在的话
|
||||
"""
|
||||
_started = False
|
||||
for media in context.medias:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
# 检查缓存
|
||||
cache_key = f"{media.type}-{media.title}-{media.year}-{media.season}"
|
||||
if self.check_cache(workflow_id, cache_key):
|
||||
logger.info(f"{media.title} {media.year} 已添加过订阅,跳过")
|
||||
continue
|
||||
mediainfo = MediaInfo()
|
||||
mediainfo.from_dict(media.dict())
|
||||
subscribechain = SubscribeChain()
|
||||
if subscribechain.exists(mediainfo):
|
||||
logger.info(f"{media.title} 已存在订阅")
|
||||
continue
|
||||
# 添加订阅
|
||||
_started = True
|
||||
sid, message = subscribechain.add(mtype=mediainfo.type,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=mediainfo.season,
|
||||
doubanid=mediainfo.douban_id,
|
||||
bangumiid=mediainfo.bangumi_id,
|
||||
username=settings.SUPERUSER)
|
||||
if sid:
|
||||
self._added_subscribes.append(sid)
|
||||
# 保存缓存
|
||||
self.save_cache(workflow_id, cache_key)
|
||||
|
||||
if self._added_subscribes:
|
||||
logger.info(f"已添加 {len(self._added_subscribes)} 个订阅")
|
||||
for sid in self._added_subscribes:
|
||||
context.subscribes.append(SubscribeOper().get(sid))
|
||||
elif _started:
|
||||
self._has_error = True
|
||||
|
||||
self.job_done(f"已添加 {len(self._added_subscribes)} 个订阅")
|
||||
return context
|
||||
65
app/actions/fetch_downloads.py
Normal file
65
app/actions/fetch_downloads.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from app.actions import BaseAction, ActionChain
|
||||
from app.core.config import global_vars
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class FetchDownloadsParams(ActionParams):
|
||||
"""
|
||||
获取下载任务参数
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class FetchDownloadsAction(BaseAction):
|
||||
"""
|
||||
获取下载任务
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._downloads = []
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "获取下载任务"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "获取下载队列中的任务状态"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return FetchDownloadsParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.done
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
更新downloads中的下载任务状态
|
||||
"""
|
||||
__all_complete = False
|
||||
for download in self._downloads:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
logger.info(f"获取下载任务 {download.download_id} 状态 ...")
|
||||
torrents = ActionChain().list_torrents(hashs=[download.download_id])
|
||||
if not torrents:
|
||||
download.completed = True
|
||||
continue
|
||||
for t in torrents:
|
||||
download.path = t.path
|
||||
if t.progress >= 100:
|
||||
logger.info(f"下载任务 {download.download_id} 已完成")
|
||||
download.completed = True
|
||||
else:
|
||||
logger.info(f"下载任务 {download.download_id} 未完成")
|
||||
download.completed = False
|
||||
if all([d.completed for d in self._downloads]):
|
||||
self.job_done()
|
||||
return context
|
||||
186
app/actions/fetch_medias.py
Normal file
186
app/actions/fetch_medias.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.event import eventmanager
|
||||
from app.log import logger
|
||||
from app.schemas import RecommendSourceEventData, MediaInfo
|
||||
from app.schemas.types import ChainEventType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class FetchMediasParams(ActionParams):
|
||||
"""
|
||||
获取媒体数据参数
|
||||
"""
|
||||
source_type: Optional[str] = Field(default="ranking", description="来源")
|
||||
sources: Optional[List[str]] = Field(default=[], description="榜单")
|
||||
api_path: Optional[str] = Field(default=None, description="API路径")
|
||||
|
||||
|
||||
class FetchMediasAction(BaseAction):
|
||||
"""
|
||||
获取媒体数据
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
|
||||
self._medias = []
|
||||
self._has_error = False
|
||||
self.__inner_sources = [
|
||||
{
|
||||
"func": RecommendChain().tmdb_trending,
|
||||
"name": '流行趋势',
|
||||
"api_path": "recommend/tmdb_trending"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movie_showing,
|
||||
"name": '正在热映',
|
||||
"api_path": "recommend/douban_showing"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().bangumi_calendar,
|
||||
"name": 'Bangumi每日放送',
|
||||
"api_path": "recommend/bangumi_calendar"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().tmdb_movies,
|
||||
"name": 'TMDB热门电影',
|
||||
"api_path": "recommend/tmdb_movies"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().tmdb_tvs,
|
||||
"name": 'TMDB热门电视剧',
|
||||
"api_path": "recommend/tmdb_tvs?with_original_language=zh|en|ja|ko"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movie_hot,
|
||||
"name": '豆瓣热门电影',
|
||||
"api_path": "recommend/douban_movie_hot"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_hot,
|
||||
"name": '豆瓣热门电视剧',
|
||||
"api_path": "recommend/douban_tv_hot"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_animation,
|
||||
"name": '豆瓣热门动漫',
|
||||
"api_path": "recommend/douban_tv_animation"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movies,
|
||||
"name": '豆瓣最新电影',
|
||||
"api_path": "recommend/douban_movies"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tvs,
|
||||
"name": '豆瓣最新电视剧',
|
||||
"api_path": "recommend/douban_tvs"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movie_top250,
|
||||
"name": '豆瓣电影TOP250',
|
||||
"api_path": "recommend/douban_movie_top250"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_weekly_chinese,
|
||||
"name": '豆瓣国产剧集榜',
|
||||
"api_path": "recommend/douban_tv_weekly_chinese"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_weekly_global,
|
||||
"name": '豆瓣全球剧集榜',
|
||||
"api_path": "recommend/douban_tv_weekly_global"
|
||||
}
|
||||
]
|
||||
|
||||
# 广播事件,请示额外的推荐数据源支持
|
||||
event_data = RecommendSourceEventData()
|
||||
event = eventmanager.send_event(ChainEventType.RecommendSource, event_data)
|
||||
# 使用事件返回的上下文数据
|
||||
if event and event.event_data:
|
||||
event_data: RecommendSourceEventData = event.event_data
|
||||
if event_data.extra_sources:
|
||||
self.__inner_sources.extend([s.dict() for s in event_data.extra_sources])
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "获取媒体数据"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "获取榜单等媒体数据列表"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return FetchMediasParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return not self._has_error
|
||||
|
||||
def __get_source(self, source: str):
|
||||
"""
|
||||
获取数据源
|
||||
"""
|
||||
for s in self.__inner_sources:
|
||||
if s['api_path'] == source:
|
||||
return s
|
||||
return None
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
获取媒体数据,填充到medias
|
||||
"""
|
||||
params = FetchMediasParams(**params)
|
||||
try:
|
||||
if params.source_type == "ranking":
|
||||
for api_path in params.sources:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
source = self.__get_source(api_path)
|
||||
if not source:
|
||||
continue
|
||||
logger.info(f"获取媒体数据 {source} ...")
|
||||
name = source.get("name")
|
||||
results = []
|
||||
if source.get("func"):
|
||||
results = source['func']()
|
||||
else:
|
||||
# 调用内部API获取数据
|
||||
api_url = f"http://127.0.0.1:{settings.PORT}/api/v1/{source['api_path']}?token={settings.API_TOKEN}"
|
||||
res = RequestUtils(timeout=15).post_res(api_url)
|
||||
if res:
|
||||
results = res.json()
|
||||
if results:
|
||||
logger.info(f"{name} 获取到 {len(results)} 条数据")
|
||||
self._medias.extend([MediaInfo(**r) for r in results])
|
||||
else:
|
||||
logger.error(f"{name} 获取数据失败")
|
||||
else:
|
||||
# 调用内部API获取数据
|
||||
api_url = f"http://127.0.0.1:{settings.PORT}{params.api_path}?token={settings.API_TOKEN}"
|
||||
res = RequestUtils(timeout=15).post_res(api_url)
|
||||
if res:
|
||||
results = res.json()
|
||||
if results:
|
||||
logger.info(f"{params.api_path} 获取到 {len(results)} 条数据")
|
||||
self._medias.extend([MediaInfo(**r) for r in results])
|
||||
except Exception as e:
|
||||
logger.error(f"获取媒体数据失败: {e}")
|
||||
self._has_error = True
|
||||
|
||||
if self._medias:
|
||||
context.medias.extend(self._medias)
|
||||
|
||||
self.job_done(f"获取到 {len(self._medias)} 条媒数据")
|
||||
return context
|
||||
112
app/actions/fetch_rss.py
Normal file
112
app/actions/fetch_rss.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction, ActionChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.context import Context
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.helper.rss import RssHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ActionParams, ActionContext, TorrentInfo
|
||||
|
||||
|
||||
class FetchRssParams(ActionParams):
|
||||
"""
|
||||
获取RSS资源列表参数
|
||||
"""
|
||||
url: str = Field(default=None, description="RSS地址")
|
||||
proxy: Optional[bool] = Field(default=False, description="是否使用代理")
|
||||
timeout: Optional[int] = Field(default=15, description="超时时间")
|
||||
content_type: Optional[str] = Field(default=None, description="Content-Type")
|
||||
referer: Optional[str] = Field(default=None, description="Referer")
|
||||
ua: Optional[str] = Field(default=None, description="User-Agent")
|
||||
match_media: Optional[str] = Field(default=None, description="匹配媒体信息")
|
||||
|
||||
|
||||
class FetchRssAction(BaseAction):
|
||||
"""
|
||||
获取RSS资源列表
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._rss_torrents = []
|
||||
self._has_error = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "获取RSS资源"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "订阅RSS地址获取资源"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return FetchRssParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return not self._has_error
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
请求RSS地址获取数据,并解析为资源列表
|
||||
"""
|
||||
params = FetchRssParams(**params)
|
||||
if not params.url:
|
||||
return context
|
||||
|
||||
headers = {}
|
||||
if params.content_type:
|
||||
headers["Content-Type"] = params.content_type
|
||||
if params.referer:
|
||||
headers["Referer"] = params.referer
|
||||
if params.ua:
|
||||
headers["User-Agent"] = params.ua
|
||||
|
||||
rss_items = RssHelper().parse(url=params.url,
|
||||
proxy=settings.PROXY if params.proxy else None,
|
||||
timeout=params.timeout,
|
||||
headers=headers)
|
||||
if rss_items is None or rss_items is False:
|
||||
logger.error(f'RSS地址 {params.url} 请求失败!')
|
||||
self._has_error = True
|
||||
return context
|
||||
|
||||
if not rss_items:
|
||||
logger.error(f'RSS地址 {params.url} 未获取到RSS数据!')
|
||||
return context
|
||||
|
||||
# 组装种子
|
||||
for item in rss_items:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
if not item.get("title"):
|
||||
continue
|
||||
torrentinfo = TorrentInfo(
|
||||
title=item.get("title"),
|
||||
enclosure=item.get("enclosure"),
|
||||
page_url=item.get("link"),
|
||||
size=item.get("size"),
|
||||
pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None,
|
||||
)
|
||||
meta = MetaInfo(title=torrentinfo.title, subtitle=torrentinfo.description)
|
||||
mediainfo = None
|
||||
if params.match_media:
|
||||
mediainfo = ActionChain().recognize_media(meta)
|
||||
if not mediainfo:
|
||||
logger.warning(f"{torrentinfo.title} 未识别到媒体信息")
|
||||
continue
|
||||
self._rss_torrents.append(Context(meta_info=meta, media_info=mediainfo, torrent_info=torrentinfo))
|
||||
|
||||
if self._rss_torrents:
|
||||
logger.info(f"获取到 {len(self._rss_torrents)} 个RSS资源")
|
||||
context.torrents.extend(self._rss_torrents)
|
||||
|
||||
self.job_done(f"获取到 {len(self._rss_torrents)} 个资源")
|
||||
return context
|
||||
102
app/actions/fetch_torrents.py
Normal file
102
app/actions/fetch_torrents.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import random
|
||||
import time
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction
|
||||
from app.chain.search import SearchChain
|
||||
from app.core.config import global_vars
|
||||
from app.log import logger
|
||||
from app.schemas import ActionParams, ActionContext, MediaType
|
||||
|
||||
|
||||
class FetchTorrentsParams(ActionParams):
|
||||
"""
|
||||
获取站点资源参数
|
||||
"""
|
||||
search_type: Optional[str] = Field(default="keyword", description="搜索类型")
|
||||
name: Optional[str] = Field(default=None, description="资源名称")
|
||||
year: Optional[str] = Field(default=None, description="年份")
|
||||
type: Optional[str] = Field(default=None, description="资源类型 (电影/电视剧)")
|
||||
season: Optional[int] = Field(default=None, description="季度")
|
||||
sites: Optional[List[int]] = Field(default=[], description="站点列表")
|
||||
match_media: Optional[bool] = Field(default=False, description="匹配媒体信息")
|
||||
|
||||
|
||||
class FetchTorrentsAction(BaseAction):
|
||||
"""
|
||||
搜索站点资源
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._torrents = []
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "搜索站点资源"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "搜索站点种子资源列表"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return FetchTorrentsParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.done
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
搜索站点,获取资源列表
|
||||
"""
|
||||
params = FetchTorrentsParams(**params)
|
||||
searchchain = SearchChain()
|
||||
if params.search_type == "keyword":
|
||||
# 按关键字搜索
|
||||
torrents = searchchain.search_by_title(title=params.name, sites=params.sites)
|
||||
for torrent in torrents:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
if params.year and torrent.meta_info.year != params.year:
|
||||
continue
|
||||
if params.type and torrent.media_info and torrent.media_info.type != MediaType(params.type):
|
||||
continue
|
||||
if params.season and torrent.meta_info.begin_season != params.season:
|
||||
continue
|
||||
# 识别媒体信息
|
||||
if params.match_media:
|
||||
torrent.media_info = searchchain.recognize_media(torrent.meta_info)
|
||||
if not torrent.media_info:
|
||||
logger.warning(f"{torrent.torrent_info.title} 未识别到媒体信息")
|
||||
continue
|
||||
self._torrents.append(torrent)
|
||||
else:
|
||||
# 搜索媒体列表
|
||||
for media in context.medias:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
torrents = searchchain.search_by_id(tmdbid=media.tmdb_id,
|
||||
doubanid=media.douban_id,
|
||||
mtype=MediaType(media.type),
|
||||
sites=params.sites)
|
||||
for torrent in torrents:
|
||||
self._torrents.append(torrent)
|
||||
|
||||
# 随机休眠 5-30秒
|
||||
sleep_time = random.randint(5, 30)
|
||||
logger.info(f"随机休眠 {sleep_time} 秒 ...")
|
||||
time.sleep(sleep_time)
|
||||
|
||||
if self._torrents:
|
||||
context.torrents.extend(self._torrents)
|
||||
logger.info(f"共搜索到 {len(self._torrents)} 条资源")
|
||||
|
||||
self.job_done(f"搜索到 {len(self._torrents)} 个资源")
|
||||
return context
|
||||
69
app/actions/filter_medias.py
Normal file
69
app/actions/filter_medias.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction
|
||||
from app.core.config import global_vars
|
||||
from app.log import logger
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
|
||||
|
||||
class FilterMediasParams(ActionParams):
|
||||
"""
|
||||
过滤媒体数据参数
|
||||
"""
|
||||
type: Optional[str] = Field(default=None, description="媒体类型 (电影/电视剧)")
|
||||
vote: Optional[int] = Field(default=0, description="评分")
|
||||
year: Optional[str] = Field(default=None, description="年份")
|
||||
|
||||
|
||||
class FilterMediasAction(BaseAction):
|
||||
"""
|
||||
过滤媒体数据
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._medias = []
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "过滤媒体数据"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "对媒体数据列表进行过滤"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return FilterMediasParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.done
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
过滤medias中媒体数据
|
||||
"""
|
||||
params = FilterMediasParams(**params)
|
||||
for media in context.medias:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
if params.type and media.type != params.type:
|
||||
continue
|
||||
if params.vote and media.vote_average < params.vote:
|
||||
continue
|
||||
if params.year and media.year != params.year:
|
||||
continue
|
||||
self._medias.append(media)
|
||||
|
||||
logger.info(f"过滤后剩余 {len(self._medias)} 条媒体数据")
|
||||
|
||||
context.medias = self._medias
|
||||
|
||||
self.job_done(f"过滤后剩余 {len(self._medias)} 条媒体数据")
|
||||
return context
|
||||
84
app/actions/filter_torrents.py
Normal file
84
app/actions/filter_torrents.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction, ActionChain
|
||||
from app.core.config import global_vars
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
|
||||
|
||||
class FilterTorrentsParams(ActionParams):
|
||||
"""
|
||||
过滤资源数据参数
|
||||
"""
|
||||
rule_groups: Optional[List[str]] = Field(default=[], description="规则组")
|
||||
quality: Optional[str] = Field(default=None, description="资源质量")
|
||||
resolution: Optional[str] = Field(default=None, description="资源分辨率")
|
||||
effect: Optional[str] = Field(default=None, description="特效")
|
||||
include: Optional[str] = Field(default=None, description="包含规则")
|
||||
exclude: Optional[str] = Field(default=None, description="排除规则")
|
||||
size: Optional[str] = Field(default=None, description="资源大小范围(MB)")
|
||||
|
||||
|
||||
class FilterTorrentsAction(BaseAction):
|
||||
"""
|
||||
过滤资源数据
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._torrents = []
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "过滤资源"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "对资源列表数据进行过滤"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return FilterTorrentsParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.done
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
过滤torrents中的资源
|
||||
"""
|
||||
params = FilterTorrentsParams(**params)
|
||||
for torrent in context.torrents:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
if TorrentHelper().filter_torrent(
|
||||
torrent_info=torrent.torrent_info,
|
||||
filter_params={
|
||||
"quality": params.quality,
|
||||
"resolution": params.resolution,
|
||||
"effect": params.effect,
|
||||
"include": params.include,
|
||||
"exclude": params.exclude,
|
||||
"size": params.size
|
||||
}
|
||||
):
|
||||
if ActionChain().filter_torrents(
|
||||
rule_groups=params.rule_groups,
|
||||
torrent_list=[torrent.torrent_info],
|
||||
mediainfo=torrent.media_info
|
||||
):
|
||||
self._torrents.append(torrent)
|
||||
|
||||
logger.info(f"过滤后剩余 {len(self._torrents)} 个资源")
|
||||
|
||||
context.torrents = self._torrents
|
||||
|
||||
self.job_done(f"过滤后剩余 {len(self._torrents)} 个资源")
|
||||
return context
|
||||
70
app/actions/invoke_plugin.py
Normal file
70
app/actions/invoke_plugin.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
|
||||
|
||||
class InvokePluginParams(ActionParams):
|
||||
"""
|
||||
调用插件动作参数
|
||||
"""
|
||||
plugin_id: str = Field(default=None, description="插件ID")
|
||||
action_id: str = Field(default=None, description="动作ID")
|
||||
action_params: dict = Field(default={}, description="动作参数")
|
||||
|
||||
|
||||
class InvokePluginAction(BaseAction):
|
||||
"""
|
||||
调用插件
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._success = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "调用插件"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "调用插件提供的动作"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return InvokePluginParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self._success
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
执行插件定义的动作
|
||||
"""
|
||||
params = InvokePluginParams(**params)
|
||||
if not params.plugin_id or not params.action_id:
|
||||
return context
|
||||
try:
|
||||
plugin_actions = PluginManager().get_plugin_actions(params.plugin_id)
|
||||
if not plugin_actions:
|
||||
logger.error(f"插件不存在: {params.plugin_id}")
|
||||
return context
|
||||
actions = plugin_actions[0].get("actions", [])
|
||||
action = next((action for action in actions if action.action_id == params.action_id), None)
|
||||
if not action or not action.get("func"):
|
||||
logger.error(f"插件动作不存在: {params.plugin_id} - {params.action_id}")
|
||||
return context
|
||||
# 执行插件动作
|
||||
self._success, context = action["func"](context, **params.action_params)
|
||||
except Exception as e:
|
||||
self._success = False
|
||||
logger.error(f"调用插件动作失败: {e}")
|
||||
return context
|
||||
self.job_done()
|
||||
return context
|
||||
30
app/actions/note.py
Normal file
30
app/actions/note.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from app.actions import BaseAction
|
||||
from app.schemas import ActionContext
|
||||
|
||||
|
||||
class NoteAction(BaseAction):
|
||||
"""
|
||||
备注
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "备注"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "给工作流添加备注"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return {}
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return True
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
return context
|
||||
83
app/actions/scan_file.py
Normal file
83
app/actions/scan_file.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction
|
||||
from app.chain.storage import StorageChain
|
||||
from app.core.config import global_vars, settings
|
||||
from app.log import logger
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
|
||||
|
||||
class ScanFileParams(ActionParams):
|
||||
"""
|
||||
整理文件参数
|
||||
"""
|
||||
# 存储
|
||||
storage: Optional[str] = Field(default="local", description="存储")
|
||||
directory: Optional[str] = Field(default=None, description="目录")
|
||||
|
||||
|
||||
class ScanFileAction(BaseAction):
|
||||
"""
|
||||
整理文件
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._fileitems = []
|
||||
self._has_error = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "扫描目录"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "扫描目录文件到队列"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return ScanFileParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return not self._has_error
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
扫描目录中的所有文件,记录到fileitems
|
||||
"""
|
||||
params = ScanFileParams(**params)
|
||||
if not params.storage or not params.directory:
|
||||
return context
|
||||
storagechain = StorageChain()
|
||||
fileitem = storagechain.get_file_item(params.storage, Path(params.directory))
|
||||
if not fileitem:
|
||||
logger.error(f"目录不存在: 【{params.storage}】{params.directory}")
|
||||
self._has_error = True
|
||||
return context
|
||||
files = storagechain.list_files(fileitem, recursion=True)
|
||||
for file in files:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
if not file.extension or f".{file.extension.lower()}" not in settings.RMT_MEDIAEXT:
|
||||
continue
|
||||
# 检查缓存
|
||||
cache_key = f"{file.path}"
|
||||
if self.check_cache(workflow_id, cache_key):
|
||||
logger.info(f"{file.path} 已处理过,跳过")
|
||||
continue
|
||||
self._fileitems.append(fileitem)
|
||||
# 保存缓存
|
||||
self.save_cache(workflow_id, cache_key)
|
||||
|
||||
if self._fileitems:
|
||||
context.fileitems.extend(self._fileitems)
|
||||
|
||||
self.job_done(f"扫描到 {len(self._fileitems)} 个文件")
|
||||
return context
|
||||
82
app/actions/scrape_file.py
Normal file
82
app/actions/scrape_file.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.actions import BaseAction
|
||||
from app.core.config import global_vars
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.storage import StorageChain
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class ScrapeFileParams(ActionParams):
|
||||
"""
|
||||
刮削文件参数
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ScrapeFileAction(BaseAction):
|
||||
"""
|
||||
刮削文件
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._scraped_files = []
|
||||
self._has_error = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "刮削文件"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "刮削媒体信息和图片"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return ScrapeFileParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return not self._has_error
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
刮削fileitems中的所有文件
|
||||
"""
|
||||
# 失败次数
|
||||
_failed_count = 0
|
||||
for fileitem in context.fileitems:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
if fileitem in self._scraped_files:
|
||||
continue
|
||||
if not StorageChain().exists(fileitem):
|
||||
continue
|
||||
# 检查缓存
|
||||
cache_key = f"{fileitem.path}"
|
||||
if self.check_cache(workflow_id, cache_key):
|
||||
logger.info(f"{fileitem.path} 已刮削过,跳过")
|
||||
continue
|
||||
meta = MetaInfoPath(Path(fileitem.path))
|
||||
mediachain = MediaChain()
|
||||
mediainfo = mediachain.recognize_media(meta)
|
||||
if not mediainfo:
|
||||
_failed_count += 1
|
||||
logger.info(f"{fileitem.path} 未识别到媒体信息,无法刮削")
|
||||
continue
|
||||
mediachain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||
self._scraped_files.append(fileitem)
|
||||
# 保存缓存
|
||||
self.save_cache(workflow_id, cache_key)
|
||||
|
||||
if not self._scraped_files and _failed_count:
|
||||
self._has_error = True
|
||||
|
||||
self.job_done(f"成功刮削 {len(self._scraped_files)} 个文件,失败 {_failed_count} 个")
|
||||
return context
|
||||
48
app/actions/send_event.py
Normal file
48
app/actions/send_event.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from app.actions import BaseAction
|
||||
from app.core.event import eventmanager
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
from app.schemas.types import ChainEventType
|
||||
|
||||
|
||||
class SendEventParams(ActionParams):
|
||||
"""
|
||||
发送事件参数
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SendEventAction(BaseAction):
|
||||
"""
|
||||
发送事件
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "发送事件"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "发送任务执行事件"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return SendEventParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.done
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
发送工作流事件,以更插件干预工作流执行
|
||||
"""
|
||||
# 触发资源下载事件,更新执行上下文
|
||||
event = eventmanager.send_event(ChainEventType.WorkflowExecution, context)
|
||||
if event and event.event_data:
|
||||
context = event.event_data
|
||||
|
||||
self.job_done()
|
||||
return context
|
||||
73
app/actions/send_message.py
Normal file
73
app/actions/send_message.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction, ActionChain
|
||||
from app.schemas import ActionParams, ActionContext, Notification
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class SendMessageParams(ActionParams):
|
||||
"""
|
||||
发送消息参数
|
||||
"""
|
||||
client: Optional[List[str]] = Field(default=[], description="消息渠道")
|
||||
userid: Optional[Union[str, int]] = Field(default=None, description="用户ID")
|
||||
|
||||
|
||||
class SendMessageAction(BaseAction):
|
||||
"""
|
||||
发送消息
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "发送消息"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "发送任务执行消息"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return SendMessageParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.done
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
发送messages中的消息
|
||||
"""
|
||||
params = SendMessageParams(**params)
|
||||
msg_text = f"当前进度:{context.progress}%"
|
||||
index = 1
|
||||
if context.execute_history:
|
||||
for history in context.execute_history:
|
||||
if not history.message:
|
||||
continue
|
||||
msg_text += f"\n{index}. {history.action}:{history.message}"
|
||||
index += 1
|
||||
# 发送消息
|
||||
if not params.client:
|
||||
params.client = [""]
|
||||
for client in params.client:
|
||||
ActionChain().post_message(
|
||||
Notification(
|
||||
source=client,
|
||||
userid=params.userid,
|
||||
title="【工作流执行结果】",
|
||||
text=msg_text,
|
||||
link=settings.MP_DOMAIN("#/workflow")
|
||||
)
|
||||
)
|
||||
|
||||
self.job_done()
|
||||
return context
|
||||
136
app/actions/transfer_file.py
Normal file
136
app/actions/transfer_file.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction
|
||||
from app.core.config import global_vars
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
from app.chain.storage import StorageChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class TransferFileParams(ActionParams):
|
||||
"""
|
||||
整理文件参数
|
||||
"""
|
||||
# 来源
|
||||
source: Optional[str] = Field(default="downloads", description="来源")
|
||||
|
||||
|
||||
class TransferFileAction(BaseAction):
|
||||
"""
|
||||
整理文件
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._fileitems = []
|
||||
self._has_error = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "整理文件"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "整理队列中的文件"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return TransferFileParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return not self._has_error
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
从 downloads / fileitems 中整理文件,记录到fileitems
|
||||
"""
|
||||
|
||||
def check_continue():
|
||||
"""
|
||||
检查是否继续整理文件
|
||||
"""
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
return False
|
||||
return True
|
||||
|
||||
params = TransferFileParams(**params)
|
||||
# 失败次数
|
||||
_failed_count = 0
|
||||
storagechain = StorageChain()
|
||||
transferchain = TransferChain()
|
||||
transferhis = TransferHistoryOper()
|
||||
if params.source == "downloads":
|
||||
# 从下载任务中整理文件
|
||||
for download in context.downloads:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
if not download.completed:
|
||||
logger.info(f"下载任务 {download.download_id} 未完成")
|
||||
continue
|
||||
# 检查缓存
|
||||
cache_key = f"{download.download_id}"
|
||||
if self.check_cache(workflow_id, cache_key):
|
||||
logger.info(f"{download.path} 已整理过,跳过")
|
||||
continue
|
||||
fileitem = storagechain.get_file_item(storage="local", path=Path(download.path))
|
||||
if not fileitem:
|
||||
logger.info(f"文件 {download.path} 不存在")
|
||||
continue
|
||||
transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage)
|
||||
if transferd:
|
||||
# 已经整理过的文件不再整理
|
||||
continue
|
||||
logger.info(f"开始整理文件 {download.path} ...")
|
||||
state, errmsg = transferchain.do_transfer(fileitem, background=False)
|
||||
if not state:
|
||||
_failed_count += 1
|
||||
logger.error(f"整理文件 {download.path} 失败: {errmsg}")
|
||||
continue
|
||||
logger.info(f"整理文件 {download.path} 完成")
|
||||
self._fileitems.append(fileitem)
|
||||
self.save_cache(workflow_id, cache_key)
|
||||
else:
|
||||
# 从 fileitems 中整理文件
|
||||
for fileitem in copy.deepcopy(context.fileitems):
|
||||
if not check_continue():
|
||||
break
|
||||
# 检查缓存
|
||||
cache_key = f"{fileitem.path}"
|
||||
if self.check_cache(workflow_id, cache_key):
|
||||
logger.info(f"{fileitem.path} 已整理过,跳过")
|
||||
continue
|
||||
transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage)
|
||||
if transferd:
|
||||
# 已经整理过的文件不再整理
|
||||
continue
|
||||
logger.info(f"开始整理文件 {fileitem.path} ...")
|
||||
state, errmsg = transferchain.do_transfer(fileitem, background=False,
|
||||
continue_callback=check_continue)
|
||||
if not state:
|
||||
_failed_count += 1
|
||||
logger.error(f"整理文件 {fileitem.path} 失败: {errmsg}")
|
||||
continue
|
||||
logger.info(f"整理文件 {fileitem.path} 完成")
|
||||
# 从 fileitems 中移除已整理的文件
|
||||
context.fileitems.remove(fileitem)
|
||||
self._fileitems.append(fileitem)
|
||||
# 记录已整理的文件
|
||||
self.save_cache(workflow_id, cache_key)
|
||||
|
||||
if self._fileitems:
|
||||
context.fileitems.extend(self._fileitems)
|
||||
elif _failed_count:
|
||||
self._has_error = True
|
||||
|
||||
self.job_done(f"整理成功 {len(self._fileitems)} 个文件,失败 {_failed_count} 个")
|
||||
return context
|
||||
@@ -1,8 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
|
||||
from app.api.endpoints import login, user, webhook, message, site, subscribe, \
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
|
||||
transfer, mediaserver, bangumi, storage
|
||||
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, monitoring
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
||||
@@ -24,3 +24,8 @@ api_router.include_router(storage.router, prefix="/storage", tags=["storage"])
|
||||
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"])
|
||||
api_router.include_router(discover.router, prefix="/discover", tags=["discover"])
|
||||
api_router.include_router(recommend.router, prefix="/recommend", tags=["recommend"])
|
||||
api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"])
|
||||
api_router.include_router(torrent.router, prefix="/torrent", tags=["torrent"])
|
||||
api_router.include_router(monitoring.router, prefix="/monitoring", tags=["monitoring"])
|
||||
|
||||
@@ -1,83 +1,73 @@
|
||||
from typing import List, Any
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.recommend import RecommendChain
|
||||
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每日放送
|
||||
"""
|
||||
return RecommendChain().bangumi_calendar(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson])
|
||||
def bangumi_credits(bangumiid: int,
|
||||
page: int = 1,
|
||||
count: int = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def bangumi_credits(bangumiid: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询Bangumi演职员表
|
||||
"""
|
||||
persons = BangumiChain().bangumi_credits(bangumiid)
|
||||
persons = await BangumiChain().async_bangumi_credits(bangumiid)
|
||||
if persons:
|
||||
return persons[(page - 1) * count: page * count]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/recommend/{bangumiid}", summary="查询Bangumi推荐", response_model=List[schemas.MediaInfo])
|
||||
def bangumi_recommend(bangumiid: int,
|
||||
page: int = 1,
|
||||
count: int = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def bangumi_recommend(bangumiid: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询Bangumi推荐
|
||||
"""
|
||||
medias = BangumiChain().bangumi_recommend(bangumiid)
|
||||
medias = await BangumiChain().async_bangumi_recommend(bangumiid)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||
def bangumi_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def bangumi_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
return BangumiChain().person_detail(person_id=person_id)
|
||||
return await BangumiChain().async_person_detail(person_id=person_id)
|
||||
|
||||
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
def bangumi_person_credits(person_id: int,
|
||||
page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def bangumi_person_credits(person_id: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
medias = BangumiChain().person_credits(person_id=person_id)
|
||||
medias = await BangumiChain().async_person_credits(person_id=person_id)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * 20: page * 20]]
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo)
|
||||
def bangumi_info(bangumiid: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def bangumi_info(bangumiid: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询Bangumi详情
|
||||
"""
|
||||
info = BangumiChain().bangumi_info(bangumiid)
|
||||
info = await BangumiChain().async_bangumi_info(bangumiid)
|
||||
if info:
|
||||
return MediaInfo(bangumi_info=info).to_dict()
|
||||
else:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, List, Optional, Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -18,7 +18,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
|
||||
def statistic(name: str = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def statistic(name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询媒体数量统计信息
|
||||
"""
|
||||
@@ -37,7 +37,7 @@ def statistic(name: str = None, _: schemas.TokenPayload = Depends(verify_token))
|
||||
|
||||
|
||||
@router.get("/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic)
|
||||
def statistic2(_: str = Depends(verify_apitoken)) -> Any:
|
||||
def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
查询媒体数量统计信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
@@ -66,7 +66,7 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/storage2", summary="本地存储空间(API_TOKEN)", response_model=schemas.Storage)
|
||||
def storage2(_: str = Depends(verify_apitoken)) -> Any:
|
||||
def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
查询本地存储空间信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
@@ -82,7 +82,7 @@ def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
|
||||
def downloader(name: str = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def downloader(name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询下载器信息
|
||||
"""
|
||||
@@ -103,7 +103,7 @@ def downloader(name: str = None, _: schemas.TokenPayload = Depends(verify_token)
|
||||
|
||||
|
||||
@router.get("/downloader2", summary="下载器信息(API_TOKEN)", response_model=schemas.DownloaderInfo)
|
||||
def downloader2(_: str = Depends(verify_apitoken)) -> Any:
|
||||
def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
查询下载器信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
@@ -111,7 +111,7 @@ def downloader2(_: str = Depends(verify_apitoken)) -> Any:
|
||||
|
||||
|
||||
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
|
||||
def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询后台服务信息
|
||||
"""
|
||||
@@ -119,7 +119,7 @@ def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/schedule2", summary="后台服务(API_TOKEN)", response_model=List[schemas.ScheduleInfo])
|
||||
def schedule2(_: str = Depends(verify_apitoken)) -> Any:
|
||||
async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
查询下载器信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
@@ -127,12 +127,13 @@ def schedule2(_: str = Depends(verify_apitoken)) -> Any:
|
||||
|
||||
|
||||
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
|
||||
def transfer(days: int = 7, db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def transfer(days: Optional[int] = 7,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询文件整理统计信息
|
||||
"""
|
||||
transfer_stat = TransferHistory.statistic(db, days)
|
||||
transfer_stat = await TransferHistory.async_statistic(db, days)
|
||||
return [stat[1] for stat in transfer_stat]
|
||||
|
||||
|
||||
@@ -145,7 +146,7 @@ def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/cpu2", summary="获取当前CPU使用率(API_TOKEN)", response_model=int)
|
||||
def cpu2(_: str = Depends(verify_apitoken)) -> Any:
|
||||
def cpu2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
获取当前CPU使用率 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
@@ -161,8 +162,24 @@ def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/memory2", summary="获取当前内存使用量和使用率(API_TOKEN)", response_model=List[int])
|
||||
def memory2(_: str = Depends(verify_apitoken)) -> Any:
|
||||
def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
获取当前内存使用率 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return memory()
|
||||
|
||||
|
||||
@router.get("/network", summary="获取当前网络流量", response_model=List[int])
|
||||
def network(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取当前网络流量(上行和下行流量,单位:bytes/s)
|
||||
"""
|
||||
return SystemUtils.network_usage()
|
||||
|
||||
|
||||
@router.get("/network2", summary="获取当前网络流量(API_TOKEN)", response_model=List[int])
|
||||
def network2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
获取当前网络流量 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return network()
|
||||
|
||||
130
app/api/endpoints/discover.py
Normal file
130
app/api/endpoints/discover.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.schemas import DiscoverSourceEventData
|
||||
from app.schemas.types import ChainEventType, MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/source", summary="获取探索数据源", response_model=List[schemas.DiscoverMediaSource])
|
||||
def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取探索数据源
|
||||
"""
|
||||
# 广播事件,请示额外的探索数据源支持
|
||||
event_data = DiscoverSourceEventData()
|
||||
event = eventmanager.send_event(ChainEventType.DiscoverSource, event_data)
|
||||
# 使用事件返回的上下文数据
|
||||
if event and event.event_data:
|
||||
event_data: DiscoverSourceEventData = event.event_data
|
||||
if event_data.extra_sources:
|
||||
return event_data.extra_sources
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/bangumi", summary="探索Bangumi", response_model=List[schemas.MediaInfo])
|
||||
async def bangumi(type: Optional[int] = 2,
|
||||
cat: Optional[int] = None,
|
||||
sort: Optional[str] = 'rank',
|
||||
year: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
探索Bangumi
|
||||
"""
|
||||
medias = await BangumiChain().async_discover(type=type, cat=cat, sort=sort, year=year,
|
||||
limit=count, offset=(page - 1) * count)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/douban_movies", summary="探索豆瓣电影", response_model=List[schemas.MediaInfo])
|
||||
async def douban_movies(sort: Optional[str] = "R",
|
||||
tags: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣电影信息
|
||||
"""
|
||||
movies = await DoubanChain().async_douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
|
||||
@router.get("/douban_tvs", summary="探索豆瓣剧集", response_model=List[schemas.MediaInfo])
|
||||
async def douban_tvs(sort: Optional[str] = "R",
|
||||
tags: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
tvs = await DoubanChain().async_douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
|
||||
@router.get("/tmdb_movies", summary="探索TMDB电影", response_model=List[schemas.MediaInfo])
|
||||
async def tmdb_movies(sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB电影信息
|
||||
"""
|
||||
movies = await TmdbChain().async_tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return [movie.to_dict() for movie in movies] if movies else []
|
||||
|
||||
|
||||
@router.get("/tmdb_tvs", summary="探索TMDB剧集", response_model=List[schemas.MediaInfo])
|
||||
async def tmdb_tvs(sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
tvs = await TmdbChain().async_tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return [tv.to_dict() for tv in tvs] if tvs else []
|
||||
@@ -1,10 +1,9 @@
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.schemas import MediaType
|
||||
@@ -13,148 +12,54 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||
def douban_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
return DoubanChain().person_detail(person_id=person_id)
|
||||
return await DoubanChain().async_person_detail(person_id=person_id)
|
||||
|
||||
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
def douban_person_credits(person_id: int,
|
||||
page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_person_credits(person_id: int,
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
medias = DoubanChain().person_credits(person_id=person_id, page=page)
|
||||
medias = await DoubanChain().async_person_credits(person_id=person_id, page=page)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
|
||||
def movie_showing(page: int = 1,
|
||||
count: int = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣正在热映
|
||||
"""
|
||||
return RecommendChain().douban_movie_showing(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
|
||||
def douban_movies(sort: str = "R",
|
||||
tags: str = "",
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣电影信息
|
||||
"""
|
||||
return RecommendChain().douban_movies(sort=sort, tags=tags, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
|
||||
def douban_tvs(sort: str = "R",
|
||||
tags: str = "",
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
return RecommendChain().douban_tvs(sort=sort, tags=tags, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
||||
def movie_top250(page: int = 1,
|
||||
count: int = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
return RecommendChain().douban_movie_top250(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
def tv_weekly_chinese(page: int = 1,
|
||||
count: int = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
中国每周剧集口碑榜
|
||||
"""
|
||||
return RecommendChain().douban_tv_weekly_chinese(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
def tv_weekly_global(page: int = 1,
|
||||
count: int = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
全球每周剧集口碑榜
|
||||
"""
|
||||
return RecommendChain().douban_tv_weekly_global(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
|
||||
def tv_animation(page: int = 1,
|
||||
count: int = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门动画剧集
|
||||
"""
|
||||
return RecommendChain().douban_tv_animation(page=page, count=count)
|
||||
|
||||
|
||||
@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:
|
||||
"""
|
||||
热门电影
|
||||
"""
|
||||
return RecommendChain().douban_movie_hot(page=page, count=count)
|
||||
|
||||
|
||||
@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:
|
||||
"""
|
||||
热门电视剧
|
||||
"""
|
||||
return RecommendChain().douban_tv_hot(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson])
|
||||
def douban_credits(doubanid: str,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_credits(doubanid: str,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询演员阵容,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
return DoubanChain().movie_credits(doubanid=doubanid)
|
||||
return await DoubanChain().async_movie_credits(doubanid=doubanid)
|
||||
elif mediatype == MediaType.TV:
|
||||
return DoubanChain().tv_credits(doubanid=doubanid)
|
||||
return await DoubanChain().async_tv_credits(doubanid=doubanid)
|
||||
return []
|
||||
|
||||
|
||||
@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:
|
||||
async 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:
|
||||
medias = DoubanChain().movie_recommend(doubanid=doubanid)
|
||||
medias = await DoubanChain().async_movie_recommend(doubanid=doubanid)
|
||||
elif mediatype == MediaType.TV:
|
||||
medias = DoubanChain().tv_recommend(doubanid=doubanid)
|
||||
medias = await DoubanChain().async_tv_recommend(doubanid=doubanid)
|
||||
else:
|
||||
return []
|
||||
if medias:
|
||||
@@ -163,12 +68,12 @@ def douban_recommend(doubanid: str,
|
||||
|
||||
|
||||
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
|
||||
def douban_info(doubanid: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def douban_info(doubanid: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询豆瓣媒体信息
|
||||
"""
|
||||
doubaninfo = DoubanChain().douban_info(doubanid=doubanid)
|
||||
doubaninfo = await DoubanChain().async_douban_info(doubanid=doubanid)
|
||||
if doubaninfo:
|
||||
return MediaInfo(douban_info=doubaninfo).to_dict()
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
|
||||
@@ -18,7 +18,7 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
||||
def current(
|
||||
name: str = None,
|
||||
name: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询正在下载的任务
|
||||
@@ -30,8 +30,8 @@ def current(
|
||||
def download(
|
||||
media_in: schemas.MediaInfo,
|
||||
torrent_in: schemas.TorrentInfo,
|
||||
downloader: str = Body(None),
|
||||
save_path: str = Body(None),
|
||||
downloader: Annotated[str | None, Body()] = None,
|
||||
save_path: Annotated[str | None, Body()] = None,
|
||||
current_user: User = Depends(get_current_active_user)) -> Any:
|
||||
"""
|
||||
添加下载任务(含媒体信息)
|
||||
@@ -44,6 +44,8 @@ def download(
|
||||
# 种子信息
|
||||
torrentinfo = TorrentInfo()
|
||||
torrentinfo.from_dict(torrent_in.dict())
|
||||
# 手动下载始终使用选择的下载器
|
||||
torrentinfo.site_downloader = downloader
|
||||
# 上下文
|
||||
context = Context(
|
||||
meta_info=metainfo,
|
||||
@@ -51,7 +53,7 @@ def download(
|
||||
torrent_info=torrentinfo
|
||||
)
|
||||
did = DownloadChain().download_single(context=context, username=current_user.name,
|
||||
downloader=downloader, save_path=save_path, source="Manual")
|
||||
save_path=save_path, source="Manual")
|
||||
if not did:
|
||||
return schemas.Response(success=False, message="任务添加失败")
|
||||
return schemas.Response(success=True, data={
|
||||
@@ -62,8 +64,8 @@ def download(
|
||||
@router.post("/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response)
|
||||
def add(
|
||||
torrent_in: schemas.TorrentInfo,
|
||||
downloader: str = Body(None),
|
||||
save_path: str = Body(None),
|
||||
downloader: Annotated[str | None, Body()] = None,
|
||||
save_path: Annotated[str | None, Body()] = None,
|
||||
current_user: User = Depends(get_current_active_user)) -> Any:
|
||||
"""
|
||||
添加下载任务(不含媒体信息)
|
||||
@@ -94,27 +96,27 @@ def add(
|
||||
|
||||
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
|
||||
def start(
|
||||
hashString: str,
|
||||
hashString: str, name: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
开如下载任务
|
||||
"""
|
||||
ret = DownloadChain().set_downloading(hashString, "start")
|
||||
ret = DownloadChain().set_downloading(hashString, "start", name=name)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
|
||||
def stop(hashString: str,
|
||||
def stop(hashString: str, name: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
暂停下载任务
|
||||
"""
|
||||
ret = DownloadChain().set_downloading(hashString, "stop")
|
||||
ret = DownloadChain().set_downloading(hashString, "stop", name=name)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@router.get("/clients", summary="查询可用下载器", response_model=List[dict])
|
||||
def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询可用下载器
|
||||
"""
|
||||
@@ -125,10 +127,10 @@ def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
||||
def delete(hashString: str,
|
||||
def delete(hashString: str, name: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除下载任务
|
||||
"""
|
||||
ret = DownloadChain().remove_downloading(hashString)
|
||||
ret = DownloadChain().remove_downloading(hashString, name=name)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
from typing import List, Any
|
||||
from typing import List, Any, Optional
|
||||
|
||||
import jieba
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.storage import StorageChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db import get_async_db, get_db
|
||||
from app.db.models import User
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.db.user_oper import get_current_active_superuser_async, get_current_active_superuser
|
||||
from app.schemas.types import EventType, MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/download", summary="查询下载历史记录", response_model=List[schemas.DownloadHistory])
|
||||
def download_history(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def download_history(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询下载历史记录
|
||||
"""
|
||||
return DownloadHistory.list_by_page(db, page, count)
|
||||
return await DownloadHistory.async_list_by_page(db, page, count)
|
||||
|
||||
|
||||
@router.delete("/download", summary="删除下载历史记录", response_model=schemas.Response)
|
||||
def delete_download_history(history_in: schemas.DownloadHistory,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def delete_download_history(history_in: schemas.DownloadHistory,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除下载历史记录
|
||||
"""
|
||||
DownloadHistory.delete(db, history_in.id)
|
||||
await DownloadHistory.async_delete(db, history_in.id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/transfer", summary="查询整理记录", response_model=schemas.Response)
|
||||
def transfer_history(title: str = None,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
status: bool = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def transfer_history(title: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
status: Optional[bool] = None,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询整理记录
|
||||
"""
|
||||
@@ -59,15 +59,14 @@ def transfer_history(title: str = None,
|
||||
status = True
|
||||
|
||||
if title:
|
||||
if settings.TOKENIZED_SEARCH:
|
||||
words = jieba.cut(title, HMM=False)
|
||||
title = "%".join(words)
|
||||
total = TransferHistory.count_by_title(db, title=title, status=status)
|
||||
result = TransferHistory.list_by_title(db, title=title, page=page,
|
||||
count=count, status=status)
|
||||
words = jieba.cut(title, HMM=False)
|
||||
title = "%".join(words)
|
||||
total = await TransferHistory.async_count_by_title(db, title=title, status=status)
|
||||
result = await TransferHistory.async_list_by_title(db, title=title, page=page,
|
||||
count=count, status=status)
|
||||
else:
|
||||
result = TransferHistory.list_by_page(db, page=page, count=count, status=status)
|
||||
total = TransferHistory.count(db, status=status)
|
||||
result = await TransferHistory.async_list_by_page(db, page=page, count=count, status=status)
|
||||
total = await TransferHistory.async_count(db, status=status)
|
||||
|
||||
return schemas.Response(success=True,
|
||||
data={
|
||||
@@ -78,10 +77,10 @@ def transfer_history(title: str = None,
|
||||
|
||||
@router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response)
|
||||
def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
deletesrc: bool = False,
|
||||
deletedest: bool = False,
|
||||
deletesrc: Optional[bool] = False,
|
||||
deletedest: Optional[bool] = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
删除整理记录
|
||||
"""
|
||||
@@ -91,7 +90,7 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
# 册除媒体库文件
|
||||
if deletedest and history.dest_fileitem:
|
||||
dest_fileitem = schemas.FileItem(**history.dest_fileitem)
|
||||
StorageChain().delete_media_file(fileitem=dest_fileitem, mtype=MediaType(history.type))
|
||||
StorageChain().delete_media_file(dest_fileitem)
|
||||
|
||||
# 删除源文件
|
||||
if deletesrc and history.src_fileitem:
|
||||
@@ -113,10 +112,10 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
|
||||
|
||||
@router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response)
|
||||
def delete_transfer_history(db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
async def empty_transfer_history(db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
清空整理记录
|
||||
"""
|
||||
TransferHistory.truncate(db)
|
||||
await TransferHistory.async_truncate(db)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from app import schemas
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.user import UserChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.utils.web import WebUtils
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.helper.wallpaper import WallpaperHelper
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
|
||||
def login_access_token(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
otp_password: str = Form(None)
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
otp_password: Annotated[str | None, Form()] = None
|
||||
) -> Any:
|
||||
"""
|
||||
获取认证Token
|
||||
@@ -45,7 +43,8 @@ def login_access_token(
|
||||
user_id=user_or_message.id,
|
||||
user_name=user_or_message.name,
|
||||
avatar=user_or_message.avatar,
|
||||
level=level
|
||||
level=level,
|
||||
permissions=user_or_message.permissions or {},
|
||||
)
|
||||
|
||||
|
||||
@@ -54,12 +53,7 @@ def wallpaper() -> Any:
|
||||
"""
|
||||
获取登录页面电影海报
|
||||
"""
|
||||
if settings.WALLPAPER == "bing":
|
||||
url = WebUtils.get_bing_wallpaper()
|
||||
elif settings.WALLPAPER == "mediaserver":
|
||||
url = MediaServerChain().get_latest_wallpaper()
|
||||
else:
|
||||
url = TmdbChain().get_random_wallpager()
|
||||
url = WallpaperHelper().get_wallpaper()
|
||||
if url:
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
@@ -73,9 +67,4 @@ def wallpapers() -> Any:
|
||||
"""
|
||||
获取登录页面电影海报
|
||||
"""
|
||||
if settings.WALLPAPER == "bing":
|
||||
return WebUtils.get_bing_wallpapers()
|
||||
elif settings.WALLPAPER == "mediaserver":
|
||||
return MediaServerChain().get_latest_wallpapers()
|
||||
else:
|
||||
return TmdbChain().get_trending_wallpapers()
|
||||
return WallpaperHelper().get_wallpapers()
|
||||
|
||||
@@ -1,78 +1,83 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Any, Union
|
||||
from typing import List, Any, Union, Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context
|
||||
from app.core.event import eventmanager
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.core.security import verify_token, verify_apitoken
|
||||
from app.schemas import MediaType
|
||||
from app.schemas import MediaType, MediaRecognizeConvertEventData
|
||||
from app.schemas.types import ChainEventType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context)
|
||||
def recognize(title: str,
|
||||
subtitle: str = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def recognize(title: str,
|
||||
subtitle: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据标题、副标题识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
metainfo = MetaInfo(title, subtitle)
|
||||
mediainfo = MediaChain().recognize_by_meta(metainfo)
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
|
||||
if mediainfo:
|
||||
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
|
||||
return schemas.Context()
|
||||
|
||||
|
||||
@router.get("/recognize2", summary="识别种子媒体信息(API_TOKEN)", response_model=schemas.Context)
|
||||
def recognize2(title: str,
|
||||
subtitle: str = None,
|
||||
_: str = Depends(verify_apitoken)) -> Any:
|
||||
async def recognize2(_: Annotated[str, Depends(verify_apitoken)],
|
||||
title: str,
|
||||
subtitle: Optional[str] = None
|
||||
) -> Any:
|
||||
"""
|
||||
根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
# 识别媒体信息
|
||||
return recognize(title, subtitle)
|
||||
return await recognize(title, subtitle)
|
||||
|
||||
|
||||
@router.get("/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context)
|
||||
def recognize_file(path: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def recognize_file(path: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = MediaChain().recognize_by_path(path)
|
||||
context = await MediaChain().async_recognize_by_path(path)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
return schemas.Context()
|
||||
|
||||
|
||||
@router.get("/recognize_file2", summary="识别文件媒体信息(API_TOKEN)", response_model=schemas.Context)
|
||||
def recognize_file2(path: str,
|
||||
_: str = Depends(verify_apitoken)) -> Any:
|
||||
async def recognize_file2(path: str,
|
||||
_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
# 识别媒体信息
|
||||
return recognize_file(path)
|
||||
return await recognize_file(path)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
|
||||
def search(title: str,
|
||||
type: str = "media",
|
||||
page: int = 1,
|
||||
count: int = 8,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def search(title: str,
|
||||
type: Optional[str] = "media",
|
||||
page: int = 1,
|
||||
count: int = 8,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
模糊搜索媒体/人物信息列表 media:媒体信息,person:人物信息
|
||||
"""
|
||||
def __get_source(obj: Union[dict, schemas.MediaPerson]):
|
||||
|
||||
def __get_source(obj: Union[schemas.MediaInfo, schemas.MediaPerson, dict]):
|
||||
"""
|
||||
获取对象属性
|
||||
"""
|
||||
@@ -81,12 +86,15 @@ def search(title: str,
|
||||
return obj.source
|
||||
|
||||
result = []
|
||||
media_chain = MediaChain()
|
||||
if type == "media":
|
||||
_, medias = MediaChain().search(title=title)
|
||||
_, medias = await media_chain.async_search(title=title)
|
||||
if medias:
|
||||
result = [media.to_dict() for media in medias]
|
||||
elif type == "collection":
|
||||
result = await media_chain.async_search_collections(name=title)
|
||||
else:
|
||||
result = MediaChain().search_persons(name=title)
|
||||
result = await media_chain.async_search_persons(name=title)
|
||||
if result:
|
||||
# 按设置的顺序对结果进行排序
|
||||
setting_order = settings.SEARCH_SOURCE.split(',') or []
|
||||
@@ -94,12 +102,13 @@ def search(title: str,
|
||||
for index, source in enumerate(setting_order):
|
||||
sort_order[source] = index
|
||||
result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4))
|
||||
return result[(page - 1) * count:page * count]
|
||||
return result[(page - 1) * count:page * count]
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response)
|
||||
def scrape(fileitem: schemas.FileItem,
|
||||
storage: str = "local",
|
||||
storage: Optional[str] = "local",
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
刮削媒体信息
|
||||
@@ -116,38 +125,122 @@ def scrape(fileitem: schemas.FileItem,
|
||||
if storage == "local":
|
||||
if not scrape_path.exists():
|
||||
return schemas.Response(success=False, message="刮削路径不存在")
|
||||
# 手动刮削
|
||||
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
|
||||
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True)
|
||||
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||||
|
||||
|
||||
@router.get("/category", summary="查询自动分类配置", response_model=dict)
|
||||
def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询自动分类配置
|
||||
"""
|
||||
return MediaChain().media_category() or {}
|
||||
|
||||
|
||||
@router.get("/group/seasons/{episode_group}", summary="查询剧集组季信息", response_model=List[schemas.MediaSeason])
|
||||
async def group_seasons(episode_group: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询剧集组季信息(themoviedb)
|
||||
"""
|
||||
return await TmdbChain().async_tmdb_group_seasons(group_id=episode_group)
|
||||
|
||||
|
||||
@router.get("/groups/{tmdbid}", summary="查询媒体剧集组", response_model=List[dict])
|
||||
async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询媒体剧集组列表(themoviedb)
|
||||
"""
|
||||
mediainfo = await MediaChain().async_recognize_media(tmdbid=tmdbid, mtype=MediaType.TV)
|
||||
if not mediainfo:
|
||||
return []
|
||||
return mediainfo.episode_groups
|
||||
|
||||
|
||||
@router.get("/seasons", summary="查询媒体季信息", response_model=List[schemas.MediaSeason])
|
||||
async def seasons(mediaid: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: str = None,
|
||||
season: int = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询媒体季信息
|
||||
"""
|
||||
if mediaid:
|
||||
if mediaid.startswith("tmdb:"):
|
||||
tmdbid = int(mediaid[5:])
|
||||
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
|
||||
if seasons_info:
|
||||
if season:
|
||||
return [sea for sea in seasons_info if sea.season_number == season]
|
||||
return seasons_info
|
||||
if title:
|
||||
meta = MetaInfo(title)
|
||||
if year:
|
||||
meta.year = year
|
||||
mediainfo = await MediaChain().async_recognize_media(meta, mtype=MediaType.TV)
|
||||
if mediainfo:
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id)
|
||||
if seasons_info:
|
||||
if season:
|
||||
return [sea for sea in seasons_info if sea.season_number == season]
|
||||
return seasons_info
|
||||
else:
|
||||
sea = season or 1
|
||||
return [schemas.MediaSeason(
|
||||
season_number=sea,
|
||||
poster_path=mediainfo.poster_path,
|
||||
name=f"第 {sea} 季",
|
||||
air_date=mediainfo.release_date,
|
||||
overview=mediainfo.overview,
|
||||
vote_average=mediainfo.vote_average,
|
||||
episode_count=mediainfo.number_of_episodes
|
||||
)]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||
def media_info(mediaid: str, type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: str = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧
|
||||
"""
|
||||
mtype = MediaType(type_name)
|
||||
tmdbid, doubanid, bangumiid = None, None, None
|
||||
mediainfo = None
|
||||
mediachain = MediaChain()
|
||||
if mediaid.startswith("tmdb:"):
|
||||
tmdbid = int(mediaid[5:])
|
||||
mediainfo = await mediachain.async_recognize_media(tmdbid=int(mediaid[5:]), mtype=mtype)
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid[7:]
|
||||
mediainfo = await mediachain.async_recognize_media(doubanid=mediaid[7:], mtype=mtype)
|
||||
elif mediaid.startswith("bangumi:"):
|
||||
bangumiid = int(mediaid[8:])
|
||||
if not tmdbid and not doubanid and not bangumiid:
|
||||
return schemas.MediaInfo()
|
||||
mediainfo = await mediachain.async_recognize_media(bangumiid=int(mediaid[8:]), mtype=mtype)
|
||||
else:
|
||||
# 广播事件解析媒体信息
|
||||
event_data = MediaRecognizeConvertEventData(
|
||||
mediaid=mediaid,
|
||||
convert_type=settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data)
|
||||
# 使用事件返回的上下文数据
|
||||
if event and event.event_data and event.event_data.media_dict:
|
||||
event_data: MediaRecognizeConvertEventData = event.event_data
|
||||
new_id = event_data.media_dict.get("id")
|
||||
if event_data.convert_type == "themoviedb":
|
||||
mediainfo = await mediachain.async_recognize_media(tmdbid=new_id, mtype=mtype)
|
||||
elif event_data.convert_type == "douban":
|
||||
mediainfo = await mediachain.async_recognize_media(doubanid=new_id, mtype=mtype)
|
||||
elif title:
|
||||
# 使用名称识别兜底
|
||||
meta = MetaInfo(title)
|
||||
if year:
|
||||
meta.year = year
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
mediainfo = await mediachain.async_recognize_media(meta=meta)
|
||||
# 识别
|
||||
mediainfo = MediaChain().recognize_media(tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, mtype=mtype)
|
||||
if mediainfo:
|
||||
MediaChain().obtain_images(mediainfo)
|
||||
await mediachain.async_obtain_images(mediainfo)
|
||||
return mediainfo.to_dict()
|
||||
|
||||
return schemas.MediaInfo()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Any, List, Dict
|
||||
from typing import Any, List, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.chain.download import DownloadChain
|
||||
@@ -9,7 +9,7 @@ from app.chain.mediaserver import MediaServerChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db import get_async_db
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.db.models import MediaServerItem
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
@@ -43,13 +43,13 @@ def play_item(itemid: str, _: schemas.TokenPayload = Depends(verify_token)) -> s
|
||||
|
||||
|
||||
@router.get("/exists", summary="查询本地是否存在(数据库)", response_model=schemas.Response)
|
||||
def exists_local(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:
|
||||
async def exists_local(title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
mtype: Optional[str] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
season: Optional[int] = None,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
判断本地是否存在
|
||||
"""
|
||||
@@ -59,7 +59,7 @@ def exists_local(title: str = None,
|
||||
# 返回对象
|
||||
ret_info = {}
|
||||
# 本地数据库是否存在
|
||||
exist: MediaServerItem = MediaServerOper(db).exists(
|
||||
exist: MediaServerItem = await MediaServerOper(db).async_exists(
|
||||
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
|
||||
)
|
||||
if exist:
|
||||
@@ -121,7 +121,7 @@ def not_exists(media_in: schemas.MediaInfo,
|
||||
|
||||
|
||||
@router.get("/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem])
|
||||
def latest(server: str, count: int = 18,
|
||||
def latest(server: str, count: Optional[int] = 20,
|
||||
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
@@ -130,7 +130,7 @@ def latest(server: str, count: int = 18,
|
||||
|
||||
|
||||
@router.get("/playing", summary="正在播放条目", response_model=List[schemas.MediaServerPlayItem])
|
||||
def playing(server: str, count: int = 12,
|
||||
def playing(server: str, count: Optional[int] = 12,
|
||||
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取媒体服务器正在播放条目
|
||||
@@ -139,7 +139,7 @@ def playing(server: str, count: int = 12,
|
||||
|
||||
|
||||
@router.get("/library", summary="媒体库列表", response_model=List[schemas.MediaServerLibrary])
|
||||
def library(server: str, hidden: bool = False,
|
||||
def library(server: str, hidden: Optional[bool] = False,
|
||||
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取媒体服务器媒体库列表
|
||||
@@ -148,7 +148,7 @@ def library(server: str, hidden: bool = False,
|
||||
|
||||
|
||||
@router.get("/clients", summary="查询可用媒体服务器", response_model=List[dict])
|
||||
def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询可用媒体服务器
|
||||
"""
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import json
|
||||
from typing import Union, Any, List
|
||||
from typing import Union, Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from pywebpush import WebPushException, webpush
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
from app import schemas
|
||||
from app.chain.message import MessageChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.security import verify_token, verify_apitoken
|
||||
from app.db import get_db
|
||||
from app.db import get_async_db
|
||||
from app.db.models import User
|
||||
from app.db.models.message import Message
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
@@ -58,15 +58,15 @@ def web_message(text: str, current_user: User = Depends(get_current_active_super
|
||||
|
||||
|
||||
@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):
|
||||
async def get_web_message(_: schemas.TokenPayload = Depends(verify_token),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20):
|
||||
"""
|
||||
获取WEB消息列表
|
||||
"""
|
||||
ret_messages = []
|
||||
messages = Message.list_by_page(db, page=page, count=count)
|
||||
messages = await Message.async_list_by_page(db, page=page, count=count)
|
||||
for message in messages:
|
||||
try:
|
||||
ret_messages.append(message.to_dict())
|
||||
@@ -77,7 +77,7 @@ def get_web_message(_: schemas.TokenPayload = Depends(verify_token),
|
||||
|
||||
|
||||
def wechat_verify(echostr: str, msg_signature: str, timestamp: Union[str, int], nonce: str,
|
||||
source: str = None) -> Any:
|
||||
source: Optional[str] = None) -> Any:
|
||||
"""
|
||||
微信验证响应
|
||||
"""
|
||||
@@ -114,8 +114,8 @@ def vocechat_verify() -> Any:
|
||||
|
||||
|
||||
@router.get("/", summary="回调请求验证")
|
||||
def incoming_verify(token: str = None, echostr: str = None, msg_signature: str = None,
|
||||
timestamp: Union[str, int] = None, nonce: str = None, source: str = None,
|
||||
def incoming_verify(token: Optional[str] = None, echostr: Optional[str] = None, msg_signature: Optional[str] = None,
|
||||
timestamp: Union[str, int] = None, nonce: Optional[str] = None, source: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_apitoken)) -> Any:
|
||||
"""
|
||||
微信/VoceChat等验证响应
|
||||
@@ -128,7 +128,7 @@ def incoming_verify(token: str = None, echostr: str = None, msg_signature: str =
|
||||
|
||||
|
||||
@router.post("/webpush/subscribe", summary="客户端webpush通知订阅", response_model=schemas.Response)
|
||||
def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)):
|
||||
async def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
客户端webpush通知订阅
|
||||
"""
|
||||
|
||||
409
app/api/endpoints/monitoring.py
Normal file
409
app/api/endpoints/monitoring.py
Normal file
@@ -0,0 +1,409 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app import schemas
|
||||
from app.core.security import verify_apitoken
|
||||
from app.monitoring import monitor, get_metrics_response
|
||||
from app.schemas.monitoring import (
|
||||
PerformanceSnapshot,
|
||||
EndpointStats,
|
||||
ErrorRequest,
|
||||
MonitoringOverview
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/overview", summary="获取监控概览", response_model=schemas.MonitoringOverview)
|
||||
def get_overview(_: str = Depends(verify_apitoken)) -> Any:
|
||||
"""
|
||||
获取完整的监控概览信息
|
||||
"""
|
||||
# 获取性能快照
|
||||
performance = monitor.get_performance_snapshot()
|
||||
|
||||
# 获取最活跃端点
|
||||
top_endpoints = monitor.get_top_endpoints(limit=10)
|
||||
|
||||
# 获取最近错误
|
||||
recent_errors = monitor.get_recent_errors(limit=20)
|
||||
|
||||
# 检查告警
|
||||
alerts = monitor.check_alerts()
|
||||
|
||||
return MonitoringOverview(
|
||||
performance=PerformanceSnapshot(
|
||||
timestamp=performance.timestamp,
|
||||
cpu_usage=performance.cpu_usage,
|
||||
memory_usage=performance.memory_usage,
|
||||
active_requests=performance.active_requests,
|
||||
request_rate=performance.request_rate,
|
||||
avg_response_time=performance.avg_response_time,
|
||||
error_rate=performance.error_rate,
|
||||
slow_requests=performance.slow_requests
|
||||
),
|
||||
top_endpoints=[EndpointStats(**endpoint) for endpoint in top_endpoints],
|
||||
recent_errors=[ErrorRequest(**error) for error in recent_errors],
|
||||
alerts=alerts
|
||||
)
|
||||
|
||||
|
||||
@router.get("/performance", summary="获取性能快照", response_model=schemas.PerformanceSnapshot)
|
||||
def get_performance(_: str = Depends(verify_apitoken)) -> Any:
|
||||
"""
|
||||
获取当前性能快照
|
||||
"""
|
||||
snapshot = monitor.get_performance_snapshot()
|
||||
return PerformanceSnapshot(
|
||||
timestamp=snapshot.timestamp,
|
||||
cpu_usage=snapshot.cpu_usage,
|
||||
memory_usage=snapshot.memory_usage,
|
||||
active_requests=snapshot.active_requests,
|
||||
request_rate=snapshot.request_rate,
|
||||
avg_response_time=snapshot.avg_response_time,
|
||||
error_rate=snapshot.error_rate,
|
||||
slow_requests=snapshot.slow_requests
|
||||
)
|
||||
|
||||
|
||||
@router.get("/endpoints", summary="获取端点统计", response_model=List[schemas.EndpointStats])
|
||||
def get_endpoints(
|
||||
limit: int = Query(10, ge=1, le=50, description="返回的端点数量"),
|
||||
_: str = Depends(verify_apitoken)
|
||||
) -> Any:
|
||||
"""
|
||||
获取最活跃的API端点统计
|
||||
"""
|
||||
endpoints = monitor.get_top_endpoints(limit=limit)
|
||||
return [EndpointStats(**endpoint) for endpoint in endpoints]
|
||||
|
||||
|
||||
@router.get("/errors", summary="获取错误请求", response_model=List[schemas.ErrorRequest])
|
||||
def get_errors(
|
||||
limit: int = Query(20, ge=1, le=100, description="返回的错误数量"),
|
||||
_: str = Depends(verify_apitoken)
|
||||
) -> Any:
|
||||
"""
|
||||
获取最近的错误请求记录
|
||||
"""
|
||||
errors = monitor.get_recent_errors(limit=limit)
|
||||
return [ErrorRequest(**error) for error in errors]
|
||||
|
||||
|
||||
@router.get("/alerts", summary="获取告警信息", response_model=List[str])
|
||||
def get_alerts(_: str = Depends(verify_apitoken)) -> Any:
|
||||
"""
|
||||
获取当前告警信息
|
||||
"""
|
||||
return monitor.check_alerts()
|
||||
|
||||
|
||||
@router.get("/metrics", summary="Prometheus指标")
|
||||
def get_prometheus_metrics(_: str = Depends(verify_apitoken)) -> Any:
|
||||
"""
|
||||
获取Prometheus格式的监控指标
|
||||
"""
|
||||
return get_metrics_response()
|
||||
|
||||
|
||||
@router.get("/dashboard", summary="监控仪表板", response_class=HTMLResponse)
|
||||
def get_dashboard(_: str = Depends(verify_apitoken)) -> Any:
|
||||
"""
|
||||
获取实时监控仪表板HTML页面
|
||||
"""
|
||||
return HTMLResponse(content="""
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MoviePilot 性能监控仪表板</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
}
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.metric-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #2196F3;
|
||||
}
|
||||
.metric-label {
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.chart-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.alerts {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.alert-item {
|
||||
color: #856404;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.refresh-btn {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.refresh-btn:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎬 MoviePilot 性能监控仪表板</h1>
|
||||
<button class="refresh-btn" onclick="refreshData()">刷新数据</button>
|
||||
</div>
|
||||
|
||||
<div id="alerts" class="alerts" style="display: none;">
|
||||
<h3>⚠️ 告警信息</h3>
|
||||
<div id="alerts-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="cpu-usage">--</div>
|
||||
<div class="metric-label">CPU使用率 (%)</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="memory-usage">--</div>
|
||||
<div class="metric-label">内存使用率 (%)</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="active-requests">--</div>
|
||||
<div class="metric-label">活跃请求数</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="request-rate">--</div>
|
||||
<div class="metric-label">请求率 (req/min)</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="avg-response-time">--</div>
|
||||
<div class="metric-label">平均响应时间 (s)</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="error-rate">--</div>
|
||||
<div class="metric-label">错误率 (%)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h3>📊 性能趋势</h3>
|
||||
<canvas id="performanceChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h3>🔥 最活跃端点</h3>
|
||||
<canvas id="endpointsChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let performanceChart, endpointsChart;
|
||||
let performanceData = {
|
||||
labels: [],
|
||||
cpu: [],
|
||||
memory: [],
|
||||
requests: []
|
||||
};
|
||||
|
||||
// 初始化图表
|
||||
function initCharts() {
|
||||
const ctx1 = document.getElementById('performanceChart').getContext('2d');
|
||||
performanceChart = new Chart(ctx1, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: performanceData.labels,
|
||||
datasets: [{
|
||||
label: 'CPU使用率 (%)',
|
||||
data: performanceData.cpu,
|
||||
borderColor: '#2196F3',
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||
tension: 0.4
|
||||
}, {
|
||||
label: '内存使用率 (%)',
|
||||
data: performanceData.memory,
|
||||
borderColor: '#4CAF50',
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||
tension: 0.4
|
||||
}, {
|
||||
label: '活跃请求数',
|
||||
data: performanceData.requests,
|
||||
borderColor: '#FF9800',
|
||||
backgroundColor: 'rgba(255, 152, 0, 0.1)',
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const ctx2 = document.getElementById('endpointsChart').getContext('2d');
|
||||
endpointsChart = new Chart(ctx2, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: '请求数',
|
||||
data: [],
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.8)'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 更新性能数据
|
||||
function updatePerformanceData(data) {
|
||||
const now = new Date().toLocaleTimeString();
|
||||
|
||||
performanceData.labels.push(now);
|
||||
performanceData.cpu.push(data.performance.cpu_usage);
|
||||
performanceData.memory.push(data.performance.memory_usage);
|
||||
performanceData.requests.push(data.performance.active_requests);
|
||||
|
||||
// 保持最近20个数据点
|
||||
if (performanceData.labels.length > 20) {
|
||||
performanceData.labels.shift();
|
||||
performanceData.cpu.shift();
|
||||
performanceData.memory.shift();
|
||||
performanceData.requests.shift();
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
performanceChart.data.labels = performanceData.labels;
|
||||
performanceChart.data.datasets[0].data = performanceData.cpu;
|
||||
performanceChart.data.datasets[1].data = performanceData.memory;
|
||||
performanceChart.data.datasets[2].data = performanceData.requests;
|
||||
performanceChart.update();
|
||||
|
||||
// 更新端点图表
|
||||
const endpointLabels = data.top_endpoints.map(e => e.endpoint.substring(0, 20));
|
||||
const endpointData = data.top_endpoints.map(e => e.count);
|
||||
|
||||
endpointsChart.data.labels = endpointLabels;
|
||||
endpointsChart.data.datasets[0].data = endpointData;
|
||||
endpointsChart.update();
|
||||
}
|
||||
|
||||
// 更新指标显示
|
||||
function updateMetrics(data) {
|
||||
document.getElementById('cpu-usage').textContent = data.performance.cpu_usage.toFixed(1);
|
||||
document.getElementById('memory-usage').textContent = data.performance.memory_usage.toFixed(1);
|
||||
document.getElementById('active-requests').textContent = data.performance.active_requests;
|
||||
document.getElementById('request-rate').textContent = data.performance.request_rate.toFixed(0);
|
||||
document.getElementById('avg-response-time').textContent = data.performance.avg_response_time.toFixed(3);
|
||||
document.getElementById('error-rate').textContent = (data.performance.error_rate * 100).toFixed(2);
|
||||
}
|
||||
|
||||
// 更新告警
|
||||
function updateAlerts(alerts) {
|
||||
const alertsDiv = document.getElementById('alerts');
|
||||
const alertsList = document.getElementById('alerts-list');
|
||||
|
||||
if (alerts.length > 0) {
|
||||
alertsDiv.style.display = 'block';
|
||||
alertsList.innerHTML = alerts.map(alert =>
|
||||
`<div class="alert-item">⚠️ ${alert}</div>`
|
||||
).join('');
|
||||
} else {
|
||||
alertsDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取URL中的token参数
|
||||
function getTokenFromUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('token');
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
async function refreshData() {
|
||||
try {
|
||||
const token = getTokenFromUrl();
|
||||
if (!token) {
|
||||
console.error('未找到token参数');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/monitoring/overview?token=${token}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
updateMetrics(data);
|
||||
updatePerformanceData(data);
|
||||
updateAlerts(data.alerts);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取监控数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initCharts();
|
||||
refreshData();
|
||||
|
||||
// 每5秒自动刷新
|
||||
setInterval(refreshData, 5000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
@@ -1,22 +1,30 @@
|
||||
import mimetypes
|
||||
import shutil
|
||||
from typing import Annotated, Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
import aiofiles
|
||||
from anyio import Path as AsyncPath
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from starlette import status
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
from app.command import Command
|
||||
from app.core.config import settings
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_apikey, verify_token
|
||||
from app.core.security import verify_apikey, verify_token, verify_apitoken
|
||||
from app.db.models import User
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async
|
||||
from app.factory import app
|
||||
from app.helper.plugin import PluginHelper
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.plugin import PluginMemoryInfo
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
|
||||
|
||||
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
||||
|
||||
router = APIRouter()
|
||||
@@ -66,9 +74,13 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
|
||||
try:
|
||||
api["path"] = api_path
|
||||
allow_anonymous = api.pop("allow_anonymous", False)
|
||||
auth_mode = api.pop("auth", "apikey")
|
||||
dependencies = api.setdefault("dependencies", [])
|
||||
if not allow_anonymous and Depends(verify_apikey) not in dependencies:
|
||||
dependencies.append(Depends(verify_apikey))
|
||||
if not allow_anonymous:
|
||||
if auth_mode == "bear" and Depends(verify_token) not in dependencies:
|
||||
dependencies.append(Depends(verify_token))
|
||||
elif Depends(verify_apikey) not in dependencies:
|
||||
dependencies.append(Depends(verify_apikey))
|
||||
app.add_api_route(**api, tags=["plugin"])
|
||||
is_modified = True
|
||||
logger.debug(f"Added plugin route: {api_path}")
|
||||
@@ -116,23 +128,36 @@ def _clean_protected_routes(existing_paths: dict):
|
||||
logger.error(f"Error removing protected route {protected_route}: {str(e)}")
|
||||
|
||||
|
||||
def register_plugin(plugin_id: str):
|
||||
"""
|
||||
注册一个插件相关的服务
|
||||
"""
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
|
||||
|
||||
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
||||
def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||
state: str = "all") -> List[schemas.Plugin]:
|
||||
async def all_plugins(_: User = Depends(get_current_active_superuser_async),
|
||||
state: Optional[str] = "all", force: bool = False) -> List[schemas.Plugin]:
|
||||
"""
|
||||
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
||||
"""
|
||||
# 本地插件
|
||||
local_plugins = PluginManager().get_local_plugins()
|
||||
plugin_manager = PluginManager()
|
||||
local_plugins = plugin_manager.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
|
||||
|
||||
# 未安装的本地插件
|
||||
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
|
||||
# 在线插件
|
||||
online_plugins = PluginManager().get_online_plugins()
|
||||
online_plugins = await plugin_manager.async_get_online_plugins(force)
|
||||
if not online_plugins:
|
||||
# 没有获取在线插件
|
||||
if state == "market":
|
||||
@@ -159,12 +184,13 @@ def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||
if state == "market":
|
||||
# 返回未安装的插件
|
||||
return market_plugins
|
||||
|
||||
# 返回所有插件
|
||||
return installed_plugins + market_plugins
|
||||
|
||||
|
||||
@router.get("/installed", summary="已安装插件", response_model=List[str])
|
||||
def installed(_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
async def installed(_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
查询用户已安装插件清单
|
||||
"""
|
||||
@@ -172,30 +198,43 @@ def installed(_: schemas.TokenPayload = Depends(get_current_active_superuser)) -
|
||||
|
||||
|
||||
@router.get("/statistic", summary="插件安装统计", response_model=dict)
|
||||
def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
插件安装统计
|
||||
"""
|
||||
return PluginHelper().get_statistic()
|
||||
return await PluginHelper().async_get_statistic()
|
||||
|
||||
|
||||
@router.get("/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response)
|
||||
def reload_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
重新加载插件
|
||||
"""
|
||||
# 重新加载插件
|
||||
PluginManager().reload_plugin(plugin_id)
|
||||
# 注册插件服务
|
||||
register_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
||||
def install(plugin_id: str,
|
||||
repo_url: str = "",
|
||||
force: bool = False,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
async def install(plugin_id: str,
|
||||
repo_url: Optional[str] = "",
|
||||
force: Optional[bool] = False,
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
安装插件
|
||||
"""
|
||||
# 已安装插件
|
||||
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计
|
||||
plugin_helper = PluginHelper()
|
||||
if not force and plugin_id in PluginManager().get_plugin_ids():
|
||||
PluginHelper().install_reg(pid=plugin_id)
|
||||
await plugin_helper.async_install_reg(pid=plugin_id)
|
||||
else:
|
||||
# 插件不存在或需要强制安装,下载安装并注册插件
|
||||
if repo_url:
|
||||
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
|
||||
state, msg = await plugin_helper.async_install(pid=plugin_id, repo_url=repo_url)
|
||||
# 安装失败则直接响应
|
||||
if not state:
|
||||
return schemas.Response(success=False, message=msg)
|
||||
@@ -206,37 +245,67 @@ def install(plugin_id: str,
|
||||
if plugin_id not in install_plugins:
|
||||
install_plugins.append(plugin_id)
|
||||
# 保存设置
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 加载插件到内存
|
||||
PluginManager().reload_plugin(plugin_id)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
await SystemConfigOper().async_set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 重新加载插件
|
||||
await run_in_threadpool(reload_plugin, plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[dict])
|
||||
async def remotes(token: str) -> Any:
|
||||
"""
|
||||
获取插件联邦组件列表
|
||||
"""
|
||||
if token != "moviepilot":
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
return PluginManager().get_plugin_remotes()
|
||||
|
||||
|
||||
@router.get("/form/{plugin_id}", summary="获取插件表单页面")
|
||||
def plugin_form(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
_: User = Depends(get_current_active_superuser)) -> dict:
|
||||
"""
|
||||
根据插件ID获取插件配置表单
|
||||
根据插件ID获取插件配置表单或Vue组件URL
|
||||
"""
|
||||
conf, model = PluginManager().get_plugin_form(plugin_id)
|
||||
return {
|
||||
"conf": conf,
|
||||
"model": model
|
||||
}
|
||||
plugin_manager = PluginManager()
|
||||
plugin_instance = plugin_manager.running_plugins.get(plugin_id)
|
||||
if not plugin_instance:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载")
|
||||
|
||||
# 渲染模式
|
||||
render_mode, _ = plugin_instance.get_render_mode()
|
||||
try:
|
||||
conf, model = plugin_instance.get_form()
|
||||
return {
|
||||
"render_mode": render_mode,
|
||||
"conf": conf,
|
||||
"model": plugin_manager.get_plugin_config(plugin_id) or model
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}")
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
||||
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> List[dict]:
|
||||
def plugin_page(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> dict:
|
||||
"""
|
||||
根据插件ID获取插件数据页面
|
||||
"""
|
||||
return PluginManager().get_plugin_page(plugin_id)
|
||||
plugin_instance = PluginManager().running_plugins.get(plugin_id)
|
||||
if not plugin_instance:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载")
|
||||
|
||||
# 渲染模式
|
||||
render_mode, _ = plugin_instance.get_render_mode()
|
||||
try:
|
||||
page = plugin_instance.get_page()
|
||||
return {
|
||||
"render_mode": render_mode,
|
||||
"page": page or []
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"插件 {plugin_id} 调用方法 get_page 出错: {str(e)}")
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
|
||||
@@ -247,48 +316,238 @@ def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> Li
|
||||
return PluginManager().get_plugin_dashboard_meta()
|
||||
|
||||
|
||||
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
|
||||
def plugin_dashboard_by_key(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Optional[schemas.PluginDashboard]:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
"""
|
||||
return PluginManager().get_plugin_dashboard(plugin_id, key, user_agent)
|
||||
|
||||
|
||||
@router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置")
|
||||
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
"""
|
||||
return PluginManager().get_plugin_dashboard(plugin_id, user_agent=user_agent)
|
||||
|
||||
|
||||
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
|
||||
def plugin_dashboard(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
"""
|
||||
return PluginManager().get_plugin_dashboard(plugin_id, key=key, user_agent=user_agent)
|
||||
return plugin_dashboard_by_key(plugin_id, "", user_agent)
|
||||
|
||||
|
||||
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
|
||||
def reset_plugin(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
根据插件ID重置插件配置及数据
|
||||
"""
|
||||
plugin_manager = PluginManager()
|
||||
# 删除配置
|
||||
PluginManager().delete_plugin_config(plugin_id)
|
||||
plugin_manager.delete_plugin_config(plugin_id)
|
||||
# 删除插件所有数据
|
||||
PluginManager().delete_plugin_data(plugin_id)
|
||||
# 重新生效插件
|
||||
PluginManager().reload_plugin(plugin_id)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
plugin_manager.delete_plugin_data(plugin_id)
|
||||
# 重新加载插件
|
||||
reload_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件")
|
||||
async def plugin_static_file(plugin_id: str, filepath: str):
|
||||
"""
|
||||
获取插件静态文件
|
||||
"""
|
||||
# 基础安全检查
|
||||
if ".." in filepath or ".." in plugin_id:
|
||||
logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
plugin_base_dir = AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower()
|
||||
plugin_file_path = plugin_base_dir / filepath
|
||||
if not await plugin_file_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在")
|
||||
if not await plugin_file_path.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件")
|
||||
|
||||
# 判断 MIME 类型
|
||||
response_type, _ = mimetypes.guess_type(str(plugin_file_path))
|
||||
suffix = plugin_file_path.suffix.lower()
|
||||
# 强制修正 .mjs 和 .js 的 MIME 类型
|
||||
if suffix in ['.js', '.mjs']:
|
||||
response_type = 'application/javascript'
|
||||
elif suffix == '.css' and not response_type: # 如果 guess_type 没猜对 css,也修正
|
||||
response_type = 'text/css'
|
||||
elif not response_type: # 对于其他猜不出的类型
|
||||
response_type = 'application/octet-stream'
|
||||
|
||||
try:
|
||||
# 异步生成器函数,用于流式读取文件
|
||||
async def file_generator():
|
||||
async with aiofiles.open(plugin_file_path, mode='rb') as file:
|
||||
# 8KB 块大小
|
||||
while chunk := await file.read(8192):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
file_generator(),
|
||||
media_type=response_type,
|
||||
headers={"Content-Disposition": f"inline; filename={plugin_file_path.name}"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating/sending StreamingResponse for {plugin_file_path}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal Server Error")
|
||||
|
||||
|
||||
@router.get("/folders", summary="获取插件文件夹配置", response_model=dict)
|
||||
async def get_plugin_folders(_: User = Depends(get_current_active_superuser_async)) -> dict:
|
||||
"""
|
||||
获取插件文件夹分组配置
|
||||
"""
|
||||
try:
|
||||
result = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"[文件夹API] 获取文件夹配置失败: {str(e)}")
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response)
|
||||
async def save_plugin_folders(folders: dict, _: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
保存插件文件夹分组配置
|
||||
"""
|
||||
try:
|
||||
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
|
||||
return schemas.Response(success=True)
|
||||
except Exception as e:
|
||||
logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}")
|
||||
return schemas.Response(success=False, message=str(e))
|
||||
|
||||
|
||||
@router.post("/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response)
|
||||
async def create_plugin_folder(folder_name: str,
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
创建新的插件文件夹
|
||||
"""
|
||||
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
|
||||
if folder_name not in folders:
|
||||
folders[folder_name] = []
|
||||
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
|
||||
return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 创建成功")
|
||||
else:
|
||||
return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 已存在")
|
||||
|
||||
|
||||
@router.delete("/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response)
|
||||
async def delete_plugin_folder(folder_name: str,
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
删除插件文件夹
|
||||
"""
|
||||
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
|
||||
if folder_name in folders:
|
||||
del folders[folder_name]
|
||||
await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders)
|
||||
return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 删除成功")
|
||||
else:
|
||||
return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 不存在")
|
||||
|
||||
|
||||
@router.put("/folders/{folder_name}/plugins", summary="更新文件夹中的插件", response_model=schemas.Response)
|
||||
async def update_folder_plugins(folder_name: str, plugin_ids: List[str],
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
更新指定文件夹中的插件列表
|
||||
"""
|
||||
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
|
||||
folders[folder_name] = plugin_ids
|
||||
await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders)
|
||||
return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 中的插件已更新")
|
||||
|
||||
|
||||
@router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response)
|
||||
def clone_plugin(plugin_id: str,
|
||||
clone_data: dict,
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
创建插件分身
|
||||
"""
|
||||
try:
|
||||
success, message = PluginManager().clone_plugin(
|
||||
plugin_id=plugin_id,
|
||||
suffix=clone_data.get("suffix", ""),
|
||||
name=clone_data.get("name", ""),
|
||||
description=clone_data.get("description", ""),
|
||||
version=clone_data.get("version", ""),
|
||||
icon=clone_data.get("icon", "")
|
||||
)
|
||||
|
||||
if success:
|
||||
# 注册插件服务
|
||||
reload_plugin(message)
|
||||
# 将分身插件添加到原插件所在的文件夹中
|
||||
_add_clone_to_plugin_folder(plugin_id, message)
|
||||
return schemas.Response(success=True, message="插件分身创建成功")
|
||||
else:
|
||||
return schemas.Response(success=False, message=message)
|
||||
except Exception as e:
|
||||
logger.error(f"创建插件分身失败:{str(e)}")
|
||||
return schemas.Response(success=False, message=f"创建插件分身失败:{str(e)}")
|
||||
|
||||
|
||||
@router.get("/memory", summary="插件内存使用统计", response_model=List[PluginMemoryInfo])
|
||||
def plugin_memory_stats(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
获取所有插件的内存使用统计信息
|
||||
"""
|
||||
try:
|
||||
plugin_manager = PluginManager()
|
||||
memory_stats = plugin_manager.get_plugin_memory_stats()
|
||||
return memory_stats
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件内存统计失败:{str(e)}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取插件内存统计失败:{str(e)}")
|
||||
|
||||
|
||||
@router.get("/memory/{plugin_id}", summary="单个插件内存使用统计", response_model=PluginMemoryInfo)
|
||||
def plugin_memory_stat(plugin_id: str, _: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
获取指定插件的内存使用统计信息
|
||||
"""
|
||||
try:
|
||||
plugin_manager = PluginManager()
|
||||
memory_stats = plugin_manager.get_plugin_memory_stats(plugin_id)
|
||||
if not memory_stats:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"插件 {plugin_id} 不存在或未运行")
|
||||
return memory_stats[0]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件 {plugin_id} 内存统计失败:{str(e)}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取插件内存统计失败:{str(e)}")
|
||||
|
||||
|
||||
@router.delete("/memory/cache", summary="清除插件内存统计缓存")
|
||||
def clear_plugin_memory_cache(_: Annotated[str, Depends(verify_apitoken)],
|
||||
plugin_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
清除插件内存统计缓存
|
||||
"""
|
||||
try:
|
||||
plugin_manager = PluginManager()
|
||||
plugin_manager.clear_plugin_memory_cache(plugin_id)
|
||||
message = f"已清除插件 {plugin_id} 的内存统计缓存" if plugin_id else "已清除所有插件的内存统计缓存"
|
||||
return schemas.Response(success=True, message=message)
|
||||
except Exception as e:
|
||||
logger.error(f"清除插件内存统计缓存失败:{str(e)}")
|
||||
return schemas.Response(success=False, message=f"清除缓存失败:{str(e)}")
|
||||
|
||||
|
||||
@router.get("/{plugin_id}", summary="获取插件配置")
|
||||
def plugin_config(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
async def plugin_config(plugin_id: str,
|
||||
_: User = Depends(get_current_active_superuser_async)) -> dict:
|
||||
"""
|
||||
根据插件ID获取插件配置信息
|
||||
"""
|
||||
@@ -297,45 +556,143 @@ def plugin_config(plugin_id: str,
|
||||
|
||||
@router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response)
|
||||
def set_plugin_config(plugin_id: str, conf: dict,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
更新插件配置
|
||||
"""
|
||||
plugin_manager = PluginManager()
|
||||
# 保存配置
|
||||
PluginManager().save_plugin_config(plugin_id, conf)
|
||||
plugin_manager.save_plugin_config(plugin_id, conf)
|
||||
# 重新生效插件
|
||||
PluginManager().init_plugin(plugin_id, conf)
|
||||
plugin_manager.init_plugin(plugin_id, conf)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
register_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response)
|
||||
def uninstall_plugin(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
卸载插件
|
||||
"""
|
||||
config_oper = SystemConfigOper()
|
||||
# 删除已安装信息
|
||||
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
for plugin in install_plugins:
|
||||
if plugin == plugin_id:
|
||||
install_plugins.remove(plugin)
|
||||
break
|
||||
# 保存
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
config_oper.set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 移除插件API
|
||||
remove_plugin_api(plugin_id)
|
||||
# 移除插件服务
|
||||
Scheduler().remove_plugin_job(plugin_id)
|
||||
# 判断是否为分身
|
||||
plugin_manager = PluginManager()
|
||||
plugin_class = plugin_manager.plugins.get(plugin_id)
|
||||
if getattr(plugin_class, "is_clone", False):
|
||||
# 如果是分身插件,则删除分身数据和配置
|
||||
plugin_manager.delete_plugin_config(plugin_id)
|
||||
plugin_manager.delete_plugin_data(plugin_id)
|
||||
# 删除分身文件
|
||||
plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
|
||||
if plugin_base_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(plugin_base_dir)
|
||||
plugin_manager.plugins.pop(plugin_id, None)
|
||||
except Exception as e:
|
||||
logger.error(f"删除插件分身目录 {plugin_base_dir} 失败: {str(e)}")
|
||||
# 从插件文件夹中移除该插件
|
||||
_remove_plugin_from_folders(plugin_id)
|
||||
# 移除插件
|
||||
PluginManager().remove_plugin(plugin_id)
|
||||
plugin_manager.remove_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
# 注册全部插件API
|
||||
register_plugin_api()
|
||||
def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str):
|
||||
"""
|
||||
将分身插件添加到原插件所在的文件夹中
|
||||
:param original_plugin_id: 原插件ID
|
||||
:param clone_plugin_id: 分身插件ID
|
||||
"""
|
||||
try:
|
||||
config_oper = SystemConfigOper()
|
||||
# 获取插件文件夹配置
|
||||
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
|
||||
|
||||
# 查找原插件所在的文件夹
|
||||
target_folder = None
|
||||
for folder_name, folder_data in folders.items():
|
||||
if isinstance(folder_data, dict) and 'plugins' in folder_data:
|
||||
# 新格式:{"plugins": [...], "order": ..., "icon": ...}
|
||||
if original_plugin_id in folder_data['plugins']:
|
||||
target_folder = folder_name
|
||||
break
|
||||
elif isinstance(folder_data, list):
|
||||
# 旧格式:直接是插件列表
|
||||
if original_plugin_id in folder_data:
|
||||
target_folder = folder_name
|
||||
break
|
||||
|
||||
# 如果找到了原插件所在的文件夹,则将分身插件也添加到该文件夹中
|
||||
if target_folder:
|
||||
folder_data = folders[target_folder]
|
||||
if isinstance(folder_data, dict) and 'plugins' in folder_data:
|
||||
# 新格式
|
||||
if clone_plugin_id not in folder_data['plugins']:
|
||||
folder_data['plugins'].append(clone_plugin_id)
|
||||
logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中")
|
||||
elif isinstance(folder_data, list):
|
||||
# 旧格式
|
||||
if clone_plugin_id not in folder_data:
|
||||
folder_data.append(clone_plugin_id)
|
||||
logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中")
|
||||
|
||||
# 保存更新后的文件夹配置
|
||||
config_oper.set(SystemConfigKey.PluginFolders, folders)
|
||||
else:
|
||||
logger.info(f"原插件 {original_plugin_id} 不在任何文件夹中,分身插件 {clone_plugin_id} 将保持独立")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理插件文件夹时出错:{str(e)}")
|
||||
# 文件夹处理失败不影响插件分身创建的整体流程
|
||||
|
||||
|
||||
def _remove_plugin_from_folders(plugin_id: str):
|
||||
"""
|
||||
从所有文件夹中移除指定的插件
|
||||
:param plugin_id: 要移除的插件ID
|
||||
"""
|
||||
try:
|
||||
config_oper = SystemConfigOper()
|
||||
# 获取插件文件夹配置
|
||||
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
|
||||
|
||||
# 标记是否有修改
|
||||
modified = False
|
||||
|
||||
# 遍历所有文件夹,移除指定插件
|
||||
for folder_name, folder_data in folders.items():
|
||||
if isinstance(folder_data, dict) and 'plugins' in folder_data:
|
||||
# 新格式:{"plugins": [...], "order": ..., "icon": ...}
|
||||
if plugin_id in folder_data['plugins']:
|
||||
folder_data['plugins'].remove(plugin_id)
|
||||
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
|
||||
modified = True
|
||||
elif isinstance(folder_data, list):
|
||||
# 旧格式:直接是插件列表
|
||||
if plugin_id in folder_data:
|
||||
folder_data.remove(plugin_id)
|
||||
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
|
||||
modified = True
|
||||
|
||||
# 如果有修改,保存更新后的文件夹配置
|
||||
if modified:
|
||||
config_oper.set(SystemConfigKey.PluginFolders, folders)
|
||||
else:
|
||||
logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"从文件夹中移除插件时出错:{str(e)}")
|
||||
# 文件夹处理失败不影响插件卸载的整体流程
|
||||
|
||||
191
app/api/endpoints/recommend.py
Normal file
191
app/api/endpoints/recommend.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.schemas import RecommendSourceEventData
|
||||
from app.schemas.types import ChainEventType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/source", summary="获取推荐数据源", response_model=List[schemas.RecommendMediaSource])
|
||||
def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取推荐数据源
|
||||
"""
|
||||
# 广播事件,请示额外的推荐数据源支持
|
||||
event_data = RecommendSourceEventData()
|
||||
event = eventmanager.send_event(ChainEventType.RecommendSource, event_data)
|
||||
# 使用事件返回的上下文数据
|
||||
if event and event.event_data:
|
||||
event_data: RecommendSourceEventData = event.event_data
|
||||
if event_data.extra_sources:
|
||||
return event_data.extra_sources
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/bangumi_calendar", summary="Bangumi每日放送", response_model=List[schemas.MediaInfo])
|
||||
async def bangumi_calendar(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览Bangumi每日放送
|
||||
"""
|
||||
return await RecommendChain().async_bangumi_calendar(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
|
||||
async def douban_showing(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣正在热映
|
||||
"""
|
||||
return await RecommendChain().async_douban_movie_showing(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
|
||||
async def douban_movies(sort: Optional[str] = "R",
|
||||
tags: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣电影信息
|
||||
"""
|
||||
return await RecommendChain().async_douban_movies(sort=sort, tags=tags, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
|
||||
async def douban_tvs(sort: Optional[str] = "R",
|
||||
tags: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
return await RecommendChain().async_douban_tvs(sort=sort, tags=tags, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
||||
async def douban_movie_top250(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
return await RecommendChain().async_douban_movie_top250(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
async def douban_tv_weekly_chinese(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
中国每周剧集口碑榜
|
||||
"""
|
||||
return await RecommendChain().async_douban_tv_weekly_chinese(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
async def douban_tv_weekly_global(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
全球每周剧集口碑榜
|
||||
"""
|
||||
return await RecommendChain().async_douban_tv_weekly_global(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
|
||||
async def douban_tv_animation(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门动画剧集
|
||||
"""
|
||||
return await RecommendChain().async_douban_tv_animation(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
|
||||
async def douban_movie_hot(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门电影
|
||||
"""
|
||||
return await RecommendChain().async_douban_movie_hot(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
|
||||
async def douban_tv_hot(page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门电视剧
|
||||
"""
|
||||
return await RecommendChain().async_douban_tv_hot(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tmdb_movies", summary="TMDB电影", response_model=List[schemas.MediaInfo])
|
||||
async def tmdb_movies(sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB电影信息
|
||||
"""
|
||||
return await RecommendChain().async_tmdb_movies(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
|
||||
|
||||
@router.get("/tmdb_tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
|
||||
async def tmdb_tvs(sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
return await RecommendChain().async_tmdb_tvs(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
|
||||
|
||||
@router.get("/tmdb_trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
|
||||
async def tmdb_trending(page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
TMDB流行趋势
|
||||
"""
|
||||
return await RecommendChain().async_tmdb_trending(page=page)
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import List, Any
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
@@ -6,81 +6,145 @@ from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas import MediaRecognizeConvertEventData
|
||||
from app.schemas.types import MediaType, ChainEventType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
|
||||
def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询搜索结果
|
||||
"""
|
||||
torrents = SearchChain().last_search_results()
|
||||
torrents = await SearchChain().async_last_search_results()
|
||||
return [torrent.to_dict() for torrent in torrents]
|
||||
|
||||
|
||||
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=schemas.Response)
|
||||
def search_by_id(mediaid: str,
|
||||
mtype: str = None,
|
||||
area: str = "title",
|
||||
season: str = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def search_by_id(mediaid: str,
|
||||
mtype: Optional[str] = None,
|
||||
area: Optional[str] = "title",
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
sites: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:
|
||||
"""
|
||||
if mtype:
|
||||
mtype = MediaType(mtype)
|
||||
media_type = MediaType(mtype)
|
||||
else:
|
||||
media_type = None
|
||||
if season:
|
||||
season = int(season)
|
||||
media_season = int(season)
|
||||
else:
|
||||
media_season = None
|
||||
if sites:
|
||||
site_list = [int(site) for site in sites.split(",") if site]
|
||||
else:
|
||||
site_list = None
|
||||
torrents = None
|
||||
media_chain = MediaChain()
|
||||
search_chain = SearchChain()
|
||||
# 根据前缀识别媒体ID
|
||||
if mediaid.startswith("tmdb:"):
|
||||
tmdbid = int(mediaid.replace("tmdb:", ""))
|
||||
if settings.RECOGNIZE_SOURCE == "douban":
|
||||
# 通过TMDBID识别豆瓣ID
|
||||
doubaninfo = MediaChain().get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=mtype)
|
||||
doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=media_type)
|
||||
if doubaninfo:
|
||||
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
|
||||
mtype=mtype, area=area, season=season)
|
||||
torrents = await search_chain.async_search_by_id(doubanid=doubaninfo.get("id"),
|
||||
mtype=media_type, area=area, season=media_season,
|
||||
sites=site_list, cache_local=True)
|
||||
else:
|
||||
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
|
||||
else:
|
||||
torrents = SearchChain().search_by_id(tmdbid=tmdbid, mtype=mtype, area=area, season=season)
|
||||
torrents = await search_chain.async_search_by_id(tmdbid=tmdbid, mtype=media_type, area=area,
|
||||
season=media_season,
|
||||
sites=site_list, cache_local=True)
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid.replace("douban:", "")
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
# 通过豆瓣ID识别TMDBID
|
||||
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
|
||||
tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=media_type)
|
||||
if tmdbinfo:
|
||||
if tmdbinfo.get('season') and not season:
|
||||
season = tmdbinfo.get('season')
|
||||
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
|
||||
mtype=mtype, area=area, season=season)
|
||||
if tmdbinfo.get('season') and not media_season:
|
||||
media_season = tmdbinfo.get('season')
|
||||
torrents = await search_chain.async_search_by_id(tmdbid=tmdbinfo.get("id"),
|
||||
mtype=media_type, area=area, season=media_season,
|
||||
sites=site_list, cache_local=True)
|
||||
else:
|
||||
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
|
||||
else:
|
||||
torrents = SearchChain().search_by_id(doubanid=doubanid, mtype=mtype, area=area, season=season)
|
||||
torrents = await search_chain.async_search_by_id(doubanid=doubanid, mtype=media_type, area=area,
|
||||
season=media_season,
|
||||
sites=site_list, cache_local=True)
|
||||
elif mediaid.startswith("bangumi:"):
|
||||
bangumiid = int(mediaid.replace("bangumi:", ""))
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
# 通过BangumiID识别TMDBID
|
||||
tmdbinfo = MediaChain().get_tmdbinfo_by_bangumiid(bangumiid=bangumiid)
|
||||
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(bangumiid=bangumiid)
|
||||
if tmdbinfo:
|
||||
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
|
||||
mtype=mtype, area=area, season=season)
|
||||
torrents = await search_chain.async_search_by_id(tmdbid=tmdbinfo.get("id"),
|
||||
mtype=media_type, area=area, season=media_season,
|
||||
sites=site_list, cache_local=True)
|
||||
else:
|
||||
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
|
||||
else:
|
||||
# 通过BangumiID识别豆瓣ID
|
||||
doubaninfo = MediaChain().get_doubaninfo_by_bangumiid(bangumiid=bangumiid)
|
||||
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(bangumiid=bangumiid)
|
||||
if doubaninfo:
|
||||
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
|
||||
mtype=mtype, area=area, season=season)
|
||||
torrents = await search_chain.async_search_by_id(doubanid=doubaninfo.get("id"),
|
||||
mtype=media_type, area=area, season=media_season,
|
||||
sites=site_list, cache_local=True)
|
||||
else:
|
||||
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
|
||||
else:
|
||||
return schemas.Response(success=False, message="未知的媒体ID")
|
||||
|
||||
# 未知前缀,广播事件解析媒体信息
|
||||
event_data = MediaRecognizeConvertEventData(
|
||||
mediaid=mediaid,
|
||||
convert_type=settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data)
|
||||
# 使用事件返回的上下文数据
|
||||
if event and event.event_data:
|
||||
event_data: MediaRecognizeConvertEventData = event.event_data
|
||||
if event_data.media_dict:
|
||||
search_id = event_data.media_dict.get("id")
|
||||
if event_data.convert_type == "themoviedb":
|
||||
torrents = await search_chain.async_search_by_id(tmdbid=search_id, mtype=media_type, area=area,
|
||||
season=media_season, cache_local=True)
|
||||
elif event_data.convert_type == "douban":
|
||||
torrents = await search_chain.async_search_by_id(doubanid=search_id, mtype=media_type, area=area,
|
||||
season=media_season, cache_local=True)
|
||||
else:
|
||||
if not title:
|
||||
return schemas.Response(success=False, message="未知的媒体ID")
|
||||
# 使用名称识别兜底
|
||||
meta = MetaInfo(title)
|
||||
if year:
|
||||
meta.year = year
|
||||
if media_type:
|
||||
meta.type = media_type
|
||||
if media_season:
|
||||
meta.type = MediaType.TV
|
||||
meta.begin_season = media_season
|
||||
mediainfo = await media_chain.async_recognize_media(meta=meta)
|
||||
if mediainfo:
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
torrents = await search_chain.async_search_by_id(tmdbid=mediainfo.tmdb_id, mtype=media_type,
|
||||
area=area,
|
||||
season=media_season, cache_local=True)
|
||||
else:
|
||||
torrents = await search_chain.async_search_by_id(doubanid=mediainfo.douban_id, mtype=media_type,
|
||||
area=area,
|
||||
season=media_season, cache_local=True)
|
||||
# 返回搜索结果
|
||||
if not torrents:
|
||||
return schemas.Response(success=False, message="未搜索到任何资源")
|
||||
else:
|
||||
@@ -88,14 +152,18 @@ def search_by_id(mediaid: str,
|
||||
|
||||
|
||||
@router.get("/title", summary="模糊搜索资源", response_model=schemas.Response)
|
||||
def search_by_title(keyword: str = None,
|
||||
page: int = 0,
|
||||
site: int = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def search_by_title(keyword: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
sites: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
|
||||
"""
|
||||
torrents = SearchChain().search_by_title(title=keyword, page=page, site=site)
|
||||
torrents = await SearchChain().async_search_by_title(
|
||||
title=keyword, page=page,
|
||||
sites=[int(site) for site in sites.split(",") if site] if sites else None,
|
||||
cache_local=True
|
||||
)
|
||||
if not torrents:
|
||||
return schemas.Response(success=False, message="未搜索到任何资源")
|
||||
return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
from typing import List, Any
|
||||
from typing import List, Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
from app import schemas
|
||||
from app.api.endpoints.plugin import register_plugin_api
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.event import EventManager
|
||||
from app.command import Command
|
||||
from app.core.event import eventmanager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db import get_db, get_async_db
|
||||
from app.db.models import User
|
||||
from app.db.models.site import Site
|
||||
from app.db.models.siteicon import SiteIcon
|
||||
from app.db.models.sitestatistic import SiteStatistic
|
||||
from app.db.models.siteuserdata import SiteUserData
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
from app.utils.string import StringUtils
|
||||
@@ -27,20 +31,20 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
|
||||
def read_sites(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> List[dict]:
|
||||
async def read_sites(db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser)) -> List[dict]:
|
||||
"""
|
||||
获取站点列表
|
||||
"""
|
||||
return Site.list_order_by_pri(db)
|
||||
return await Site.async_list_order_by_pri(db)
|
||||
|
||||
|
||||
@router.post("/", summary="新增站点", response_model=schemas.Response)
|
||||
def add_site(
|
||||
async def add_site(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
site_in: schemas.Site,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)
|
||||
_: User = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
"""
|
||||
新增站点
|
||||
@@ -50,10 +54,10 @@ def add_site(
|
||||
if SitesHelper().auth_level < 2:
|
||||
return schemas.Response(success=False, message="用户未通过认证,无法使用站点功能!")
|
||||
domain = StringUtils.get_url_domain(site_in.url)
|
||||
site_info = SitesHelper().get_indexer(domain)
|
||||
site_info = await SitesHelper().async_get_indexer(domain)
|
||||
if not site_info:
|
||||
return schemas.Response(success=False, message="该站点不支持,请检查站点域名是否正确")
|
||||
if Site.get_by_domain(db, domain):
|
||||
if await Site.async_get_by_domain(db, domain):
|
||||
return schemas.Response(success=False, message=f"{domain} 站点己存在")
|
||||
# 保存站点信息
|
||||
site_in.domain = domain
|
||||
@@ -66,39 +70,39 @@ def add_site(
|
||||
site = Site(**site_in.dict())
|
||||
site.create(db)
|
||||
# 通知站点更新
|
||||
EventManager().send_event(EventType.SiteUpdated, {
|
||||
await eventmanager.async_send_event(EventType.SiteUpdated, {
|
||||
"domain": domain
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.put("/", summary="更新站点", response_model=schemas.Response)
|
||||
def update_site(
|
||||
async def update_site(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
site_in: schemas.Site,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)
|
||||
_: User = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
"""
|
||||
更新站点信息
|
||||
"""
|
||||
site = Site.get(db, site_in.id)
|
||||
site = await Site.async_get(db, site_in.id)
|
||||
if not site:
|
||||
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())
|
||||
await site.async_update(db, site_in.dict())
|
||||
# 通知站点更新
|
||||
EventManager().send_event(EventType.SiteUpdated, {
|
||||
await eventmanager.async_send_event(EventType.SiteUpdated, {
|
||||
"domain": site_in.domain
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
|
||||
def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
async def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
运行CookieCloud同步站点信息
|
||||
"""
|
||||
@@ -107,7 +111,7 @@ def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||
|
||||
|
||||
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
|
||||
def reset(db: Session = Depends(get_db),
|
||||
def reset(db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
清空所有站点数据并重新同步CookieCloud站点信息
|
||||
@@ -118,25 +122,25 @@ def reset(db: Session = Depends(get_db),
|
||||
# 启动定时服务
|
||||
Scheduler().start("cookiecloud", manual=True)
|
||||
# 插件站点删除
|
||||
EventManager().send_event(EventType.SiteDeleted,
|
||||
{
|
||||
"site_id": "*"
|
||||
})
|
||||
eventmanager.send_event(EventType.SiteDeleted,
|
||||
{
|
||||
"site_id": "*"
|
||||
})
|
||||
return schemas.Response(success=True, message="站点已重置!")
|
||||
|
||||
|
||||
@router.post("/priorities", summary="批量更新站点优先级", response_model=schemas.Response)
|
||||
def update_sites_priority(
|
||||
async def update_sites_priority(
|
||||
priorities: List[dict],
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
批量更新站点优先级
|
||||
"""
|
||||
for priority in priorities:
|
||||
site = Site.get(db, priority.get("id"))
|
||||
site = await Site.async_get(db, priority.get("id"))
|
||||
if site:
|
||||
site.update(db, {"pri": priority.get("pri")})
|
||||
await site.async_update(db, {"pri": priority.get("pri")})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -145,9 +149,9 @@ def update_cookie(
|
||||
site_id: int,
|
||||
username: str,
|
||||
password: str,
|
||||
code: str = None,
|
||||
code: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
使用用户密码更新站点Cookie
|
||||
"""
|
||||
@@ -170,7 +174,7 @@ def update_cookie(
|
||||
def refresh_userdata(
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
刷新站点用户数据
|
||||
"""
|
||||
@@ -188,34 +192,34 @@ def refresh_userdata(
|
||||
|
||||
|
||||
@router.get("/userdata/latest", summary="查询所有站点最新用户数据", response_model=List[schemas.SiteUserData])
|
||||
def read_userdata_latest(
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
async def read_userdata_latest(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
查询所有站点最新用户数据
|
||||
"""
|
||||
user_datas = SiteUserData.get_latest(db)
|
||||
user_datas = await SiteUserData.async_get_latest(db)
|
||||
if not user_datas:
|
||||
return []
|
||||
return [user_data.to_dict() for user_data in user_datas]
|
||||
|
||||
|
||||
@router.get("/userdata/{site_id}", summary="查询某站点用户数据", response_model=schemas.Response)
|
||||
def read_userdata(
|
||||
async def read_userdata(
|
||||
site_id: int,
|
||||
workdate: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
workdate: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
查询站点用户数据
|
||||
"""
|
||||
site = Site.get(db, site_id)
|
||||
site = await Site.async_get(db, site_id)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
user_data = SiteUserData.get_by_domain(db, domain=site.domain, workdate=workdate)
|
||||
user_data = await SiteUserData.async_get_by_domain(db, domain=site.domain, workdate=workdate)
|
||||
if not user_data:
|
||||
return schemas.Response(success=False, data=[])
|
||||
return schemas.Response(success=True, data=user_data)
|
||||
@@ -239,19 +243,19 @@ def test_site(site_id: int,
|
||||
|
||||
|
||||
@router.get("/icon/{site_id}", summary="站点图标", response_model=schemas.Response)
|
||||
def site_icon(site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def site_icon(site_id: int,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取站点图标:base64或者url
|
||||
"""
|
||||
site = Site.get(db, site_id)
|
||||
site = await Site.async_get(db, site_id)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
icon = SiteIcon.get_by_domain(db, site.domain)
|
||||
icon = await SiteIcon.async_get_by_domain(db, site.domain)
|
||||
if not icon:
|
||||
return schemas.Response(success=False, message="站点图标不存在!")
|
||||
return schemas.Response(success=True, data={
|
||||
@@ -259,36 +263,69 @@ def site_icon(site_id: int,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo])
|
||||
def site_resource(site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
@router.get("/category/{site_id}", summary="站点分类", response_model=List[schemas.SiteCategory])
|
||||
async def site_category(site_id: int,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览站点资源
|
||||
获取站点分类
|
||||
"""
|
||||
site = Site.get(db, site_id)
|
||||
site = await Site.async_get(db, site_id)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
torrents = TorrentsChain().browse(domain=site.domain)
|
||||
indexer = await SitesHelper().async_get_indexer(site.domain)
|
||||
if not indexer:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"站点 {site.domain} 不支持",
|
||||
)
|
||||
category: Dict[str, List[dict]] = indexer.get('category') or []
|
||||
if not category:
|
||||
return []
|
||||
result = []
|
||||
for cats in category.values():
|
||||
for cat in cats:
|
||||
if cat not in result:
|
||||
result.append(cat)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo])
|
||||
async def site_resource(site_id: int,
|
||||
keyword: Optional[str] = None,
|
||||
cat: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
浏览站点资源
|
||||
"""
|
||||
site = await Site.async_get(db, site_id)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
torrents = await TorrentsChain().async_browse(domain=site.domain, keyword=keyword, cat=cat, page=page)
|
||||
if not torrents:
|
||||
return []
|
||||
return [torrent.to_dict() for torrent in torrents]
|
||||
|
||||
|
||||
@router.get("/domain/{site_url}", summary="站点详情", response_model=schemas.Site)
|
||||
def read_site_by_domain(
|
||||
async def read_site_by_domain(
|
||||
site_url: str,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
通过域名获取站点信息
|
||||
"""
|
||||
domain = StringUtils.get_url_domain(site_url)
|
||||
site = Site.get_by_domain(db, domain)
|
||||
site = await Site.async_get_by_domain(db, domain)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -297,25 +334,36 @@ def read_site_by_domain(
|
||||
return site
|
||||
|
||||
|
||||
@router.get("/statistic/{site_url}", summary="站点统计信息", response_model=schemas.SiteStatistic)
|
||||
def read_site_by_domain(
|
||||
@router.get("/statistic/{site_url}", summary="特定站点统计信息", response_model=schemas.SiteStatistic)
|
||||
async def read_statistic_by_domain(
|
||||
site_url: str,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
通过域名获取站点统计信息
|
||||
"""
|
||||
domain = StringUtils.get_url_domain(site_url)
|
||||
sitestatistic = SiteStatistic.get_by_domain(db, domain)
|
||||
sitestatistic = await SiteStatistic.async_get_by_domain(db, domain)
|
||||
if sitestatistic:
|
||||
return sitestatistic
|
||||
return schemas.SiteStatistic(domain=domain)
|
||||
|
||||
|
||||
@router.get("/statistic", summary="所有站点统计信息", response_model=List[schemas.SiteStatistic])
|
||||
async def read_statistics(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
获取所有站点统计信息
|
||||
"""
|
||||
return await SiteStatistic.async_list(db)
|
||||
|
||||
|
||||
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
|
||||
def read_rss_sites(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
async def read_rss_sites(db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
"""
|
||||
获取站点列表
|
||||
"""
|
||||
@@ -323,7 +371,7 @@ def read_rss_sites(db: Session = Depends(get_db),
|
||||
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
||||
|
||||
# 所有站点
|
||||
all_site = Site.list_order_by_pri(db)
|
||||
all_site = await Site.async_list_order_by_pri(db)
|
||||
if not selected_sites:
|
||||
return all_site
|
||||
|
||||
@@ -333,7 +381,7 @@ def read_rss_sites(db: Session = Depends(get_db),
|
||||
|
||||
|
||||
@router.get("/auth", summary="查询认证站点", response_model=dict)
|
||||
def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
||||
async def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
||||
"""
|
||||
获取可认证站点列表
|
||||
"""
|
||||
@@ -352,21 +400,47 @@ def auth_site(
|
||||
return schemas.Response(success=False, message="请输入认证站点和认证参数")
|
||||
status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)
|
||||
SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.dict())
|
||||
# 认证成功后,重新初始化插件
|
||||
PluginManager().init_config()
|
||||
Scheduler().init_plugin_jobs()
|
||||
Command().init_commands()
|
||||
register_plugin_api()
|
||||
return schemas.Response(success=status, message=msg)
|
||||
|
||||
|
||||
@router.get("/mapping", summary="获取站点域名到名称的映射", response_model=schemas.Response)
|
||||
async def site_mapping(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
获取站点域名到名称的映射关系
|
||||
"""
|
||||
try:
|
||||
sites = await SiteOper().async_list()
|
||||
mapping = {}
|
||||
for site in sites:
|
||||
mapping[site.domain] = site.name
|
||||
return schemas.Response(success=True, data=mapping)
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=f"获取映射失败:{str(e)}")
|
||||
|
||||
|
||||
@router.get("/supporting", summary="获取支持的站点列表", response_model=dict)
|
||||
async def support_sites(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
获取支持的站点列表
|
||||
"""
|
||||
return SitesHelper().get_indexsites()
|
||||
|
||||
|
||||
@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site)
|
||||
def read_site(
|
||||
async def read_site(
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)
|
||||
) -> Any:
|
||||
"""
|
||||
通过ID获取站点信息
|
||||
"""
|
||||
site = Site.get(db, site_id)
|
||||
site = await Site.async_get(db, site_id)
|
||||
if not site:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -376,18 +450,18 @@ def read_site(
|
||||
|
||||
|
||||
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
|
||||
def delete_site(
|
||||
async def delete_site(
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_active_superuser)
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async)
|
||||
) -> Any:
|
||||
"""
|
||||
删除站点
|
||||
"""
|
||||
Site.delete(db, site_id)
|
||||
await Site.async_delete(db, site_id)
|
||||
# 插件站点删除
|
||||
EventManager().send_event(EventType.SiteDeleted,
|
||||
{
|
||||
"site_id": site_id
|
||||
})
|
||||
await eventmanager.async_send_event(EventType.SiteDeleted,
|
||||
{
|
||||
"site_id": site_id
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from starlette.responses import FileResponse, Response
|
||||
@@ -12,7 +12,7 @@ from app.core.config import settings
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.core.security import verify_token
|
||||
from app.db.models import User
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.schemas.types import ProgressKey
|
||||
|
||||
@@ -27,11 +27,12 @@ def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
qrcode_data, errmsg = StorageChain().generate_qrcode(name)
|
||||
if qrcode_data:
|
||||
return schemas.Response(success=True, data=qrcode_data, message=errmsg)
|
||||
return schemas.Response(success=False)
|
||||
return schemas.Response(success=False, message=errmsg)
|
||||
|
||||
|
||||
@router.get("/check/{name}", summary="二维码登录确认", response_model=schemas.Response)
|
||||
def check(name: str, ck: str = None, t: str = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def check(name: str, ck: Optional[str] = None, t: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
二维码登录确认
|
||||
"""
|
||||
@@ -55,9 +56,19 @@ def save(name: str,
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/reset/{name}", summary="重置存储配置", response_model=schemas.Response)
|
||||
def reset(name: str,
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
重置存储配置
|
||||
"""
|
||||
StorageChain().reset_config(name)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
|
||||
def list_files(fileitem: schemas.FileItem,
|
||||
sort: str = 'updated_at',
|
||||
sort: Optional[str] = 'updated_at',
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
查询当前目录下所有目录和文件
|
||||
@@ -140,7 +151,7 @@ def image(fileitem: schemas.FileItem,
|
||||
@router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response)
|
||||
def rename(fileitem: schemas.FileItem,
|
||||
new_name: str,
|
||||
recursive: bool = False,
|
||||
recursive: Optional[bool] = False,
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
重命名文件或目录
|
||||
@@ -151,47 +162,49 @@ def rename(fileitem: schemas.FileItem,
|
||||
"""
|
||||
if not new_name:
|
||||
return schemas.Response(success=False, message="新名称为空")
|
||||
|
||||
# 重命名目录内文件
|
||||
if recursive:
|
||||
transferchain = TransferChain()
|
||||
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
|
||||
# 递归修改目录内文件(智能识别命名)
|
||||
sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem)
|
||||
if sub_files:
|
||||
# 开始进度
|
||||
progress = ProgressHelper(ProgressKey.BatchRename)
|
||||
progress.start()
|
||||
total = len(sub_files)
|
||||
handled = 0
|
||||
for sub_file in sub_files:
|
||||
handled += 1
|
||||
progress.update(value=handled / total * 100,
|
||||
text=f"正在处理 {sub_file.name} ...")
|
||||
if sub_file.type == "dir":
|
||||
continue
|
||||
if not sub_file.extension:
|
||||
continue
|
||||
if f".{sub_file.extension.lower()}" not in media_exts:
|
||||
continue
|
||||
sub_path = Path(f"{fileitem.path}{sub_file.name}")
|
||||
meta = MetaInfoPath(sub_path)
|
||||
mediainfo = transferchain.recognize_media(meta)
|
||||
if not mediainfo:
|
||||
progress.end()
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息")
|
||||
new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo)
|
||||
if not new_path:
|
||||
progress.end()
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称")
|
||||
ret: schemas.Response = rename(fileitem=sub_file,
|
||||
new_name=Path(new_path).name,
|
||||
recursive=False)
|
||||
if not ret.success:
|
||||
progress.end()
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!")
|
||||
progress.end()
|
||||
# 重命名自己
|
||||
result = StorageChain().rename_file(fileitem, new_name)
|
||||
if result:
|
||||
if recursive:
|
||||
transferchain = TransferChain()
|
||||
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
|
||||
# 递归修改目录内文件(智能识别命名)
|
||||
sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem)
|
||||
if sub_files:
|
||||
# 开始进度
|
||||
progress = ProgressHelper()
|
||||
progress.start(ProgressKey.BatchRename)
|
||||
total = len(sub_files)
|
||||
handled = 0
|
||||
for sub_file in sub_files:
|
||||
handled += 1
|
||||
progress.update(value=handled / total * 100,
|
||||
text=f"正在处理 {sub_file.name} ...",
|
||||
key=ProgressKey.BatchRename)
|
||||
if sub_file.type == "dir":
|
||||
continue
|
||||
if not sub_file.extension:
|
||||
continue
|
||||
if f".{sub_file.extension.lower()}" not in media_exts:
|
||||
continue
|
||||
sub_path = Path(f"{fileitem.path}{sub_file.name}")
|
||||
meta = MetaInfoPath(sub_path)
|
||||
mediainfo = transferchain.recognize_media(meta)
|
||||
if not mediainfo:
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息")
|
||||
new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo)
|
||||
if not new_path:
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称")
|
||||
ret: schemas.Response = rename(fileitem=sub_file,
|
||||
new_name=Path(new_path).name,
|
||||
recursive=False)
|
||||
if not ret.success:
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!")
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
return schemas.Response(success=True)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
@@ -208,7 +221,7 @@ def usage(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
|
||||
|
||||
|
||||
@router.get("/transtype/{name}", summary="支持的整理方式获取", response_model=schemas.StorageTransType)
|
||||
def transtype(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
|
||||
async def transtype(name: str, _: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||
"""
|
||||
查询支持的整理方式
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import List, Any
|
||||
from typing import List, Any, Annotated, Optional
|
||||
|
||||
import cn2an
|
||||
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
@@ -11,14 +12,15 @@ from app.core.context import MediaInfo
|
||||
from app.core.event import eventmanager
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token, verify_apitoken
|
||||
from app.db import get_db
|
||||
from app.db import get_async_db, get_db
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.db.models.user import User
|
||||
from app.db.user_oper import get_current_active_user
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_user_async
|
||||
from app.helper.subscribe import SubscribeHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import MediaType, EventType
|
||||
from app.schemas.types import MediaType, EventType, SystemConfigKey
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -33,28 +35,28 @@ def start_subscribe_add(title: str, year: str,
|
||||
|
||||
|
||||
@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe])
|
||||
def read_subscribes(
|
||||
db: Session = Depends(get_db),
|
||||
async def read_subscribes(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询所有订阅
|
||||
"""
|
||||
return Subscribe.list(db)
|
||||
return await Subscribe.async_list(db)
|
||||
|
||||
|
||||
@router.get("/list", summary="查询所有订阅(API_TOKEN)", response_model=List[schemas.Subscribe])
|
||||
def list_subscribes(_: str = Depends(verify_apitoken)) -> Any:
|
||||
async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
查询所有订阅 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return read_subscribes()
|
||||
return await read_subscribes()
|
||||
|
||||
|
||||
@router.post("/", summary="新增订阅", response_model=schemas.Response)
|
||||
def create_subscribe(
|
||||
async def create_subscribe(
|
||||
*,
|
||||
subscribe_in: schemas.Subscribe,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
current_user: User = Depends(get_current_active_user_async),
|
||||
) -> schemas.Response:
|
||||
"""
|
||||
新增订阅
|
||||
@@ -74,40 +76,36 @@ def create_subscribe(
|
||||
title = subscribe_in.name
|
||||
else:
|
||||
title = None
|
||||
sid, message = SubscribeChain().add(mtype=mtype,
|
||||
title=title,
|
||||
year=subscribe_in.year,
|
||||
tmdbid=subscribe_in.tmdbid,
|
||||
season=subscribe_in.season,
|
||||
doubanid=subscribe_in.doubanid,
|
||||
bangumiid=subscribe_in.bangumiid,
|
||||
username=current_user.name,
|
||||
best_version=subscribe_in.best_version,
|
||||
save_path=subscribe_in.save_path,
|
||||
search_imdbid=subscribe_in.search_imdbid,
|
||||
custom_words=subscribe_in.custom_words,
|
||||
media_category=subscribe_in.media_category,
|
||||
filter_groups=subscribe_in.filter_groups,
|
||||
exist_ok=True)
|
||||
# 订阅用户
|
||||
subscribe_in.username = current_user.name
|
||||
# 转化为字典
|
||||
subscribe_dict = subscribe_in.dict()
|
||||
if subscribe_in.id:
|
||||
subscribe_dict.pop("id", None)
|
||||
sid, message = await SubscribeChain().async_add(mtype=mtype,
|
||||
title=title,
|
||||
exist_ok=True,
|
||||
**subscribe_dict)
|
||||
return schemas.Response(
|
||||
success=bool(sid), message=message, data={"id": sid}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/", summary="更新订阅", response_model=schemas.Response)
|
||||
def update_subscribe(
|
||||
async def update_subscribe(
|
||||
*,
|
||||
subscribe_in: schemas.Subscribe,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
更新订阅信息
|
||||
"""
|
||||
subscribe = Subscribe.get(db, subscribe_in.id)
|
||||
subscribe = await Subscribe.async_get(db, subscribe_in.id)
|
||||
if not subscribe:
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
# 避免更新缺失集数
|
||||
old_subscribe_dict = subscribe.to_dict()
|
||||
subscribe_dict = subscribe_in.dict()
|
||||
if not subscribe_in.lack_episode:
|
||||
# 没有缺失集数时,缺失集数清空,避免更新为0
|
||||
@@ -121,60 +119,81 @@ def update_subscribe(
|
||||
# 是否手动修改过总集数
|
||||
if subscribe_in.total_episode != subscribe.total_episode:
|
||||
subscribe_dict["manual_total_episode"] = 1
|
||||
subscribe.update(db, subscribe_dict)
|
||||
# 更新到数据库
|
||||
await subscribe.async_update(db, subscribe_dict)
|
||||
# 重新获取更新后的订阅数据
|
||||
updated_subscribe = await Subscribe.async_get(db, subscribe_in.id)
|
||||
# 发送订阅调整事件
|
||||
await eventmanager.async_send_event(EventType.SubscribeModified, {
|
||||
"subscribe_id": subscribe_in.id,
|
||||
"old_subscribe_info": old_subscribe_dict,
|
||||
"subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {},
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response)
|
||||
def update_subscribe_status(
|
||||
async def update_subscribe_status(
|
||||
subid: int,
|
||||
state: str,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
更新订阅状态
|
||||
"""
|
||||
subscribe = Subscribe.get(db, subid)
|
||||
subscribe = await Subscribe.async_get(db, subid)
|
||||
if not subscribe:
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
valid_states = ["R", "P", "S"]
|
||||
if state not in valid_states:
|
||||
return schemas.Response(success=False, message="无效的订阅状态")
|
||||
subscribe.update(db, {
|
||||
old_subscribe_dict = subscribe.to_dict()
|
||||
await subscribe.async_update(db, {
|
||||
"state": state
|
||||
})
|
||||
# 重新获取更新后的订阅数据
|
||||
updated_subscribe = await Subscribe.async_get(db, subid)
|
||||
# 发送订阅调整事件
|
||||
await eventmanager.async_send_event(EventType.SubscribeModified, {
|
||||
"subscribe_id": subid,
|
||||
"old_subscribe_info": old_subscribe_dict,
|
||||
"subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {},
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/media/{mediaid}", summary="查询订阅", response_model=schemas.Subscribe)
|
||||
def subscribe_mediaid(
|
||||
async def subscribe_mediaid(
|
||||
mediaid: str,
|
||||
season: int = None,
|
||||
title: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
season: Optional[int] = None,
|
||||
title: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban:
|
||||
"""
|
||||
result = None
|
||||
title_check = False
|
||||
if mediaid.startswith("tmdb:"):
|
||||
tmdbid = mediaid[5:]
|
||||
if not tmdbid or not str(tmdbid).isdigit():
|
||||
return Subscribe()
|
||||
result = Subscribe.exists(db, tmdbid=int(tmdbid), season=season)
|
||||
result = await Subscribe.async_exists(db, tmdbid=int(tmdbid), season=season)
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid[7:]
|
||||
if not doubanid:
|
||||
return Subscribe()
|
||||
result = Subscribe.get_by_doubanid(db, doubanid)
|
||||
result = await Subscribe.async_get_by_doubanid(db, doubanid)
|
||||
if not result and title:
|
||||
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))
|
||||
result = await Subscribe.async_get_by_bangumiid(db, int(bangumiid))
|
||||
if not result and title:
|
||||
title_check = True
|
||||
else:
|
||||
result = await Subscribe.async_get_by_mediaid(db, mediaid)
|
||||
if not result and title:
|
||||
title_check = True
|
||||
# 使用名称检查订阅
|
||||
@@ -182,7 +201,7 @@ def subscribe_mediaid(
|
||||
meta = MetaInfo(title)
|
||||
if season:
|
||||
meta.begin_season = season
|
||||
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
|
||||
result = await Subscribe.async_get_by_title(db, title=meta.name, season=meta.begin_season)
|
||||
|
||||
return result if result else Subscribe()
|
||||
|
||||
@@ -198,20 +217,31 @@ def refresh_subscribes(
|
||||
|
||||
|
||||
@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response)
|
||||
def reset_subscribes(
|
||||
async def reset_subscribes(
|
||||
subid: int,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
重置订阅
|
||||
"""
|
||||
subscribe = Subscribe.get(db, subid)
|
||||
subscribe = await Subscribe.async_get(db, subid)
|
||||
if subscribe:
|
||||
subscribe.update(db, {
|
||||
# 在更新之前获取旧数据
|
||||
old_subscribe_dict = subscribe.to_dict()
|
||||
# 更新订阅
|
||||
await subscribe.async_update(db, {
|
||||
"note": [],
|
||||
"lack_episode": subscribe.total_episode,
|
||||
"state": "R"
|
||||
})
|
||||
# 重新获取更新后的订阅数据
|
||||
updated_subscribe = await Subscribe.async_get(db, subid)
|
||||
# 发送订阅调整事件
|
||||
await eventmanager.async_send_event(EventType.SubscribeModified, {
|
||||
"subscribe_id": subid,
|
||||
"old_subscribe_info": old_subscribe_dict,
|
||||
"subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {},
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
|
||||
@@ -227,7 +257,7 @@ def check_subscribes(
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
|
||||
def search_subscribes(
|
||||
async def search_subscribes(
|
||||
background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -246,7 +276,7 @@ def search_subscribes(
|
||||
|
||||
|
||||
@router.get("/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response)
|
||||
def search_subscribe(
|
||||
async def search_subscribe(
|
||||
subscribe_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
@@ -266,10 +296,10 @@ def search_subscribe(
|
||||
|
||||
|
||||
@router.delete("/media/{mediaid}", summary="删除订阅", response_model=schemas.Response)
|
||||
def delete_subscribe_by_mediaid(
|
||||
async def delete_subscribe_by_mediaid(
|
||||
mediaid: str,
|
||||
season: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
season: Optional[int] = None,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
@@ -280,28 +310,35 @@ def delete_subscribe_by_mediaid(
|
||||
tmdbid = mediaid[5:]
|
||||
if not tmdbid or not str(tmdbid).isdigit():
|
||||
return schemas.Response(success=False)
|
||||
subscribes = Subscribe().get_by_tmdbid(db, int(tmdbid), season)
|
||||
subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season)
|
||||
delete_subscribes.extend(subscribes)
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid[7:]
|
||||
if not doubanid:
|
||||
return schemas.Response(success=False)
|
||||
subscribe = Subscribe().get_by_doubanid(db, doubanid)
|
||||
subscribe = await Subscribe.async_get_by_doubanid(db, doubanid)
|
||||
if subscribe:
|
||||
delete_subscribes.append(subscribe)
|
||||
else:
|
||||
subscribe = await Subscribe.async_get_by_mediaid(db, mediaid)
|
||||
if subscribe:
|
||||
delete_subscribes.append(subscribe)
|
||||
for subscribe in delete_subscribes:
|
||||
Subscribe().delete(db, subscribe.id)
|
||||
# 在删除之前获取订阅信息
|
||||
subscribe_info = subscribe.to_dict()
|
||||
subscribe_id = subscribe.id
|
||||
await Subscribe.async_delete(db, subscribe_id)
|
||||
# 发送事件
|
||||
eventmanager.send_event(EventType.SubscribeDeleted, {
|
||||
"subscribe_id": subscribe.id,
|
||||
"subscribe_info": subscribe.to_dict()
|
||||
await eventmanager.async_send_event(EventType.SubscribeDeleted, {
|
||||
"subscribe_id": subscribe_id,
|
||||
"subscribe_info": subscribe_info
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response)
|
||||
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||
authorization: str = Header(None)) -> Any:
|
||||
authorization: Annotated[str | None, Header()] = None) -> Any:
|
||||
"""
|
||||
Jellyseerr/Overseerr网络勾子通知订阅
|
||||
"""
|
||||
@@ -353,42 +390,42 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||
|
||||
|
||||
@router.get("/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe])
|
||||
def subscribe_history(
|
||||
async def subscribe_history(
|
||||
mtype: str,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询电影/电视剧订阅历史
|
||||
"""
|
||||
return SubscribeHistory.list_by_type(db, mtype=mtype, page=page, count=count)
|
||||
return await SubscribeHistory.async_list_by_type(db, mtype=mtype, page=page, count=count)
|
||||
|
||||
|
||||
@router.delete("/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response)
|
||||
def delete_subscribe(
|
||||
async def delete_subscribe(
|
||||
history_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
删除订阅历史
|
||||
"""
|
||||
SubscribeHistory.delete(db, history_id)
|
||||
await SubscribeHistory.async_delete(db, history_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/popular", summary="热门订阅(基于用户共享数据)", response_model=List[schemas.MediaInfo])
|
||||
def popular_subscribes(
|
||||
async def popular_subscribes(
|
||||
stype: str,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
min_sub: int = None,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
min_sub: Optional[int] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询热门订阅
|
||||
"""
|
||||
subscribes = SubscribeHelper().get_statistic(stype=stype, page=page, count=count)
|
||||
subscribes = await SubscribeHelper().async_get_statistic(stype=stype, page=page, count=count)
|
||||
if subscribes:
|
||||
ret_medias = []
|
||||
for sub in subscribes:
|
||||
@@ -424,14 +461,14 @@ def popular_subscribes(
|
||||
|
||||
|
||||
@router.get("/user/{username}", summary="用户订阅", response_model=List[schemas.Subscribe])
|
||||
def user_subscribes(
|
||||
async def user_subscribes(
|
||||
username: str,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询用户订阅
|
||||
"""
|
||||
return Subscribe.list_by_username(db, username)
|
||||
return await Subscribe.async_list_by_username(db, username)
|
||||
|
||||
|
||||
@router.get("/files/{subscribe_id}", summary="订阅相关文件信息", response_model=schemas.SubscrbieInfo)
|
||||
@@ -449,23 +486,34 @@ def subscribe_files(
|
||||
|
||||
|
||||
@router.post("/share", summary="分享订阅", response_model=schemas.Response)
|
||||
def subscribe_share(
|
||||
async def subscribe_share(
|
||||
sub: schemas.SubscribeShare,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
分享订阅
|
||||
"""
|
||||
state, errmsg = SubscribeHelper().sub_share(subscribe_id=sub.subscribe_id,
|
||||
share_title=sub.share_title,
|
||||
share_comment=sub.share_comment,
|
||||
share_user=sub.share_user)
|
||||
state, errmsg = await SubscribeHelper().async_sub_share(subscribe_id=sub.subscribe_id,
|
||||
share_title=sub.share_title,
|
||||
share_comment=sub.share_comment,
|
||||
share_user=sub.share_user)
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
|
||||
async def subscribe_share_delete(
|
||||
share_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除分享
|
||||
"""
|
||||
state, errmsg = await SubscribeHelper().async_share_delete(share_id=share_id)
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.post("/fork", summary="复用订阅", response_model=schemas.Response)
|
||||
def subscribe_fork(
|
||||
async def subscribe_fork(
|
||||
sub: schemas.SubscribeShare,
|
||||
current_user: User = Depends(get_current_active_user)) -> Any:
|
||||
current_user: User = Depends(get_current_active_user_async)) -> Any:
|
||||
"""
|
||||
复用订阅
|
||||
"""
|
||||
@@ -474,54 +522,101 @@ def subscribe_fork(
|
||||
for key in list(sub_dict.keys()):
|
||||
if not hasattr(schemas.Subscribe(), key):
|
||||
sub_dict.pop(key)
|
||||
result = create_subscribe(subscribe_in=schemas.Subscribe(**sub_dict),
|
||||
current_user=current_user)
|
||||
result = await create_subscribe(subscribe_in=schemas.Subscribe(**sub_dict),
|
||||
current_user=current_user)
|
||||
if result.success:
|
||||
SubscribeHelper().sub_fork(share_id=sub.id)
|
||||
await SubscribeHelper().async_sub_fork(share_id=sub.id)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str])
|
||||
async def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询已Follow的订阅分享人
|
||||
"""
|
||||
return SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||
|
||||
|
||||
@router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response)
|
||||
async def follow_subscriber(
|
||||
share_uid: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
Follow订阅分享人
|
||||
"""
|
||||
subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||
if share_uid and share_uid not in subscribers:
|
||||
subscribers.append(share_uid)
|
||||
await SystemConfigOper().async_set(SystemConfigKey.FollowSubscribers, subscribers)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/follow", summary="取消Follow订阅分享人", response_model=schemas.Response)
|
||||
async def unfollow_subscriber(
|
||||
share_uid: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
取消Follow订阅分享人
|
||||
"""
|
||||
subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||
if share_uid and share_uid in subscribers:
|
||||
subscribers.remove(share_uid)
|
||||
await SystemConfigOper().async_set(SystemConfigKey.FollowSubscribers, subscribers)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare])
|
||||
def popular_subscribes(
|
||||
name: str = None,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
async def popular_subscribes(
|
||||
name: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询分享的订阅
|
||||
"""
|
||||
return SubscribeHelper().get_shares(name=name, page=page, count=count)
|
||||
return await SubscribeHelper().async_get_shares(name=name, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/share/statistics", summary="查询订阅分享统计", response_model=List[schemas.SubscribeShareStatistics])
|
||||
async def subscribe_share_statistics(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询订阅分享统计
|
||||
返回每个分享人分享的媒体数量以及总的复用人次
|
||||
"""
|
||||
return await SubscribeHelper().async_get_share_statistics()
|
||||
|
||||
|
||||
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
|
||||
def read_subscribe(
|
||||
async def read_subscribe(
|
||||
subscribe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据订阅编号查询订阅信息
|
||||
"""
|
||||
if not subscribe_id:
|
||||
return Subscribe()
|
||||
return Subscribe.get(db, subscribe_id)
|
||||
return await Subscribe.async_get(db, subscribe_id)
|
||||
|
||||
|
||||
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
|
||||
def delete_subscribe(
|
||||
async def delete_subscribe(
|
||||
subscribe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
删除订阅信息
|
||||
"""
|
||||
subscribe = Subscribe.get(db, subscribe_id)
|
||||
subscribe = await Subscribe.async_get(db, subscribe_id)
|
||||
if subscribe:
|
||||
subscribe.delete(db, subscribe_id)
|
||||
# 在删除之前获取订阅信息
|
||||
subscribe_info = subscribe.to_dict()
|
||||
await Subscribe.async_delete(db, subscribe_id)
|
||||
# 发送事件
|
||||
eventmanager.send_event(EventType.SubscribeDeleted, {
|
||||
await eventmanager.async_send_event(EventType.SubscribeDeleted, {
|
||||
"subscribe_id": subscribe_id,
|
||||
"subscribe_info": subscribe.to_dict()
|
||||
"subscribe_info": subscribe_info
|
||||
})
|
||||
# 统计订阅
|
||||
SubscribeHelper().sub_done_async({
|
||||
|
||||
@@ -1,56 +1,61 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import re
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from typing import Optional, Union, Annotated
|
||||
|
||||
import aiofiles
|
||||
import pillow_avif # noqa 用于自动注册AVIF支持
|
||||
from PIL import Image
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
|
||||
from anyio import Path as AsyncPath
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.system import SystemChain
|
||||
from app.core.cache import AsyncFileCache
|
||||
from app.core.config import global_vars, settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.security import verify_apitoken, verify_resource_token, verify_token
|
||||
from app.db.models import User
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async, \
|
||||
get_current_active_user_async
|
||||
from app.helper.mediaserver import MediaServerHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.helper.rule import RuleHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.sites import SitesHelper # noqa # noqa
|
||||
from app.helper.subscribe import SubscribeHelper
|
||||
from app.helper.system import SystemHelper
|
||||
from app.log import logger
|
||||
from app.monitor import Monitor
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.schemas import ConfigChangeEventData
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
from app.utils.crypto import HashUtils
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
from app.utils.security import SecurityUtils
|
||||
from app.utils.system import SystemUtils
|
||||
from app.utils.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def fetch_image(
|
||||
async def fetch_image(
|
||||
url: str,
|
||||
proxy: bool = False,
|
||||
use_disk_cache: bool = False,
|
||||
use_cache: bool = False,
|
||||
if_none_match: Optional[str] = None,
|
||||
allowed_domains: Optional[set[str]] = None) -> Response:
|
||||
"""
|
||||
处理图片缓存逻辑,支持HTTP缓存和磁盘缓存
|
||||
"""
|
||||
|
||||
if not url:
|
||||
raise HTTPException(status_code=404, detail="URL not provided")
|
||||
|
||||
@@ -61,61 +66,57 @@ def fetch_image(
|
||||
if not SecurityUtils.is_safe_url(url, allowed_domains):
|
||||
raise HTTPException(status_code=404, detail="Unsafe URL")
|
||||
|
||||
# 后续观察系统性能表现,如果发现磁盘缓存和HTTP缓存无法满足高并发情况下的响应速度需求,可以考虑重新引入内存缓存
|
||||
cache_path = None
|
||||
if use_disk_cache:
|
||||
# 生成缓存路径
|
||||
sanitized_path = SecurityUtils.sanitize_url_path(url)
|
||||
cache_path = settings.CACHE_PATH / "images" / sanitized_path
|
||||
# 缓存路径
|
||||
sanitized_path = SecurityUtils.sanitize_url_path(url)
|
||||
cache_path = Path("images") / sanitized_path
|
||||
if not cache_path.suffix:
|
||||
# 没有文件类型,则添加后缀,在恶意文件类型和实际需求下的折衷选择
|
||||
cache_path = cache_path.with_suffix(".jpg")
|
||||
|
||||
# 确保缓存路径和文件类型合法
|
||||
if not SecurityUtils.is_safe_path(settings.CACHE_PATH, cache_path, settings.SECURITY_IMAGE_SUFFIXES):
|
||||
raise HTTPException(status_code=400, detail="Invalid cache path or file type")
|
||||
# 缓存对像,缓存过期时间为全局图片缓存天数
|
||||
cache_backend = AsyncFileCache(base=settings.CACHE_PATH,
|
||||
ttl=settings.GLOBAL_IMAGE_CACHE_DAYS * 24 * 3600)
|
||||
|
||||
# 目前暂不考虑磁盘缓存文件是否过期,后续通过缓存清理机制处理
|
||||
if cache_path.exists():
|
||||
try:
|
||||
content = cache_path.read_bytes()
|
||||
etag = HashUtils.md5(content)
|
||||
headers = RequestUtils.generate_cache_headers(etag, max_age=86400 * 7)
|
||||
if if_none_match == etag:
|
||||
return Response(status_code=304, headers=headers)
|
||||
return Response(content=content, media_type="image/jpeg", headers=headers)
|
||||
except Exception as e:
|
||||
# 如果读取磁盘缓存发生异常,这里仅记录日志,尝试再次请求远端进行处理
|
||||
logger.debug(f"Failed to read cache file {cache_path}: {e}")
|
||||
if use_cache:
|
||||
content = await cache_backend.get(cache_path.as_posix(), region="images")
|
||||
if content:
|
||||
# 检查 If-None-Match
|
||||
etag = HashUtils.md5(content)
|
||||
headers = RequestUtils.generate_cache_headers(etag, max_age=86400 * 7)
|
||||
if if_none_match == etag:
|
||||
return Response(status_code=304, headers=headers)
|
||||
# 返回缓存图片
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=UrlUtils.get_mime_type(url, "image/jpeg"),
|
||||
headers=headers
|
||||
)
|
||||
|
||||
# 请求远程图片
|
||||
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
|
||||
proxies = settings.PROXY if proxy else None
|
||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=proxies, referer=referer).get_res(url=url)
|
||||
response = await AsyncRequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=proxies, referer=referer,
|
||||
accept_type="image/avif,image/webp,image/apng,*/*").get_res(url=url)
|
||||
if not response:
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch the image from the remote server")
|
||||
|
||||
# 验证下载的内容是否为有效图片
|
||||
try:
|
||||
Image.open(io.BytesIO(response.content)).verify()
|
||||
content = response.content
|
||||
Image.open(io.BytesIO(content)).verify()
|
||||
except Exception as e:
|
||||
logger.debug(f"Invalid image format for URL {url}: {e}")
|
||||
raise HTTPException(status_code=502, detail="Invalid image format")
|
||||
|
||||
content = response.content
|
||||
# 获取请求响应头
|
||||
response_headers = response.headers
|
||||
|
||||
cache_control_header = response_headers.get("Cache-Control", "")
|
||||
cache_directive, max_age = RequestUtils.parse_cache_control(cache_control_header)
|
||||
|
||||
# 如果需要使用磁盘缓存,则保存到磁盘
|
||||
if use_disk_cache and cache_path:
|
||||
try:
|
||||
if not cache_path.parent.exists():
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file:
|
||||
tmp_file.write(content)
|
||||
temp_path = Path(tmp_file.name)
|
||||
temp_path.replace(cache_path)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to write cache file {cache_path}: {e}")
|
||||
# 保存缓存
|
||||
if use_cache:
|
||||
await cache_backend.set(cache_path.as_posix(), content, region="images")
|
||||
logger.debug(f"Image cached at {cache_path.as_posix()}")
|
||||
|
||||
# 检查 If-None-Match
|
||||
etag = HashUtils.md5(content)
|
||||
@@ -123,8 +124,8 @@ def fetch_image(
|
||||
headers = RequestUtils.generate_cache_headers(etag, cache_directive, max_age)
|
||||
return Response(status_code=304, headers=headers)
|
||||
|
||||
# 响应
|
||||
headers = RequestUtils.generate_cache_headers(etag, cache_directive, max_age)
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=response_headers.get("Content-Type") or UrlUtils.get_mime_type(url, "image/jpeg"),
|
||||
@@ -133,10 +134,11 @@ def fetch_image(
|
||||
|
||||
|
||||
@router.get("/img/{proxy}", summary="图片代理")
|
||||
def proxy_img(
|
||||
async def proxy_img(
|
||||
imgurl: str,
|
||||
proxy: bool = False,
|
||||
if_none_match: Optional[str] = Header(None),
|
||||
cache: bool = False,
|
||||
if_none_match: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token)
|
||||
) -> Response:
|
||||
"""
|
||||
@@ -146,14 +148,14 @@ def proxy_img(
|
||||
hosts = [config.config.get("host") for config in MediaServerHelper().get_configs().values() if
|
||||
config and config.config and config.config.get("host")]
|
||||
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts)
|
||||
return fetch_image(url=imgurl, proxy=proxy, use_disk_cache=False,
|
||||
if_none_match=if_none_match, allowed_domains=allowed_domains)
|
||||
return await fetch_image(url=imgurl, proxy=proxy, use_cache=cache,
|
||||
if_none_match=if_none_match, allowed_domains=allowed_domains)
|
||||
|
||||
|
||||
@router.get("/cache/image", summary="图片缓存")
|
||||
def cache_img(
|
||||
async def cache_img(
|
||||
url: str,
|
||||
if_none_match: Optional[str] = Header(None),
|
||||
if_none_match: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token)
|
||||
) -> Response:
|
||||
"""
|
||||
@@ -161,25 +163,37 @@ def cache_img(
|
||||
"""
|
||||
# 如果没有启用全局图片缓存,则不使用磁盘缓存
|
||||
proxy = "doubanio.com" not in url
|
||||
return fetch_image(url=url, proxy=proxy, use_disk_cache=settings.GLOBAL_IMAGE_CACHE, if_none_match=if_none_match)
|
||||
return await fetch_image(url=url, proxy=proxy, use_cache=settings.GLOBAL_IMAGE_CACHE,
|
||||
if_none_match=if_none_match)
|
||||
|
||||
|
||||
@router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response)
|
||||
def get_global_setting():
|
||||
def get_global_setting(token: str):
|
||||
"""
|
||||
查询非敏感系统设置(无需鉴权)
|
||||
查询非敏感系统设置(默认鉴权)
|
||||
"""
|
||||
if token != "moviepilot":
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# FIXME: 新增敏感配置项时要在此处添加排除项
|
||||
info = settings.dict(
|
||||
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY", "API_TOKEN", "TMDB_API_KEY", "TVDB_API_KEY", "FANART_API_KEY",
|
||||
"COOKIECLOUD_KEY", "COOKIECLOUD_PASSWORD", "GITHUB_TOKEN", "REPO_GITHUB_TOKEN"}
|
||||
"COOKIECLOUD_KEY", "COOKIECLOUD_PASSWORD", "GITHUB_TOKEN", "REPO_GITHUB_TOKEN", "U115_APP_ID",
|
||||
"ALIPAN_APP_ID", "TVDB_V4_API_KEY", "TVDB_V4_API_PIN"}
|
||||
)
|
||||
# 追加用户唯一ID和订阅分享管理权限
|
||||
share_admin = SubscribeHelper().is_admin_user()
|
||||
info.update({
|
||||
"USER_UNIQUE_ID": SubscribeHelper().get_user_uuid(),
|
||||
"SUBSCRIBE_SHARE_MANAGE": share_admin,
|
||||
"WORKFLOW_SHARE_MANAGE": share_admin
|
||||
})
|
||||
return schemas.Response(success=True,
|
||||
data=info)
|
||||
|
||||
|
||||
@router.get("/env", summary="查询系统配置", response_model=schemas.Response)
|
||||
def get_env_setting(_: User = Depends(get_current_active_superuser)):
|
||||
async def get_env_setting(_: User = Depends(get_current_active_user_async)):
|
||||
"""
|
||||
查询系统环境变量,包括当前版本号(仅管理员)
|
||||
"""
|
||||
@@ -197,26 +211,35 @@ def get_env_setting(_: User = Depends(get_current_active_superuser)):
|
||||
|
||||
|
||||
@router.post("/env", summary="更新系统配置", response_model=schemas.Response)
|
||||
def set_env_setting(env: dict,
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
async def set_env_setting(env: dict,
|
||||
_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
更新系统环境变量(仅管理员)
|
||||
"""
|
||||
result = settings.update_settings(env=env)
|
||||
# 统计成功和失败的结果
|
||||
success_updates = {k: v for k, v in result.items() if v[0]}
|
||||
failed_updates = {k: v for k, v in result.items() if not v[0]}
|
||||
failed_updates = {k: v for k, v in result.items() if v[0] is False}
|
||||
|
||||
if failed_updates:
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message="部分配置项更新失败",
|
||||
message=f"{', '.join([v[1] for v in failed_updates.values()])}",
|
||||
data={
|
||||
"success_updates": success_updates,
|
||||
"failed_updates": failed_updates
|
||||
}
|
||||
)
|
||||
|
||||
if success_updates:
|
||||
for key in success_updates.keys():
|
||||
# 发送配置变更事件
|
||||
await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=getattr(settings, key, None),
|
||||
change_type="update"
|
||||
))
|
||||
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
message="所有配置项更新成功",
|
||||
@@ -231,16 +254,16 @@ async def get_progress(request: Request, process_type: str, _: schemas.TokenPayl
|
||||
"""
|
||||
实时获取处理进度,返回格式为SSE
|
||||
"""
|
||||
progress = ProgressHelper()
|
||||
progress = ProgressHelper(process_type)
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while not global_vars.is_system_stopped:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
detail = progress.get(process_type)
|
||||
detail = progress.get()
|
||||
yield f"data: {json.dumps(detail)}\n\n"
|
||||
await asyncio.sleep(0.2)
|
||||
await asyncio.sleep(0.5)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
@@ -248,8 +271,8 @@ async def get_progress(request: Request, process_type: str, _: schemas.TokenPayl
|
||||
|
||||
|
||||
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
|
||||
def get_setting(key: str,
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
async def get_setting(key: str,
|
||||
_: User = Depends(get_current_active_user_async)):
|
||||
"""
|
||||
查询系统设置(仅管理员)
|
||||
"""
|
||||
@@ -263,23 +286,46 @@ def get_setting(key: str,
|
||||
|
||||
|
||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
||||
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
async def set_setting(
|
||||
key: str,
|
||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
):
|
||||
"""
|
||||
更新系统设置(仅管理员)
|
||||
"""
|
||||
if hasattr(settings, key):
|
||||
success, message = settings.update_setting(key=key, value=value)
|
||||
if success:
|
||||
# 发送配置变更事件
|
||||
await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=value,
|
||||
change_type="update"
|
||||
))
|
||||
elif success is None:
|
||||
success = True
|
||||
return schemas.Response(success=success, message=message)
|
||||
elif key in {item.value for item in SystemConfigKey}:
|
||||
SystemConfigOper().set(key, value)
|
||||
if isinstance(value, list):
|
||||
value = list(filter(None, value))
|
||||
value = value if value else None
|
||||
success = await SystemConfigOper().async_set(key, value)
|
||||
if success:
|
||||
# 发送配置变更事件
|
||||
await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=value,
|
||||
change_type="update"
|
||||
))
|
||||
return schemas.Response(success=True)
|
||||
else:
|
||||
return schemas.Response(success=False, message=f"配置项 '{key}' 不存在")
|
||||
|
||||
|
||||
@router.get("/message", summary="实时消息")
|
||||
async def get_message(request: Request, role: str = "system", _: schemas.TokenPayload = Depends(verify_resource_token)):
|
||||
async def get_message(request: Request, role: Optional[str] = "system",
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token)):
|
||||
"""
|
||||
实时获取系统消息,返回格式为SSE
|
||||
"""
|
||||
@@ -300,67 +346,113 @@ async def get_message(request: Request, role: str = "system", _: schemas.TokenPa
|
||||
|
||||
|
||||
@router.get("/logging", summary="实时日志")
|
||||
async def get_logging(request: Request, length: int = 50, logfile: str = "moviepilot.log",
|
||||
async def get_logging(request: Request, length: Optional[int] = 50, logfile: Optional[str] = "moviepilot.log",
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token)):
|
||||
"""
|
||||
实时获取系统日志
|
||||
length = -1 时, 返回text/plain
|
||||
否则 返回格式SSE
|
||||
"""
|
||||
log_path = settings.LOG_PATH / logfile
|
||||
base_path = AsyncPath(settings.LOG_PATH)
|
||||
log_path = base_path / logfile
|
||||
|
||||
if not SecurityUtils.is_safe_path(settings.LOG_PATH, log_path, allowed_suffixes={".log"}):
|
||||
if not await SecurityUtils.async_is_safe_path(base_path=base_path, user_path=log_path, allowed_suffixes={".log"}):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
if not await log_path.exists() or not await log_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
async def log_generator():
|
||||
try:
|
||||
# 使用固定大小的双向队列来限制内存使用
|
||||
lines_queue = deque(maxlen=max(length, 50))
|
||||
# 使用 aiofiles 异步读取文件
|
||||
async with aiofiles.open(log_path, mode="r", encoding="utf-8") as f:
|
||||
# 逐行读取文件,将每一行存入队列
|
||||
file_content = await f.read()
|
||||
for line in file_content.splitlines():
|
||||
# 获取文件大小
|
||||
file_stat = await log_path.stat()
|
||||
file_size = file_stat.st_size
|
||||
|
||||
# 读取历史日志
|
||||
async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as f:
|
||||
# 优化大文件读取策略
|
||||
if file_size > 100 * 1024:
|
||||
# 只读取最后100KB的内容
|
||||
bytes_to_read = min(file_size, 100 * 1024)
|
||||
position = file_size - bytes_to_read
|
||||
await f.seek(position)
|
||||
content = await f.read()
|
||||
# 找到第一个完整的行
|
||||
first_newline = content.find('\n')
|
||||
if first_newline != -1:
|
||||
content = content[first_newline + 1:]
|
||||
else:
|
||||
# 小文件直接读取全部内容
|
||||
content = await f.read()
|
||||
|
||||
# 按行分割并添加到队列,只保留非空行
|
||||
lines = [line.strip() for line in content.splitlines() if line.strip()]
|
||||
# 只取最后N行
|
||||
for line in lines[-max(length, 50):]:
|
||||
lines_queue.append(line)
|
||||
for line in lines_queue:
|
||||
yield f"data: {line}\n\n"
|
||||
|
||||
# 输出历史日志
|
||||
for line in lines_queue:
|
||||
yield f"data: {line}\n\n"
|
||||
|
||||
# 实时监听新日志
|
||||
async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as f:
|
||||
# 移动文件指针到文件末尾,继续监听新增内容
|
||||
await f.seek(0, 2)
|
||||
# 记录初始文件大小
|
||||
initial_stat = await log_path.stat()
|
||||
initial_size = initial_stat.st_size
|
||||
# 实时监听新日志,使用更短的轮询间隔
|
||||
while not global_vars.is_system_stopped:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
line = await f.readline()
|
||||
if not line:
|
||||
# 检查文件是否有新内容
|
||||
current_stat = await log_path.stat()
|
||||
current_size = current_stat.st_size
|
||||
if current_size > initial_size:
|
||||
# 文件有新内容,读取新行
|
||||
line = await f.readline()
|
||||
if line:
|
||||
line = line.strip()
|
||||
if line:
|
||||
yield f"data: {line}\n\n"
|
||||
initial_size = current_size
|
||||
else:
|
||||
# 没有新内容,短暂等待
|
||||
await asyncio.sleep(0.5)
|
||||
continue
|
||||
yield f"data: {line}\n\n"
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as err:
|
||||
logger.error(f"日志读取异常: {err}")
|
||||
yield f"data: 日志读取异常: {err}\n\n"
|
||||
|
||||
# 根据length参数返回不同的响应
|
||||
if length == -1:
|
||||
# 返回全部日志作为文本响应
|
||||
if not log_path.exists():
|
||||
if not await log_path.exists():
|
||||
return Response(content="日志文件不存在!", media_type="text/plain")
|
||||
with open(log_path, "r", encoding='utf-8') as file:
|
||||
text = file.read()
|
||||
# 倒序输出
|
||||
text = "\n".join(text.split("\n")[::-1])
|
||||
return Response(content=text, media_type="text/plain")
|
||||
try:
|
||||
# 使用 aiofiles 异步读取文件
|
||||
async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as file:
|
||||
text = await file.read()
|
||||
# 倒序输出
|
||||
text = "\n".join(text.split("\n")[::-1])
|
||||
return Response(content=text, media_type="text/plain")
|
||||
except Exception as e:
|
||||
return Response(content=f"读取日志文件失败: {e}", media_type="text/plain")
|
||||
else:
|
||||
# 返回SSE流响应
|
||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
|
||||
def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询Github所有Release版本
|
||||
"""
|
||||
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||
version_res = await AsyncRequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||
f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
|
||||
if version_res:
|
||||
ver_json = version_res.json()
|
||||
@@ -372,7 +464,7 @@ def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
@router.get("/ruletest", summary="过滤规则测试", response_model=schemas.Response)
|
||||
def ruletest(title: str,
|
||||
rulegroup_name: str,
|
||||
subtitle: str = None,
|
||||
subtitle: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索
|
||||
@@ -402,30 +494,80 @@ def ruletest(title: str,
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
def nettest(url: str,
|
||||
proxy: bool,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
async def nettest(
|
||||
url: str,
|
||||
proxy: bool,
|
||||
include: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
):
|
||||
"""
|
||||
测试网络连通性
|
||||
"""
|
||||
# 记录开始的毫秒数
|
||||
start_time = datetime.now()
|
||||
headers = None
|
||||
# 当前使用的加速代理
|
||||
proxy_name = ""
|
||||
if "github" in url:
|
||||
# 这是github的连通性测试
|
||||
headers = settings.GITHUB_HEADERS
|
||||
if "{GITHUB_PROXY}" in url:
|
||||
url = url.replace(
|
||||
"{GITHUB_PROXY}", UrlUtils.standardize_base_url(settings.GITHUB_PROXY or "")
|
||||
)
|
||||
if settings.GITHUB_PROXY:
|
||||
proxy_name = "Github加速代理"
|
||||
if "{PIP_PROXY}" in url:
|
||||
url = url.replace(
|
||||
"{PIP_PROXY}",
|
||||
UrlUtils.standardize_base_url(
|
||||
settings.PIP_PROXY or "https://pypi.org/simple/"
|
||||
),
|
||||
)
|
||||
if settings.PIP_PROXY:
|
||||
proxy_name = "PIP加速代理"
|
||||
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
|
||||
result = RequestUtils(proxies=settings.PROXY if proxy else None,
|
||||
ua=settings.USER_AGENT).get_res(url)
|
||||
result = await AsyncRequestUtils(
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
ua=settings.NORMAL_USER_AGENT,
|
||||
).get_res(url)
|
||||
# 计时结束的毫秒数
|
||||
end_time = datetime.now()
|
||||
time = round((end_time - start_time).total_seconds() * 1000)
|
||||
# 计算相关秒数
|
||||
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)
|
||||
})
|
||||
if result is None:
|
||||
return schemas.Response(
|
||||
success=False, message=f"{proxy_name}无法连接", data={"time": time}
|
||||
)
|
||||
elif result.status_code == 200:
|
||||
if include and not re.search(r"%s" % include, result.text, re.IGNORECASE):
|
||||
# 通常是被加速代理跳转到其它页面了
|
||||
logger.error(f"{url} 的响应内容不匹配包含规则 {include}")
|
||||
if proxy_name:
|
||||
message = f"{proxy_name}已失效,请检查配置"
|
||||
else:
|
||||
message = f"无效响应,不匹配 {include}"
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message=message,
|
||||
data={"time": time},
|
||||
)
|
||||
return schemas.Response(success=True, data={"time": time})
|
||||
else:
|
||||
return schemas.Response(success=False, message="网络连接失败!")
|
||||
if proxy_name:
|
||||
# 加速代理失败
|
||||
message = f"{proxy_name}已失效,错误码:{result.status_code}"
|
||||
else:
|
||||
message = f"错误码:{result.status_code}"
|
||||
if "github" in url:
|
||||
# 非加速代理访问github
|
||||
if result.status_code == 401:
|
||||
message = "Github Token已失效,请检查配置"
|
||||
elif result.status_code in {403, 429}:
|
||||
message = "触发限流,请配置Github Token"
|
||||
return schemas.Response(success=False, message=message, data={"time": time})
|
||||
|
||||
|
||||
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
|
||||
@@ -456,26 +598,15 @@ def restart_system(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
重启系统(仅管理员)
|
||||
"""
|
||||
if not SystemUtils.can_restart():
|
||||
if not SystemHelper.can_restart():
|
||||
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
|
||||
# 标识停止事件
|
||||
global_vars.stop_system()
|
||||
# 执行重启
|
||||
ret, msg = SystemUtils.restart()
|
||||
ret, msg = SystemHelper.restart()
|
||||
return schemas.Response(success=ret, message=msg)
|
||||
|
||||
|
||||
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
|
||||
def reload_module(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
重新加载模块(仅管理员)
|
||||
"""
|
||||
ModuleManager().reload()
|
||||
Scheduler().init()
|
||||
Monitor().init()
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
||||
def run_scheduler(jobid: str,
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
@@ -490,7 +621,7 @@ def run_scheduler(jobid: str,
|
||||
|
||||
@router.get("/runscheduler2", summary="运行服务(API_TOKEN)", response_model=schemas.Response)
|
||||
def run_scheduler2(jobid: str,
|
||||
_: str = Depends(verify_apitoken)):
|
||||
_: Annotated[str, Depends(verify_apitoken)]):
|
||||
"""
|
||||
执行命令(API_TOKEN认证)
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from typing import List, Any
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.security import verify_token
|
||||
from app.schemas.types import MediaType
|
||||
@@ -12,28 +11,28 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason])
|
||||
def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询themoviedb所有季信息
|
||||
"""
|
||||
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
|
||||
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
|
||||
if seasons_info:
|
||||
return seasons_info
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_similar(tmdbid: int,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_similar(tmdbid: int,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询类似电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
medias = TmdbChain().movie_similar(tmdbid=tmdbid)
|
||||
medias = await TmdbChain().async_movie_similar(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
medias = TmdbChain().tv_similar(tmdbid=tmdbid)
|
||||
medias = await TmdbChain().async_tv_similar(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if medias:
|
||||
@@ -42,17 +41,17 @@ def tmdb_similar(tmdbid: int,
|
||||
|
||||
|
||||
@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_recommend(tmdbid: int,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_recommend(tmdbid: int,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询推荐电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
medias = TmdbChain().movie_recommend(tmdbid=tmdbid)
|
||||
medias = await TmdbChain().async_movie_recommend(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
medias = TmdbChain().tv_recommend(tmdbid=tmdbid)
|
||||
medias = await TmdbChain().async_tv_recommend(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if medias:
|
||||
@@ -60,89 +59,64 @@ def tmdb_recommend(tmdbid: int,
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/collection/{collection_id}", summary="系列合集详情", response_model=List[schemas.MediaInfo])
|
||||
async def tmdb_collection(collection_id: int,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据合集ID查询合集详情
|
||||
"""
|
||||
medias = await TmdbChain().async_tmdb_collection(collection_id=collection_id)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias][(page - 1) * count:page * count]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.MediaPerson])
|
||||
def tmdb_credits(tmdbid: int,
|
||||
type_name: str,
|
||||
page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_credits(tmdbid: int,
|
||||
type_name: str,
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询演员阵容,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
persons = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
|
||||
persons = await TmdbChain().async_movie_credits(tmdbid=tmdbid, page=page)
|
||||
elif mediatype == MediaType.TV:
|
||||
persons = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
|
||||
persons = await TmdbChain().async_tv_credits(tmdbid=tmdbid, page=page)
|
||||
else:
|
||||
return []
|
||||
return persons or []
|
||||
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||
def tmdb_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
return TmdbChain().person_detail(person_id=person_id)
|
||||
return await TmdbChain().async_person_detail(person_id=person_id)
|
||||
|
||||
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_person_credits(person_id: int,
|
||||
page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_person_credits(person_id: int,
|
||||
page: Optional[int] = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
medias = TmdbChain().person_credits(person_id=person_id, page=page)
|
||||
medias = await TmdbChain().async_person_credits(person_id=person_id, page=page)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/movies", summary="TMDB电影", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_movies(sort_by: str = "popularity.desc",
|
||||
with_genres: str = "",
|
||||
with_original_language: str = "",
|
||||
page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB电影信息
|
||||
"""
|
||||
return RecommendChain().tmdb_movies(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
|
||||
|
||||
@router.get("/tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_tvs(sort_by: str = "popularity.desc",
|
||||
with_genres: str = "",
|
||||
with_original_language: str = "",
|
||||
page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
return RecommendChain().tmdb_tvs(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
|
||||
|
||||
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_trending(page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
TMDB流行趋势
|
||||
"""
|
||||
return RecommendChain().tmdb_trending(page=page)
|
||||
|
||||
|
||||
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
|
||||
def tmdb_season_episodes(tmdbid: int, season: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def tmdb_season_episodes(tmdbid: int, season: int, episode_group: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询某季的所有信信息
|
||||
"""
|
||||
return TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)
|
||||
return await TmdbChain().async_tmdb_episodes(tmdbid=tmdbid, season=season, episode_group=episode_group)
|
||||
|
||||
197
app/api/endpoints/torrent.py
Normal file
197
app/api/endpoints/torrent.py
Normal file
@@ -0,0 +1,197 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.models import User
|
||||
from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async
|
||||
from app.utils.crypto import HashUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/cache", summary="获取种子缓存", response_model=schemas.Response)
|
||||
async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
获取当前种子缓存数据
|
||||
"""
|
||||
torrents_chain = TorrentsChain()
|
||||
|
||||
# 获取spider和rss两种缓存
|
||||
if settings.SUBSCRIBE_MODE == "rss":
|
||||
cache_info = await torrents_chain.async_get_torrents("rss")
|
||||
else:
|
||||
cache_info = await torrents_chain.async_get_torrents("spider")
|
||||
|
||||
# 统计信息
|
||||
torrent_count = sum(len(torrents) for torrents in cache_info.values())
|
||||
|
||||
# 转换为前端需要的格式
|
||||
torrent_data = []
|
||||
for domain, contexts in cache_info.items():
|
||||
for context in contexts:
|
||||
torrent_hash = HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}")
|
||||
torrent_data.append({
|
||||
"hash": torrent_hash,
|
||||
"domain": domain,
|
||||
"title": context.torrent_info.title,
|
||||
"description": context.torrent_info.description,
|
||||
"size": context.torrent_info.size,
|
||||
"pubdate": context.torrent_info.pubdate,
|
||||
"site_name": context.torrent_info.site_name,
|
||||
"media_name": context.media_info.title if context.media_info else "",
|
||||
"media_year": context.media_info.year if context.media_info else "",
|
||||
"media_type": context.media_info.type if context.media_info else "",
|
||||
"season_episode": context.meta_info.season_episode if context.meta_info else "",
|
||||
"resource_term": context.meta_info.resource_term if context.meta_info else "",
|
||||
"enclosure": context.torrent_info.enclosure,
|
||||
"page_url": context.torrent_info.page_url,
|
||||
"poster_path": context.media_info.get_poster_image() if context.media_info else "",
|
||||
"backdrop_path": context.media_info.get_backdrop_image() if context.media_info else ""
|
||||
})
|
||||
|
||||
return schemas.Response(success=True, data={
|
||||
"count": torrent_count,
|
||||
"sites": len(cache_info),
|
||||
"data": torrent_data
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/cache/{domain}/{torrent_hash}", summary="删除指定种子缓存", response_model=schemas.Response)
|
||||
async def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
删除指定的种子缓存
|
||||
:param domain: 站点域名
|
||||
:param torrent_hash: 种子hash(使用title+description的md5)
|
||||
:param _: 当前用户,必须是超级用户
|
||||
"""
|
||||
|
||||
torrents_chain = TorrentsChain()
|
||||
|
||||
try:
|
||||
# 获取当前缓存
|
||||
cache_data = await torrents_chain.async_get_torrents()
|
||||
|
||||
if domain not in cache_data:
|
||||
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
|
||||
|
||||
# 查找并删除指定种子
|
||||
original_count = len(cache_data[domain])
|
||||
cache_data[domain] = [
|
||||
context for context in cache_data[domain]
|
||||
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") != torrent_hash
|
||||
]
|
||||
|
||||
if len(cache_data[domain]) == original_count:
|
||||
return schemas.Response(success=False, message="未找到指定的种子")
|
||||
|
||||
# 保存更新后的缓存
|
||||
await torrents_chain.async_save_cache(cache_data, torrents_chain.cache_file)
|
||||
|
||||
return schemas.Response(success=True, message="种子删除成功")
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=f"删除失败:{str(e)}")
|
||||
|
||||
|
||||
@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response)
|
||||
async def clear_cache(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
清理所有种子缓存
|
||||
"""
|
||||
torrents_chain = TorrentsChain()
|
||||
|
||||
try:
|
||||
await torrents_chain.async_clear_torrents()
|
||||
return schemas.Response(success=True, message="种子缓存清理完成")
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=f"清理失败:{str(e)}")
|
||||
|
||||
|
||||
@router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response)
|
||||
def refresh_cache(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
刷新种子缓存
|
||||
"""
|
||||
from app.chain.torrents import TorrentsChain
|
||||
|
||||
torrents_chain = TorrentsChain()
|
||||
|
||||
try:
|
||||
result = torrents_chain.refresh()
|
||||
|
||||
# 统计刷新结果
|
||||
total_count = sum(len(torrents) for torrents in result.values())
|
||||
sites_count = len(result)
|
||||
|
||||
return schemas.Response(success=True, message=f"缓存刷新完成,共刷新 {sites_count} 个站点,{total_count} 个种子")
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=f"刷新失败:{str(e)}")
|
||||
|
||||
|
||||
@router.post("/cache/reidentify/{domain}/{torrent_hash}", summary="重新识别种子", response_model=schemas.Response)
|
||||
async def reidentify_cache(domain: str, torrent_hash: str,
|
||||
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
重新识别指定的种子
|
||||
:param domain: 站点域名
|
||||
:param torrent_hash: 种子hash(使用title+description的md5)
|
||||
:param tmdbid: 手动指定的TMDB ID
|
||||
:param doubanid: 手动指定的豆瓣ID
|
||||
:param _: 当前用户,必须是超级用户
|
||||
"""
|
||||
|
||||
torrents_chain = TorrentsChain()
|
||||
media_chain = MediaChain()
|
||||
|
||||
try:
|
||||
# 获取当前缓存
|
||||
cache_data = await torrents_chain.async_get_torrents()
|
||||
|
||||
if domain not in cache_data:
|
||||
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
|
||||
|
||||
# 查找指定种子
|
||||
target_context = None
|
||||
for context in cache_data[domain]:
|
||||
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") == torrent_hash:
|
||||
target_context = context
|
||||
break
|
||||
|
||||
if not target_context:
|
||||
return schemas.Response(success=False, message="未找到指定的种子")
|
||||
|
||||
# 重新识别
|
||||
meta = MetaInfo(title=target_context.torrent_info.title, subtitle=target_context.torrent_info.description)
|
||||
if tmdbid or doubanid:
|
||||
# 手动指定媒体信息
|
||||
mediainfo = await media_chain.async_recognize_media(meta=meta, tmdbid=tmdbid, doubanid=doubanid)
|
||||
else:
|
||||
# 自动重新识别
|
||||
mediainfo = await media_chain.async_recognize_by_meta(meta)
|
||||
|
||||
if not mediainfo:
|
||||
# 创建空的媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
else:
|
||||
# 清理多余数据
|
||||
mediainfo.clear()
|
||||
|
||||
# 更新上下文中的媒体信息
|
||||
target_context.media_info = mediainfo
|
||||
|
||||
# 保存更新后的缓存
|
||||
await torrents_chain.async_save_cache(cache_data, TorrentsChain().cache_file)
|
||||
|
||||
return schemas.Response(success=True, message="重新识别完成", data={
|
||||
"media_name": mediainfo.title if mediainfo else "",
|
||||
"media_year": mediainfo.year if mediainfo else "",
|
||||
"media_type": mediainfo.type.value if mediainfo and mediainfo.type else ""
|
||||
})
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=f"重新识别失败:{str(e)}")
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -8,11 +8,14 @@ from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.storage import StorageChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.core.security import verify_token, verify_apitoken
|
||||
from app.db import get_db
|
||||
from app.db.models import User
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.schemas import MediaType, FileItem, ManualTransferItem
|
||||
|
||||
router = APIRouter()
|
||||
@@ -35,11 +38,19 @@ def query_name(path: str, filetype: str,
|
||||
if not new_path:
|
||||
return schemas.Response(success=False, message="未识别到新名称")
|
||||
if filetype == "dir":
|
||||
parents = Path(new_path).parents
|
||||
if len(parents) > 2:
|
||||
new_name = parents[1].name
|
||||
media_path = DirectoryHelper.get_media_root_path(
|
||||
rename_format=settings.RENAME_FORMAT(mediainfo.type),
|
||||
rename_path=Path(new_path),
|
||||
)
|
||||
if media_path:
|
||||
new_name = media_path.name
|
||||
else:
|
||||
new_name = parents[0].name
|
||||
# fallback
|
||||
parents = Path(new_path).parents
|
||||
if len(parents) > 2:
|
||||
new_name = parents[1].name
|
||||
else:
|
||||
new_name = parents[0].name
|
||||
else:
|
||||
new_name = Path(new_path).name
|
||||
return schemas.Response(success=True, data={
|
||||
@@ -48,7 +59,7 @@ def query_name(path: str, filetype: str,
|
||||
|
||||
|
||||
@router.get("/queue", summary="查询整理队列", response_model=List[schemas.TransferJob])
|
||||
def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询整理队列
|
||||
:param _: Token校验
|
||||
@@ -57,21 +68,23 @@ def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.delete("/queue", summary="从整理队列中删除任务", response_model=schemas.Response)
|
||||
def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询整理队列
|
||||
:param fileitem: 文件项
|
||||
:param _: Token校验
|
||||
"""
|
||||
TransferChain().remove_from_queue(fileitem)
|
||||
# 取消整理
|
||||
global_vars.stop_transfer(fileitem.path)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
||||
def manual_transfer(transer_item: ManualTransferItem,
|
||||
background: bool = False,
|
||||
background: Optional[bool] = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
手动转移,文件或历史记录,支持自定义剧集识别格式
|
||||
:param transer_item: 手工整理项
|
||||
@@ -98,7 +111,7 @@ def manual_transfer(transer_item: ManualTransferItem,
|
||||
if history.dest_fileitem:
|
||||
# 删除旧的已整理文件
|
||||
dest_fileitem = FileItem(**history.dest_fileitem)
|
||||
state = StorageChain().delete_media_file(dest_fileitem, mtype=MediaType(history.type))
|
||||
state = StorageChain().delete_media_file(dest_fileitem)
|
||||
if not state:
|
||||
return schemas.Response(success=False, message=f"{dest_fileitem.path} 删除失败")
|
||||
|
||||
@@ -146,6 +159,7 @@ def manual_transfer(transer_item: ManualTransferItem,
|
||||
doubanid=transer_item.doubanid,
|
||||
mtype=mtype,
|
||||
season=transer_item.season,
|
||||
episode_group=transer_item.episode_group,
|
||||
transfer_type=transer_item.transfer_type,
|
||||
epformat=epformat,
|
||||
min_filesize=transer_item.min_filesize,
|
||||
@@ -165,7 +179,7 @@ def manual_transfer(transer_item: ManualTransferItem,
|
||||
|
||||
|
||||
@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response)
|
||||
def now(_: str = Depends(verify_apitoken)) -> Any:
|
||||
def now(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
立即执行下载器文件整理 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import base64
|
||||
import re
|
||||
from typing import Any, List, Union
|
||||
from typing import Annotated, Any, List, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.core.security import get_password_hash
|
||||
from app.db import get_db
|
||||
from app.db import get_async_db
|
||||
from app.db.models.user import User
|
||||
from app.db.user_oper import get_current_active_superuser, get_current_active_user
|
||||
from app.db.user_oper import get_current_active_superuser_async, \
|
||||
get_current_active_user_async, get_current_active_user
|
||||
from app.db.userconfig_oper import UserConfigOper
|
||||
from app.utils.otp import OtpUtils
|
||||
|
||||
@@ -17,45 +18,43 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="所有用户", response_model=List[schemas.User])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_superuser),
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
current_user: User = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""
|
||||
查询用户列表
|
||||
"""
|
||||
users = current_user.list(db)
|
||||
return users
|
||||
return await current_user.async_list(db)
|
||||
|
||||
|
||||
@router.post("/", summary="新增用户", response_model=schemas.Response)
|
||||
def create_user(
|
||||
async def create_user(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
user_in: schemas.UserCreate,
|
||||
current_user: User = Depends(get_current_active_superuser),
|
||||
current_user: User = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""
|
||||
新增用户
|
||||
"""
|
||||
user = current_user.get_by_name(db, name=user_in.name)
|
||||
user = await current_user.async_get_by_name(db, name=user_in.name)
|
||||
if user:
|
||||
return schemas.Response(success=False, message="用户已存在")
|
||||
user_info = user_in.dict()
|
||||
if user_info.get("password"):
|
||||
user_info["hashed_password"] = get_password_hash(user_info["password"])
|
||||
user_info.pop("password")
|
||||
user = User(**user_info)
|
||||
user.create(db)
|
||||
return schemas.Response(success=True)
|
||||
user = await User(**user_info).async_create(db)
|
||||
return schemas.Response(success=True if user else False)
|
||||
|
||||
|
||||
@router.put("/", summary="更新用户", response_model=schemas.Response)
|
||||
def update_user(
|
||||
async def update_user(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
user_in: schemas.UserUpdate,
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
current_user: User = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""
|
||||
更新用户
|
||||
@@ -69,24 +68,24 @@ def update_user(
|
||||
message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位")
|
||||
user_info["hashed_password"] = get_password_hash(user_info["password"])
|
||||
user_info.pop("password")
|
||||
user = User.get_by_id(db, user_id=user_info["id"])
|
||||
user = await current_user.async_get_by_id(db, user_id=user_info["id"])
|
||||
user_name = user_info.get("name")
|
||||
if not user_name:
|
||||
return schemas.Response(success=False, message="用户名不能为空")
|
||||
# 新用户名去重
|
||||
users = User.list(db)
|
||||
users = await current_user.async_list(db)
|
||||
for u in users:
|
||||
if u.name == user_name and u.id != user_info["id"]:
|
||||
return schemas.Response(success=False, message="用户名已被使用")
|
||||
if not user:
|
||||
return schemas.Response(success=False, message="用户不存在")
|
||||
user.update(db, user_info)
|
||||
await user.async_update(db, user_info)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/current", summary="当前登录用户信息", response_model=schemas.User)
|
||||
def read_current_user(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
async def read_current_user(
|
||||
current_user: User = Depends(get_current_active_user_async)
|
||||
) -> Any:
|
||||
"""
|
||||
当前登录用户信息
|
||||
@@ -95,18 +94,18 @@ def read_current_user(
|
||||
|
||||
|
||||
@router.post("/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response)
|
||||
def upload_avatar(user_id: int, db: Session = Depends(get_db), file: UploadFile = File(...),
|
||||
_: User = Depends(get_current_active_user)):
|
||||
async def upload_avatar(user_id: int, db: AsyncSession = Depends(get_async_db), file: UploadFile = File(...),
|
||||
_: User = Depends(get_current_active_user_async)):
|
||||
"""
|
||||
上传用户头像
|
||||
"""
|
||||
# 将文件转换为Base64
|
||||
file_base64 = base64.b64encode(file.file.read())
|
||||
# 更新到用户表
|
||||
user = User.get(db, user_id)
|
||||
user = await User.async_get(db, user_id)
|
||||
if not user:
|
||||
return schemas.Response(success=False, message="用户不存在")
|
||||
user.update(db, {
|
||||
await user.async_update(db, {
|
||||
"avatar": f"data:image/ico;base64,{file_base64}"
|
||||
})
|
||||
return schemas.Response(success=True, message=file.filename)
|
||||
@@ -121,31 +120,31 @@ def otp_generate(
|
||||
|
||||
|
||||
@router.post('/otp/judge', summary='判断otp验证是否通过', response_model=schemas.Response)
|
||||
def otp_judge(
|
||||
async def otp_judge(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
current_user: User = Depends(get_current_active_user_async)
|
||||
) -> 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))
|
||||
await current_user.async_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)
|
||||
async def otp_disable(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
current_user: User = Depends(get_current_active_user_async)
|
||||
) -> Any:
|
||||
current_user.update_otp_by_name(db, current_user.name, False, "")
|
||||
await current_user.async_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)
|
||||
async def otp_enable(userid: str, db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
user: User = await User.async_get_by_name(db, userid)
|
||||
if not user:
|
||||
return schemas.Response(success=False)
|
||||
return schemas.Response(success=user.is_otp)
|
||||
@@ -164,8 +163,11 @@ def get_config(key: str,
|
||||
|
||||
|
||||
@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response)
|
||||
def set_config(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||
current_user: User = Depends(get_current_active_user)):
|
||||
def set_config(
|
||||
key: str,
|
||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""
|
||||
更新用户配置
|
||||
"""
|
||||
@@ -174,49 +176,49 @@ def set_config(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||
|
||||
|
||||
@router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response)
|
||||
def delete_user_by_id(
|
||||
async def delete_user_by_id(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_active_superuser),
|
||||
current_user: User = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""
|
||||
通过唯一ID删除用户
|
||||
"""
|
||||
user = current_user.get_by_id(db, user_id=user_id)
|
||||
user = await current_user.async_get_by_id(db, user_id=user_id)
|
||||
if not user:
|
||||
return schemas.Response(success=False, message="用户不存在")
|
||||
user.delete_by_id(db, user_id)
|
||||
await current_user.async_delete(db, user_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response)
|
||||
def delete_user_by_name(
|
||||
async def delete_user_by_name(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
user_name: str,
|
||||
current_user: User = Depends(get_current_active_superuser),
|
||||
current_user: User = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""
|
||||
通过用户名删除用户
|
||||
"""
|
||||
user = current_user.get_by_name(db, name=user_name)
|
||||
user = await current_user.async_get_by_name(db, name=user_name)
|
||||
if not user:
|
||||
return schemas.Response(success=False, message="用户不存在")
|
||||
user.delete_by_name(db, user_name)
|
||||
await current_user.async_delete(db, user.id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/{username}", summary="用户详情", response_model=schemas.User)
|
||||
def read_user_by_name(
|
||||
async def read_user_by_name(
|
||||
username: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user_async),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
) -> Any:
|
||||
"""
|
||||
查询用户详情
|
||||
"""
|
||||
user = current_user.get_by_name(db, name=username)
|
||||
user = await current_user.async_get_by_name(db, name=username)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any
|
||||
from typing import Any, Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Request, Depends
|
||||
|
||||
@@ -19,7 +19,7 @@ def start_webhook_chain(body: Any, form: Any, args: Any):
|
||||
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||
async def webhook_message(background_tasks: BackgroundTasks,
|
||||
request: Request,
|
||||
_: str = Depends(verify_apitoken)
|
||||
_: Annotated[str, Depends(verify_apitoken)]
|
||||
) -> Any:
|
||||
"""
|
||||
Webhook响应,配置请求中需要添加参数:token=API_TOKEN&source=媒体服务器名
|
||||
@@ -32,8 +32,8 @@ async def webhook_message(background_tasks: BackgroundTasks,
|
||||
|
||||
|
||||
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||
def webhook_message(background_tasks: BackgroundTasks,
|
||||
request: Request, _: str = Depends(verify_apitoken)) -> Any:
|
||||
async def webhook_message(background_tasks: BackgroundTasks,
|
||||
request: Request, _: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
Webhook响应,配置请求中需要添加参数:token=API_TOKEN&source=媒体服务器名
|
||||
"""
|
||||
|
||||
310
app/api/endpoints/workflow.py
Normal file
310
app/api/endpoints/workflow.py
Normal file
@@ -0,0 +1,310 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.workflow import WorkflowChain
|
||||
from app.core.config import global_vars
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
from app.core.workflow import WorkFlowManager
|
||||
from app.db import get_async_db, get_db
|
||||
from app.db.models import Workflow
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
from app.helper.workflow import WorkflowHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import EventType, EVENT_TYPE_NAMES
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="所有工作流", response_model=List[schemas.Workflow])
|
||||
async def list_workflows(db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取工作流列表
|
||||
"""
|
||||
return await WorkflowOper(db).async_list()
|
||||
|
||||
|
||||
@router.post("/", summary="创建工作流", response_model=schemas.Response)
|
||||
async def create_workflow(workflow: schemas.Workflow,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
创建工作流
|
||||
"""
|
||||
if workflow.name and await WorkflowOper(db).async_get_by_name(workflow.name):
|
||||
return schemas.Response(success=False, message="已存在相同名称的工作流")
|
||||
if not workflow.add_time:
|
||||
workflow.add_time = datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S")
|
||||
if not workflow.state:
|
||||
workflow.state = "P"
|
||||
if not workflow.trigger_type:
|
||||
workflow.trigger_type = "timer"
|
||||
workflow_obj = Workflow(**workflow.dict())
|
||||
await workflow_obj.async_create(db)
|
||||
return schemas.Response(success=True, message="创建工作流成功")
|
||||
|
||||
|
||||
@router.get("/plugin/actions", summary="查询插件动作", response_model=List[dict])
|
||||
def list_plugin_actions(plugin_id: str = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取所有动作
|
||||
"""
|
||||
return PluginManager().get_plugin_actions(plugin_id)
|
||||
|
||||
|
||||
@router.get("/actions", summary="所有动作", response_model=List[dict])
|
||||
async def list_actions(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取所有动作
|
||||
"""
|
||||
return WorkFlowManager().list_actions()
|
||||
|
||||
|
||||
@router.get("/event_types", summary="获取所有事件类型", response_model=List[dict])
|
||||
async def get_event_types(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取所有事件类型
|
||||
"""
|
||||
return [{
|
||||
"title": EVENT_TYPE_NAMES.get(event_type, event_type.name),
|
||||
"value": event_type.value
|
||||
} for event_type in EventType]
|
||||
|
||||
|
||||
@router.post("/share", summary="分享工作流", response_model=schemas.Response)
|
||||
async def workflow_share(
|
||||
workflow: schemas.WorkflowShare,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
分享工作流
|
||||
"""
|
||||
if not workflow.id or not workflow.share_title or not workflow.share_user:
|
||||
return schemas.Response(success=False, message="请填写工作流ID、分享标题和分享人")
|
||||
|
||||
state, errmsg = await WorkflowHelper().async_workflow_share(workflow_id=workflow.id,
|
||||
share_title=workflow.share_title or "",
|
||||
share_comment=workflow.share_comment or "",
|
||||
share_user=workflow.share_user or "")
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
|
||||
async def workflow_share_delete(
|
||||
share_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除分享
|
||||
"""
|
||||
state, errmsg = await WorkflowHelper().async_share_delete(share_id=share_id)
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.post("/fork", summary="复用工作流", response_model=schemas.Response)
|
||||
async def workflow_fork(
|
||||
workflow: schemas.WorkflowShare,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.User = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
复用工作流
|
||||
"""
|
||||
if not workflow.name:
|
||||
return schemas.Response(success=False, message="工作流名称不能为空")
|
||||
|
||||
# 解析JSON数据,添加错误处理
|
||||
try:
|
||||
actions = json.loads(workflow.actions or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return schemas.Response(success=False, message="actions字段JSON格式错误")
|
||||
|
||||
try:
|
||||
flows = json.loads(workflow.flows or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return schemas.Response(success=False, message="flows字段JSON格式错误")
|
||||
|
||||
try:
|
||||
context = json.loads(workflow.context or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return schemas.Response(success=False, message="context字段JSON格式错误")
|
||||
|
||||
# 创建工作流
|
||||
workflow_dict = {
|
||||
"name": workflow.name,
|
||||
"description": workflow.description,
|
||||
"timer": workflow.timer,
|
||||
"trigger_type": workflow.trigger_type or "timer",
|
||||
"event_type": workflow.event_type,
|
||||
"event_conditions": json.loads(workflow.event_conditions or "{}") if workflow.event_conditions else {},
|
||||
"actions": actions,
|
||||
"flows": flows,
|
||||
"context": context,
|
||||
"state": "P" # 默认暂停状态
|
||||
}
|
||||
|
||||
# 检查名称是否重复
|
||||
workflow_oper = WorkflowOper(db)
|
||||
if await workflow_oper.async_get_by_name(workflow_dict["name"]):
|
||||
return schemas.Response(success=False, message="已存在相同名称的工作流")
|
||||
|
||||
# 创建新工作流
|
||||
workflow = await Workflow(**workflow_dict).async_create(db)
|
||||
|
||||
# 更新复用次数
|
||||
if workflow:
|
||||
await WorkflowHelper().async_workflow_fork(share_id=workflow.id)
|
||||
|
||||
return schemas.Response(success=True, message="复用成功")
|
||||
|
||||
|
||||
@router.get("/shares", summary="查询分享的工作流", response_model=List[schemas.WorkflowShare])
|
||||
async def workflow_shares(
|
||||
name: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询分享的工作流
|
||||
"""
|
||||
return await WorkflowHelper().async_get_shares(name=name, page=page, count=count)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response)
|
||||
def run_workflow(workflow_id: int,
|
||||
from_begin: Optional[bool] = True,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
执行工作流
|
||||
"""
|
||||
state, errmsg = WorkflowChain().process(workflow_id, from_begin=from_begin)
|
||||
if not state:
|
||||
return schemas.Response(success=False, message=errmsg)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response)
|
||||
def start_workflow(workflow_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
启用工作流
|
||||
"""
|
||||
workflow = WorkflowOper(db).get(workflow_id)
|
||||
if not workflow:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
if not workflow.trigger_type or workflow.trigger_type == "timer":
|
||||
# 添加定时任务
|
||||
Scheduler().update_workflow_job(workflow)
|
||||
else:
|
||||
# 事件触发:添加到事件触发器
|
||||
WorkFlowManager().load_workflow_events(workflow_id)
|
||||
# 更新状态
|
||||
workflow.update_state(db, workflow_id, "W")
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response)
|
||||
def pause_workflow(workflow_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
停用工作流
|
||||
"""
|
||||
workflow = WorkflowOper(db).get(workflow_id)
|
||||
if not workflow:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
# 根据触发类型进行不同处理
|
||||
if workflow.trigger_type == "timer":
|
||||
# 定时触发:移除定时任务
|
||||
Scheduler().remove_workflow_job(workflow)
|
||||
elif workflow.trigger_type == "event":
|
||||
# 事件触发:从事件触发器中移除
|
||||
WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type)
|
||||
# 停止工作流
|
||||
global_vars.stop_workflow(workflow_id)
|
||||
# 更新状态
|
||||
workflow.update_state(db, workflow_id, "P")
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response)
|
||||
async def reset_workflow(workflow_id: int,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
重置工作流
|
||||
"""
|
||||
workflow = await WorkflowOper(db).async_get(workflow_id)
|
||||
if not workflow:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
# 停止工作流
|
||||
global_vars.stop_workflow(workflow_id)
|
||||
# 重置工作流
|
||||
await Workflow.async_reset(db, workflow_id, reset_count=True)
|
||||
# 删除缓存
|
||||
SystemConfigOper().delete(f"WorkflowCache-{workflow_id}")
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/{workflow_id}", summary="工作流详情", response_model=schemas.Workflow)
|
||||
async def get_workflow(workflow_id: int,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取工作流详情
|
||||
"""
|
||||
return await WorkflowOper(db).async_get(workflow_id)
|
||||
|
||||
|
||||
@router.put("/{workflow_id}", summary="更新工作流", response_model=schemas.Response)
|
||||
def update_workflow(workflow: schemas.Workflow,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
更新工作流
|
||||
"""
|
||||
if not workflow.id:
|
||||
return schemas.Response(success=False, message="工作流ID不能为空")
|
||||
workflow_oper = WorkflowOper(db)
|
||||
wf = workflow_oper.get(workflow.id)
|
||||
if not wf:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
if not wf.trigger_type:
|
||||
workflow.trigger_type = "timer"
|
||||
wf.update(db, workflow.dict())
|
||||
# 更新后的工作流对象
|
||||
updated_workflow = workflow_oper.get(workflow.id)
|
||||
# 更新定时任务
|
||||
Scheduler().update_workflow_job(updated_workflow)
|
||||
# 更新事件注册
|
||||
WorkFlowManager().update_workflow_event(updated_workflow)
|
||||
return schemas.Response(success=True, message="更新成功")
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}", summary="删除工作流", response_model=schemas.Response)
|
||||
def delete_workflow(workflow_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除工作流
|
||||
"""
|
||||
workflow = WorkflowOper(db).get(workflow_id)
|
||||
if not workflow:
|
||||
return schemas.Response(success=False, message="工作流不存在")
|
||||
if not workflow.trigger_type or workflow.trigger_type == "timer":
|
||||
# 定时触发:删除定时任务
|
||||
Scheduler().remove_workflow_job(workflow)
|
||||
else:
|
||||
# 事件触发:从事件触发器中移除
|
||||
WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type)
|
||||
# 删除工作流
|
||||
Workflow.delete(db, workflow_id)
|
||||
# 删除缓存
|
||||
SystemConfigOper().delete(f"WorkflowCache-{workflow_id}")
|
||||
return schemas.Response(success=True, message="删除成功")
|
||||
@@ -1,14 +1,16 @@
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.tvdb import TvdbChain
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_apikey
|
||||
from app.db import get_db
|
||||
from app.db import get_db, get_async_db
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.schemas import RadarrMovie, SonarrSeries
|
||||
from app.schemas.types import MediaType
|
||||
@@ -18,7 +20,7 @@ arr_router = APIRouter(tags=['servarr'])
|
||||
|
||||
|
||||
@arr_router.get("/system/status", summary="系统状态")
|
||||
def arr_system_status(_: str = Depends(verify_apikey)) -> Any:
|
||||
async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr系统状态
|
||||
"""
|
||||
@@ -72,7 +74,7 @@ def arr_system_status(_: str = Depends(verify_apikey)) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/qualityProfile", summary="质量配置")
|
||||
def arr_qualityProfile(_: str = Depends(verify_apikey)) -> Any:
|
||||
async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr质量配置
|
||||
"""
|
||||
@@ -113,7 +115,7 @@ def arr_qualityProfile(_: str = Depends(verify_apikey)) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/rootfolder", summary="根目录")
|
||||
def arr_rootfolder(_: str = Depends(verify_apikey)) -> Any:
|
||||
async def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr根目录
|
||||
"""
|
||||
@@ -129,7 +131,7 @@ def arr_rootfolder(_: str = Depends(verify_apikey)) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/tag", summary="标签")
|
||||
def arr_tag(_: str = Depends(verify_apikey)) -> Any:
|
||||
async def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr标签
|
||||
"""
|
||||
@@ -142,7 +144,7 @@ def arr_tag(_: str = Depends(verify_apikey)) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/languageprofile", summary="语言")
|
||||
def arr_languageprofile(_: str = Depends(verify_apikey)) -> Any:
|
||||
async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
模拟Radarr、Sonarr语言
|
||||
"""
|
||||
@@ -168,7 +170,7 @@ def arr_languageprofile(_: str = Depends(verify_apikey)) -> Any:
|
||||
|
||||
|
||||
@arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie])
|
||||
def arr_movies(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -> Any:
|
||||
async def arr_movies(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
查询Rardar电影
|
||||
"""
|
||||
@@ -239,7 +241,7 @@ def arr_movies(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -
|
||||
"""
|
||||
# 查询所有电影订阅
|
||||
result = []
|
||||
subscribes = Subscribe.list(db)
|
||||
subscribes = await Subscribe.async_list(db)
|
||||
for subscribe in subscribes:
|
||||
if subscribe.type != MediaType.MOVIE.value:
|
||||
continue
|
||||
@@ -259,7 +261,7 @@ def arr_movies(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -
|
||||
|
||||
|
||||
@arr_router.get("/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie])
|
||||
def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||
def arr_movie_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
查询Rardar电影 term: `tmdb:${id}`
|
||||
存在和不存在均不能返回错误
|
||||
@@ -305,11 +307,12 @@ def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(
|
||||
|
||||
|
||||
@arr_router.get("/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie)
|
||||
def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||
async def arr_movie(mid: int, _: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
查询Rardar电影订阅
|
||||
"""
|
||||
subscribe = Subscribe.get(db, mid)
|
||||
subscribe = await Subscribe.async_get(db, mid)
|
||||
if subscribe:
|
||||
return RadarrMovie(
|
||||
id=subscribe.id,
|
||||
@@ -331,25 +334,25 @@ def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_a
|
||||
|
||||
|
||||
@arr_router.post("/movie", summary="新增电影订阅")
|
||||
def arr_add_movie(movie: RadarrMovie,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(verify_apikey)
|
||||
) -> Any:
|
||||
async def arr_add_movie(_: Annotated[str, Depends(verify_apikey)],
|
||||
movie: RadarrMovie,
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
) -> Any:
|
||||
"""
|
||||
新增Rardar电影订阅
|
||||
"""
|
||||
# 检查订阅是否已存在
|
||||
subscribe = Subscribe.get_by_tmdbid(db, movie.tmdbId)
|
||||
subscribe = await Subscribe.async_get_by_tmdbid(db, movie.tmdbId)
|
||||
if subscribe:
|
||||
return {
|
||||
"id": subscribe.id
|
||||
}
|
||||
# 添加订阅
|
||||
sid, message = SubscribeChain().add(title=movie.title,
|
||||
year=movie.year,
|
||||
mtype=MediaType.MOVIE,
|
||||
tmdbid=movie.tmdbId,
|
||||
username="Seerr")
|
||||
sid, message = await SubscribeChain().async_add(title=movie.title,
|
||||
year=movie.year,
|
||||
mtype=MediaType.MOVIE,
|
||||
tmdbid=movie.tmdbId,
|
||||
username="Seerr")
|
||||
if sid:
|
||||
return {
|
||||
"id": sid
|
||||
@@ -362,13 +365,14 @@ def arr_add_movie(movie: RadarrMovie,
|
||||
|
||||
|
||||
@arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response)
|
||||
def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||
async def arr_remove_movie(mid: int, _: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
删除Rardar电影订阅
|
||||
"""
|
||||
subscribe = Subscribe.get(db, mid)
|
||||
subscribe = await Subscribe.async_get(db, mid)
|
||||
if subscribe:
|
||||
subscribe.delete(db, mid)
|
||||
await subscribe.async_delete(db, mid)
|
||||
return schemas.Response(success=True)
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -378,7 +382,7 @@ def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(v
|
||||
|
||||
|
||||
@arr_router.get("/series", summary="所有剧集", response_model=List[schemas.SonarrSeries])
|
||||
def arr_series(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -> Any:
|
||||
async def arr_series(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
查询Sonarr剧集
|
||||
"""
|
||||
@@ -486,7 +490,7 @@ def arr_series(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -
|
||||
"""
|
||||
# 查询所有电视剧订阅
|
||||
result = []
|
||||
subscribes = Subscribe.list(db)
|
||||
subscribes = await Subscribe.async_list(db)
|
||||
for subscribe in subscribes:
|
||||
if subscribe.type != MediaType.TV.value:
|
||||
continue
|
||||
@@ -514,100 +518,102 @@ def arr_series(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -
|
||||
|
||||
|
||||
@arr_router.get("/series/lookup", summary="查询剧集")
|
||||
def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||
def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
查询Sonarr剧集 term: `tvdb:${id}` title
|
||||
"""
|
||||
# 获取TVDBID
|
||||
if not term.startswith("tvdb:"):
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
|
||||
mtype=MediaType.TV)
|
||||
if not mediainfo:
|
||||
return [SonarrSeries()]
|
||||
tvdbid = mediainfo.tvdb_id
|
||||
if not tvdbid:
|
||||
return [SonarrSeries()]
|
||||
else:
|
||||
mediainfo = None
|
||||
tvdbid = int(term.replace("tvdb:", ""))
|
||||
|
||||
# 查询TVDB信息
|
||||
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
|
||||
if not tvdbinfo:
|
||||
return [SonarrSeries()]
|
||||
|
||||
# 季信息
|
||||
seas: List[int] = []
|
||||
sea_num = tvdbinfo.get('season')
|
||||
if sea_num:
|
||||
seas = list(range(1, int(sea_num) + 1))
|
||||
|
||||
# 根据TVDB查询媒体信息
|
||||
if not mediainfo:
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
|
||||
mtype=MediaType.TV)
|
||||
|
||||
# 查询是否存在
|
||||
exists = MediaChain().media_exists(mediainfo)
|
||||
if exists:
|
||||
hasfile = True
|
||||
# tvdbid 列表
|
||||
tvdbids: List[int] = []
|
||||
# 获取TVDBID
|
||||
if not term.startswith("tvdb:"):
|
||||
title = term.replace("+", " ")
|
||||
tvdbids = TvdbChain().get_tvdbid_by_name(title=title)
|
||||
else:
|
||||
hasfile = False
|
||||
tvdbid = int(term.replace("tvdb:", ""))
|
||||
tvdbids.append(tvdbid)
|
||||
|
||||
# 查询订阅信息
|
||||
seasons: List[dict] = []
|
||||
subscribes = Subscribe.get_by_tmdbid(db, mediainfo.tmdb_id)
|
||||
if subscribes:
|
||||
# 已监控
|
||||
monitored = True
|
||||
# 已监控季
|
||||
sub_seas = [sub.season for sub in subscribes]
|
||||
for sea in seas:
|
||||
if sea in sub_seas:
|
||||
seasons.append({
|
||||
"seasonNumber": sea,
|
||||
"monitored": True,
|
||||
})
|
||||
else:
|
||||
sonarr_series_list = []
|
||||
for tvdbid in tvdbids:
|
||||
# 查询TVDB信息
|
||||
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
|
||||
if not tvdbinfo:
|
||||
continue
|
||||
|
||||
# 季信息(只取默认季类型,排除特别季)
|
||||
sea_num = len([season for season in tvdbinfo.get('seasons') if
|
||||
season['type']['id'] == tvdbinfo.get('defaultSeasonType') and season['number'] > 0])
|
||||
if sea_num:
|
||||
seas = list(range(1, int(sea_num) + 1))
|
||||
|
||||
# 根据TVDB查询媒体信息
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('name')),
|
||||
mtype=MediaType.TV)
|
||||
if not mediainfo:
|
||||
continue
|
||||
# 查询是否存在
|
||||
exists = MediaChain().media_exists(mediainfo)
|
||||
if exists:
|
||||
hasfile = True
|
||||
else:
|
||||
hasfile = False
|
||||
|
||||
# 查询订阅信息
|
||||
seasons: List[dict] = []
|
||||
subscribes = Subscribe.get_by_tmdbid(db, mediainfo.tmdb_id)
|
||||
if subscribes:
|
||||
# 已监控
|
||||
monitored = True
|
||||
# 已监控季
|
||||
sub_seas = [sub.season for sub in subscribes]
|
||||
for sea in seas:
|
||||
if sea in sub_seas:
|
||||
seasons.append({
|
||||
"seasonNumber": sea,
|
||||
"monitored": True,
|
||||
})
|
||||
else:
|
||||
seasons.append({
|
||||
"seasonNumber": sea,
|
||||
"monitored": False,
|
||||
})
|
||||
subid = subscribes[-1].id
|
||||
else:
|
||||
subid = None
|
||||
monitored = False
|
||||
for sea in seas:
|
||||
seasons.append({
|
||||
"seasonNumber": sea,
|
||||
"monitored": False,
|
||||
})
|
||||
subid = subscribes[-1].id
|
||||
else:
|
||||
subid = None
|
||||
monitored = False
|
||||
for sea in seas:
|
||||
seasons.append({
|
||||
"seasonNumber": sea,
|
||||
"monitored": False,
|
||||
})
|
||||
sonarr_series = SonarrSeries(
|
||||
id=subid,
|
||||
title=mediainfo.title,
|
||||
seasonCount=len(seasons),
|
||||
seasons=seasons,
|
||||
remotePoster=mediainfo.get_poster_image(),
|
||||
year=mediainfo.year,
|
||||
tmdbId=mediainfo.tmdb_id,
|
||||
tvdbId=tvdbid,
|
||||
imdbId=mediainfo.imdb_id,
|
||||
profileId=1,
|
||||
languageProfileId=1,
|
||||
monitored=monitored,
|
||||
hasFile=hasfile,
|
||||
)
|
||||
sonarr_series_list.append(sonarr_series)
|
||||
|
||||
return [SonarrSeries(
|
||||
id=subid,
|
||||
title=mediainfo.title,
|
||||
seasonCount=len(seasons),
|
||||
seasons=seasons,
|
||||
remotePoster=mediainfo.get_poster_image(),
|
||||
year=mediainfo.year,
|
||||
tmdbId=mediainfo.tmdb_id,
|
||||
tvdbId=mediainfo.tvdb_id,
|
||||
imdbId=mediainfo.imdb_id,
|
||||
profileId=1,
|
||||
languageProfileId=1,
|
||||
qualityProfileId=1,
|
||||
isAvailable=True,
|
||||
monitored=monitored,
|
||||
hasFile=hasfile
|
||||
)]
|
||||
return sonarr_series_list if sonarr_series_list else [SonarrSeries()]
|
||||
|
||||
|
||||
@arr_router.get("/series/{tid}", summary="剧集详情")
|
||||
def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||
async def arr_serie(tid: int, _: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
查询Sonarr剧集
|
||||
"""
|
||||
subscribe = Subscribe.get(db, tid)
|
||||
subscribe = await Subscribe.async_get(db, tid)
|
||||
if subscribe:
|
||||
return SonarrSeries(
|
||||
id=subscribe.id,
|
||||
@@ -637,17 +643,17 @@ def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_a
|
||||
|
||||
|
||||
@arr_router.post("/series", summary="新增剧集订阅")
|
||||
def arr_add_series(tv: schemas.SonarrSeries,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(verify_apikey)) -> Any:
|
||||
async def arr_add_series(tv: schemas.SonarrSeries,
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
新增Sonarr剧集订阅
|
||||
"""
|
||||
# 检查订阅是否存在
|
||||
left_seasons = []
|
||||
for season in tv.seasons:
|
||||
subscribe = Subscribe.get_by_tmdbid(db, tmdbid=tv.tmdbId,
|
||||
season=season.get("seasonNumber"))
|
||||
subscribe = await Subscribe.async_get_by_tmdbid(db, tmdbid=tv.tmdbId,
|
||||
season=season.get("seasonNumber"))
|
||||
if subscribe:
|
||||
continue
|
||||
left_seasons.append(season)
|
||||
@@ -662,12 +668,12 @@ def arr_add_series(tv: schemas.SonarrSeries,
|
||||
for season in left_seasons:
|
||||
if not season.get("monitored"):
|
||||
continue
|
||||
sid, message = SubscribeChain().add(title=tv.title,
|
||||
year=tv.year,
|
||||
season=season.get("seasonNumber"),
|
||||
tmdbid=tv.tmdbId,
|
||||
mtype=MediaType.TV,
|
||||
username="Seerr")
|
||||
sid, message = await SubscribeChain().async_add(title=tv.title,
|
||||
year=tv.year,
|
||||
season=season.get("seasonNumber"),
|
||||
tmdbid=tv.tmdbId,
|
||||
mtype=MediaType.TV,
|
||||
username="Seerr")
|
||||
|
||||
if sid:
|
||||
return {
|
||||
@@ -680,14 +686,23 @@ def arr_add_series(tv: schemas.SonarrSeries,
|
||||
)
|
||||
|
||||
|
||||
@arr_router.put("/series", summary="更新剧集订阅")
|
||||
async def arr_update_series(tv: schemas.SonarrSeries, _: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
更新Sonarr剧集订阅
|
||||
"""
|
||||
return await arr_add_series(tv)
|
||||
|
||||
|
||||
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
|
||||
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||
async def arr_remove_series(tid: int, _: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
删除Sonarr剧集订阅
|
||||
"""
|
||||
subscribe = Subscribe.get(db, tid)
|
||||
subscribe = await Subscribe.async_get(db, tid)
|
||||
if subscribe:
|
||||
subscribe.delete(db, tid)
|
||||
await subscribe.async_delete(db, tid)
|
||||
return schemas.Response(success=True)
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -2,6 +2,8 @@ import gzip
|
||||
import json
|
||||
from typing import Annotated, Callable, Any, Dict, Optional
|
||||
|
||||
import aiofiles
|
||||
from anyio import Path as AsyncPath
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.routing import APIRoute
|
||||
@@ -19,7 +21,7 @@ class GzipRequest(Request):
|
||||
body = await super().body()
|
||||
if "gzip" in self.headers.getlist("Content-Encoding"):
|
||||
body = gzip.decompress(body)
|
||||
self._body = body # noqa
|
||||
self._body = body # noqa
|
||||
return self._body
|
||||
|
||||
|
||||
@@ -50,12 +52,12 @@ cookie_router = APIRouter(route_class=GzipRoute,
|
||||
|
||||
|
||||
@cookie_router.get("/", response_class=PlainTextResponse)
|
||||
def get_root():
|
||||
async def get_root():
|
||||
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
|
||||
|
||||
|
||||
@cookie_router.post("/", response_class=PlainTextResponse)
|
||||
def post_root():
|
||||
async def post_root():
|
||||
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
|
||||
|
||||
|
||||
@@ -64,31 +66,31 @@ async def update_cookie(req: schemas.CookieData):
|
||||
"""
|
||||
上传Cookie数据
|
||||
"""
|
||||
file_path = settings.COOKIE_PATH / f"{req.uuid}.json"
|
||||
file_path = AsyncPath(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()
|
||||
async with aiofiles.open(file_path, encoding="utf-8", mode="w") as file:
|
||||
await file.write(content)
|
||||
async with aiofiles.open(file_path, encoding="utf-8", mode="r") as file:
|
||||
read_content = await file.read()
|
||||
if read_content == content:
|
||||
return {"action": "done"}
|
||||
else:
|
||||
return {"action": "error"}
|
||||
|
||||
|
||||
def load_encrypt_data(uuid: str) -> Dict[str, Any]:
|
||||
async def load_encrypt_data(uuid: str) -> Dict[str, Any]:
|
||||
"""
|
||||
加载本地加密原始数据
|
||||
"""
|
||||
file_path = settings.COOKIE_PATH / f"{uuid}.json"
|
||||
file_path = AsyncPath(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()
|
||||
async with aiofiles.open(file_path, encoding="utf-8", mode="r") as file:
|
||||
read_content = await file.read()
|
||||
data = json.loads(read_content.encode("utf-8"))
|
||||
return data
|
||||
|
||||
@@ -120,7 +122,7 @@ async def get_cookie(
|
||||
"""
|
||||
GET 下载加密数据
|
||||
"""
|
||||
return load_encrypt_data(uuid)
|
||||
return await load_encrypt_data(uuid)
|
||||
|
||||
|
||||
@cookie_router.post("/get/{uuid}")
|
||||
@@ -130,5 +132,5 @@ async def post_cookie(
|
||||
"""
|
||||
POST 下载加密数据
|
||||
"""
|
||||
data = load_encrypt_data(uuid)
|
||||
data = await load_encrypt_data(uuid)
|
||||
return get_decrypted_cookie_data(uuid, request.password, data["encrypted"])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,11 @@ from typing import Optional, List
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import MediaInfo
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class BangumiChain(ChainBase, metaclass=Singleton):
|
||||
class BangumiChain(ChainBase):
|
||||
"""
|
||||
Bangumi处理链,单例运行
|
||||
Bangumi处理链
|
||||
"""
|
||||
|
||||
def calendar(self) -> Optional[List[MediaInfo]]:
|
||||
@@ -17,6 +16,12 @@ class BangumiChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("bangumi_calendar")
|
||||
|
||||
def discover(self, **kwargs) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现Bangumi番剧
|
||||
"""
|
||||
return self.run_module("bangumi_discover", **kwargs)
|
||||
|
||||
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||
"""
|
||||
获取Bangumi信息
|
||||
@@ -52,3 +57,51 @@ class BangumiChain(ChainBase, metaclass=Singleton):
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return self.run_module("bangumi_person_credits", person_id=person_id)
|
||||
|
||||
async def async_calendar(self) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取Bangumi每日放送(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_calendar")
|
||||
|
||||
async def async_discover(self, **kwargs) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现Bangumi番剧(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_discover", **kwargs)
|
||||
|
||||
async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||
"""
|
||||
获取Bangumi信息(异步版本)
|
||||
:param bangumiid: BangumiID
|
||||
:return: Bangumi信息
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_info", bangumiid=bangumiid)
|
||||
|
||||
async def async_bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
|
||||
"""
|
||||
根据BangumiID查询电影演职员表(异步版本)
|
||||
:param bangumiid: BangumiID
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_credits", bangumiid=bangumiid)
|
||||
|
||||
async def async_bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据BangumiID查询推荐电影(异步版本)
|
||||
:param bangumiid: BangumiID
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_recommend", bangumiid=bangumiid)
|
||||
|
||||
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
根据人物ID查询Bangumi人物详情(异步版本)
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_person_detail", person_id=person_id)
|
||||
|
||||
async def async_person_credits(self, person_id: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据人物ID查询人物参演作品(异步版本)
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return await self.async_run_module("async_bangumi_person_credits", person_id=person_id)
|
||||
|
||||
@@ -2,20 +2,19 @@ from typing import Optional, List
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class DashboardChain(ChainBase, metaclass=Singleton):
|
||||
class DashboardChain(ChainBase):
|
||||
"""
|
||||
各类仪表板统计处理链
|
||||
"""
|
||||
def media_statistic(self, server: str = None) -> Optional[List[schemas.Statistic]]:
|
||||
def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]:
|
||||
"""
|
||||
媒体数量统计
|
||||
"""
|
||||
return self.run_module("media_statistic", server=server)
|
||||
|
||||
def downloader_info(self, downloader: str = None) -> Optional[List[schemas.DownloaderInfo]]:
|
||||
def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]:
|
||||
"""
|
||||
下载器信息
|
||||
"""
|
||||
|
||||
@@ -4,12 +4,11 @@ from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import MediaInfo
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
class DoubanChain(ChainBase):
|
||||
"""
|
||||
豆瓣处理链,单例运行
|
||||
豆瓣处理链
|
||||
"""
|
||||
|
||||
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
@@ -19,7 +18,7 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("douban_person_detail", person_id=person_id)
|
||||
|
||||
def person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:
|
||||
def person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
:param person_id: 人物ID
|
||||
@@ -27,7 +26,7 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("douban_person_credits", person_id=person_id, page=page)
|
||||
|
||||
def movie_top250(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
def movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取豆瓣电影TOP250
|
||||
:param page: 页码
|
||||
@@ -35,26 +34,26 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("movie_top250", page=page, count=count)
|
||||
|
||||
def movie_showing(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
def movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取正在上映的电影
|
||||
"""
|
||||
return self.run_module("movie_showing", page=page, count=count)
|
||||
|
||||
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
def tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取本周中国剧集榜
|
||||
"""
|
||||
return self.run_module("tv_weekly_chinese", page=page, count=count)
|
||||
|
||||
def tv_weekly_global(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
def tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取本周全球剧集榜
|
||||
"""
|
||||
return self.run_module("tv_weekly_global", page=page, count=count)
|
||||
|
||||
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
|
||||
page: int = 0, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
page: Optional[int] = 0, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现豆瓣电影、剧集
|
||||
:param mtype: 媒体类型
|
||||
@@ -67,19 +66,19 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags,
|
||||
page=page, count=count)
|
||||
|
||||
def tv_animation(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
def tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取动画剧集
|
||||
"""
|
||||
return self.run_module("tv_animation", page=page, count=count)
|
||||
|
||||
def movie_hot(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
def movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取热门电影
|
||||
"""
|
||||
return self.run_module("movie_hot", page=page, count=count)
|
||||
|
||||
def tv_hot(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
def tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取热门剧集
|
||||
"""
|
||||
@@ -112,3 +111,111 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.run_module("douban_tv_recommend", doubanid=doubanid)
|
||||
|
||||
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
根据人物ID查询豆瓣人物详情(异步版本)
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return await self.async_run_module("async_douban_person_detail", person_id=person_id)
|
||||
|
||||
async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]:
|
||||
"""
|
||||
根据人物ID查询人物参演作品(异步版本)
|
||||
:param person_id: 人物ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return await self.async_run_module("async_douban_person_credits", person_id=person_id, page=page)
|
||||
|
||||
async def async_movie_top250(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取豆瓣电影TOP250(异步版本)
|
||||
:param page: 页码
|
||||
:param count: 每页数量
|
||||
"""
|
||||
return await self.async_run_module("async_movie_top250", page=page, count=count)
|
||||
|
||||
async def async_movie_showing(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取正在上映的电影(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_movie_showing", page=page, count=count)
|
||||
|
||||
async def async_tv_weekly_chinese(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取本周中国剧集榜(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_tv_weekly_chinese", page=page, count=count)
|
||||
|
||||
async def async_tv_weekly_global(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取本周全球剧集榜(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_tv_weekly_global", page=page, count=count)
|
||||
|
||||
async def async_douban_discover(self, mtype: MediaType, sort: str, tags: str,
|
||||
page: Optional[int] = 0, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现豆瓣电影、剧集(异步版本)
|
||||
:param mtype: 媒体类型
|
||||
:param sort: 排序方式
|
||||
:param tags: 标签
|
||||
:param page: 页码
|
||||
:param count: 数量
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
return await self.async_run_module("async_douban_discover", mtype=mtype, sort=sort, tags=tags,
|
||||
page=page, count=count)
|
||||
|
||||
async def async_tv_animation(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取动画剧集(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_tv_animation", page=page, count=count)
|
||||
|
||||
async def async_movie_hot(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取热门电影(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_movie_hot", page=page, count=count)
|
||||
|
||||
async def async_tv_hot(self, page: Optional[int] = 1,
|
||||
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取热门剧集(异步版本)
|
||||
"""
|
||||
return await self.async_run_module("async_tv_hot", page=page, count=count)
|
||||
|
||||
async def async_movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电影演职人员(异步版本)
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return await self.async_run_module("async_douban_movie_credits", doubanid=doubanid)
|
||||
|
||||
async def async_tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职人员(异步版本)
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return await self.async_run_module("async_douban_tv_credits", doubanid=doubanid)
|
||||
|
||||
async def async_movie_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影(异步版本)
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return await self.async_run_module("async_douban_movie_recommend", doubanid=doubanid)
|
||||
|
||||
async def async_tv_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电视剧(异步版本)
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return await self.async_run_module("async_douban_tv_recommend", doubanid=doubanid)
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import List, Optional, Tuple, Set, Dict, Union
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.cache import FileCache
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.context import MediaInfo, TorrentInfo, Context
|
||||
from app.core.event import eventmanager, Event
|
||||
@@ -16,11 +17,12 @@ from app.core.metainfo import MetaInfo
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, ResourceDownloadEventData
|
||||
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ChainEventType
|
||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, \
|
||||
ResourceDownloadEventData
|
||||
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \
|
||||
ChainEventType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -30,82 +32,17 @@ class DownloadChain(ChainBase):
|
||||
下载处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.torrent = TorrentHelper()
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.mediaserver = MediaServerOper()
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
self.messagehelper = MessageHelper()
|
||||
|
||||
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
|
||||
channel: MessageChannel = None, username: str = None,
|
||||
download_episodes: str = None):
|
||||
"""
|
||||
发送添加下载的消息,根据消息场景开关决定发给谁
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param torrent: 种子信息
|
||||
:param channel: 通知渠道
|
||||
:param username: 通知显示的下载用户信息
|
||||
:param download_episodes: 下载的集数
|
||||
"""
|
||||
# 拼装消息内容
|
||||
msg_text = ""
|
||||
if username:
|
||||
msg_text = f"用户:{username}"
|
||||
if torrent.site_name:
|
||||
msg_text = f"{msg_text}\n站点:{torrent.site_name}"
|
||||
if meta.resource_term:
|
||||
msg_text = f"{msg_text}\n质量:{meta.resource_term}"
|
||||
if torrent.size:
|
||||
if str(torrent.size).replace(".", "").isdigit():
|
||||
size = StringUtils.str_filesize(torrent.size)
|
||||
else:
|
||||
size = torrent.size
|
||||
msg_text = f"{msg_text}\n大小:{size}"
|
||||
if torrent.title:
|
||||
msg_text = f"{msg_text}\n种子:{torrent.title}"
|
||||
if torrent.pubdate:
|
||||
msg_text = f"{msg_text}\n发布时间:{torrent.pubdate}"
|
||||
if torrent.freedate:
|
||||
msg_text = f"{msg_text}\n免费时间:{StringUtils.diff_time_str(torrent.freedate)}"
|
||||
if torrent.seeders:
|
||||
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
|
||||
if torrent.uploadvolumefactor and torrent.downloadvolumefactor:
|
||||
msg_text = f"{msg_text}\n促销:{torrent.volume_factor}"
|
||||
if torrent.hit_and_run:
|
||||
msg_text = f"{msg_text}\nHit&Run:是"
|
||||
if torrent.labels:
|
||||
msg_text = f"{msg_text}\n标签:{' '.join(torrent.labels)}"
|
||||
if torrent.description:
|
||||
html_re = re.compile(r'<[^>]+>', re.S)
|
||||
description = html_re.sub('', torrent.description)
|
||||
torrent.description = re.sub(r'<[^>]+>', '', description)
|
||||
msg_text = f"{msg_text}\n描述:{torrent.description}"
|
||||
|
||||
# 下载成功按规则发送消息
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
mtype=NotificationType.Download,
|
||||
title=f"{mediainfo.title_year} "
|
||||
f"{'%s %s' % (meta.season, download_episodes) if download_episodes else meta.season_episode} 开始下载",
|
||||
text=msg_text,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=settings.MP_DOMAIN('/#/downloading'),
|
||||
username=username))
|
||||
|
||||
def download_torrent(self, torrent: TorrentInfo,
|
||||
channel: MessageChannel = None,
|
||||
source: str = None,
|
||||
source: Optional[str] = None,
|
||||
userid: Union[str, int] = None
|
||||
) -> Tuple[Optional[Union[Path, str]], str, list]:
|
||||
) -> Tuple[Optional[Union[str, bytes]], str, list]:
|
||||
"""
|
||||
下载种子文件,如果是磁力链,会返回磁力链接本身
|
||||
:return: 种子路径,种子目录名,种子文件清单
|
||||
:return: 种子内容,种子目录名,种子文件清单
|
||||
"""
|
||||
|
||||
def __get_redict_url(url: str, ua: str = None, cookie: str = None) -> Optional[str]:
|
||||
def __get_redict_url(url: str, ua: Optional[str] = None, cookie: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
获取下载链接, url格式:[base64]url
|
||||
"""
|
||||
@@ -124,6 +61,8 @@ class DownloadChain(ChainBase):
|
||||
# 是否使用cookie
|
||||
if not req_params.get('cookie'):
|
||||
cookie = None
|
||||
# 代理
|
||||
proxy = req_params.get('proxy')
|
||||
# 请求头
|
||||
if req_params.get('header'):
|
||||
headers = req_params.get('header')
|
||||
@@ -134,14 +73,16 @@ class DownloadChain(ChainBase):
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie,
|
||||
headers=headers
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
).get_res(url, params=req_params.get('params'))
|
||||
else:
|
||||
# POST请求
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie,
|
||||
headers=headers
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
).post_res(url, params=req_params.get('params'))
|
||||
if not res:
|
||||
return None
|
||||
@@ -177,7 +118,7 @@ class DownloadChain(ChainBase):
|
||||
logger.error(f"{torrent.title} 无法获取下载地址:{torrent.enclosure}!")
|
||||
return None, "", []
|
||||
# 下载种子文件
|
||||
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
|
||||
_, content, download_folder, files, error_msg = TorrentHelper().download_torrent(
|
||||
url=torrent_url,
|
||||
cookie=site_cookie,
|
||||
ua=torrent.site_ua or settings.USER_AGENT,
|
||||
@@ -187,7 +128,7 @@ class DownloadChain(ChainBase):
|
||||
# 磁力链
|
||||
return content, "", []
|
||||
|
||||
if not torrent_file:
|
||||
if not content:
|
||||
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
@@ -199,21 +140,24 @@ class DownloadChain(ChainBase):
|
||||
return None, "", []
|
||||
|
||||
# 返回 种子文件路径,种子目录名,种子文件清单
|
||||
return torrent_file, download_folder, files
|
||||
return content, download_folder, files
|
||||
|
||||
def download_single(self, context: Context, torrent_file: Path = None,
|
||||
def download_single(self, context: Context,
|
||||
torrent_file: Path = None,
|
||||
torrent_content: Optional[Union[str, bytes]] = None,
|
||||
episodes: Set[int] = None,
|
||||
channel: MessageChannel = None,
|
||||
source: str = None,
|
||||
downloader: str = None,
|
||||
save_path: str = None,
|
||||
source: Optional[str] = None,
|
||||
downloader: Optional[str] = None,
|
||||
save_path: Optional[str] = None,
|
||||
userid: Union[str, int] = None,
|
||||
username: str = None,
|
||||
media_category: str = None) -> Optional[str]:
|
||||
username: Optional[str] = None,
|
||||
label: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
下载及发送通知
|
||||
:param context: 资源上下文
|
||||
:param torrent_file: 种子文件路径
|
||||
:param torrent_content: 种子内容(磁力链或种子文件内容)
|
||||
:param episodes: 需要下载的集数
|
||||
:param channel: 通知渠道
|
||||
:param source: 来源(消息通知、Subscribe、Manual等)
|
||||
@@ -221,8 +165,13 @@ class DownloadChain(ChainBase):
|
||||
:param save_path: 保存路径
|
||||
:param userid: 用户ID
|
||||
:param username: 调用下载的用户名/插件名
|
||||
:param media_category: 自定义媒体类别
|
||||
:param label: 自定义标签
|
||||
"""
|
||||
_torrent = context.torrent_info
|
||||
_media = context.media_info
|
||||
_meta = context.meta_info
|
||||
_site_downloader = _torrent.site_downloader
|
||||
|
||||
# 发送资源下载事件,允许外部拦截下载
|
||||
event_data = ResourceDownloadEventData(
|
||||
context=context,
|
||||
@@ -234,7 +183,7 @@ class DownloadChain(ChainBase):
|
||||
"save_path": save_path,
|
||||
"userid": userid,
|
||||
"username": username,
|
||||
"media_category": media_category
|
||||
"media_category": _media.category
|
||||
}
|
||||
)
|
||||
# 触发资源下载事件
|
||||
@@ -247,34 +196,41 @@ class DownloadChain(ChainBase):
|
||||
f"Resource download canceled by event: {event_data.source},"
|
||||
f"Reason: {event_data.reason}")
|
||||
return None
|
||||
|
||||
_torrent = context.torrent_info
|
||||
_media = context.media_info
|
||||
_meta = context.meta_info
|
||||
_site_downloader = _torrent.site_downloader
|
||||
# 如果事件修改了下载路径,使用新路径
|
||||
if event_data.options and event_data.options.get("save_path"):
|
||||
save_path = event_data.options.get("save_path")
|
||||
|
||||
# 补充完整的media数据
|
||||
if not _media.genre_ids:
|
||||
new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id,
|
||||
doubanid=_media.douban_id, bangumiid=_media.bangumi_id)
|
||||
doubanid=_media.douban_id, bangumiid=_media.bangumi_id,
|
||||
episode_group=_media.episode_group)
|
||||
if new_media:
|
||||
_media = new_media
|
||||
|
||||
# 实际下载的集数
|
||||
download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None
|
||||
_folder_name = ""
|
||||
if not torrent_file:
|
||||
if not torrent_file and not torrent_content:
|
||||
# 下载种子文件,得到的可能是文件也可能是磁力链
|
||||
content, _folder_name, _file_list = self.download_torrent(_torrent,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid)
|
||||
if not content:
|
||||
return None
|
||||
else:
|
||||
content = torrent_file
|
||||
# 获取种子文件的文件夹名和文件清单
|
||||
_folder_name, _file_list = self.torrent.get_torrent_info(torrent_file)
|
||||
torrent_content, _folder_name, _file_list = self.download_torrent(_torrent,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid)
|
||||
elif torrent_file:
|
||||
if torrent_file.exists():
|
||||
torrent_content = torrent_file.read_bytes()
|
||||
else:
|
||||
# 缓存处理器
|
||||
cache_backend = FileCache()
|
||||
# 读取缓存的种子文件
|
||||
torrent_content = cache_backend.get(torrent_file.as_posix(), region="torrents")
|
||||
|
||||
if not torrent_content:
|
||||
return None
|
||||
|
||||
# 获取种子文件的文件夹名和文件清单
|
||||
_folder_name, _file_list = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)
|
||||
|
||||
# 下载目录
|
||||
if save_path:
|
||||
@@ -282,7 +238,7 @@ class DownloadChain(ChainBase):
|
||||
download_dir = Path(save_path)
|
||||
else:
|
||||
# 根据媒体信息查询下载目录配置
|
||||
dir_info = self.directoryhelper.get_dir(_media, storage="local", include_unsorted=True)
|
||||
dir_info = DirectoryHelper().get_dir(_media, storage="local", include_unsorted=True)
|
||||
# 拼装子目录
|
||||
if dir_info:
|
||||
# 一级目录
|
||||
@@ -305,11 +261,12 @@ class DownloadChain(ChainBase):
|
||||
return None
|
||||
|
||||
# 添加下载
|
||||
result: Optional[tuple] = self.download(content=content,
|
||||
result: Optional[tuple] = self.download(content=torrent_content,
|
||||
cookie=_torrent.site_cookie,
|
||||
episodes=episodes,
|
||||
download_dir=download_dir,
|
||||
category=_media.category,
|
||||
label=label,
|
||||
downloader=downloader or _site_downloader)
|
||||
if result:
|
||||
_downloader, _hash, _layout, error_msg = result
|
||||
@@ -331,7 +288,8 @@ class DownloadChain(ChainBase):
|
||||
_save_path = download_dir if _layout == "NoSubfolder" or not _folder_name else download_path
|
||||
|
||||
# 登记下载记录
|
||||
self.downloadhis.add(
|
||||
downloadhis = DownloadHistoryOper()
|
||||
downloadhis.add(
|
||||
path=str(download_path),
|
||||
type=_media.type.value,
|
||||
title=_media.title,
|
||||
@@ -352,7 +310,8 @@ class DownloadChain(ChainBase):
|
||||
username=username,
|
||||
channel=channel.value if channel else None,
|
||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
media_category=media_category,
|
||||
media_category=_media.category,
|
||||
episode_group=_media.episode_group,
|
||||
note={"source": source}
|
||||
)
|
||||
|
||||
@@ -378,13 +337,28 @@ class DownloadChain(ChainBase):
|
||||
"torrentname": _meta.org_string,
|
||||
})
|
||||
if files_to_add:
|
||||
self.downloadhis.add_files(files_to_add)
|
||||
downloadhis.add_files(files_to_add)
|
||||
|
||||
# 下载成功发送消息
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
|
||||
username=username, download_episodes=download_episodes)
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source if channel else None,
|
||||
mtype=NotificationType.Download,
|
||||
ctype=ContentType.DownloadAdded,
|
||||
image=_media.get_message_image(),
|
||||
link=settings.MP_DOMAIN('/#/downloading'),
|
||||
userid=userid,
|
||||
username=username
|
||||
),
|
||||
meta=_meta,
|
||||
mediainfo=_media,
|
||||
torrentinfo=_torrent,
|
||||
download_episodes=download_episodes,
|
||||
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_content=torrent_content)
|
||||
# 广播事件
|
||||
self.eventmanager.send_event(EventType.DownloadAdded, {
|
||||
"hash": _hash,
|
||||
@@ -415,13 +389,12 @@ class DownloadChain(ChainBase):
|
||||
def batch_download(self,
|
||||
contexts: List[Context],
|
||||
no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]] = None,
|
||||
save_path: str = None,
|
||||
save_path: Optional[str] = None,
|
||||
channel: MessageChannel = None,
|
||||
source: str = None,
|
||||
userid: str = None,
|
||||
username: str = None,
|
||||
media_category: str = None,
|
||||
downloader: str = None
|
||||
source: Optional[str] = None,
|
||||
userid: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
downloader: Optional[str] = None
|
||||
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
根据缺失数据,自动种子列表中组合择优下载
|
||||
@@ -432,7 +405,6 @@ class DownloadChain(ChainBase):
|
||||
:param source: 来源(消息通知、订阅、手工下载等)
|
||||
:param userid: 用户ID
|
||||
:param username: 调用下载的用户名/插件名
|
||||
:param media_category: 自定义媒体类别
|
||||
:param downloader: 下载器
|
||||
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}
|
||||
"""
|
||||
@@ -521,7 +493,7 @@ class DownloadChain(ChainBase):
|
||||
logger.info(f"开始下载电影 {context.torrent_info.title} ...")
|
||||
if self.download_single(context, save_path=save_path, channel=channel,
|
||||
source=source, userid=userid, username=username,
|
||||
media_category=media_category, downloader=downloader):
|
||||
downloader=downloader):
|
||||
# 下载成功
|
||||
logger.info(f"{context.torrent_info.title} 添加下载成功")
|
||||
downloaded_list.append(context)
|
||||
@@ -581,7 +553,7 @@ class DownloadChain(ChainBase):
|
||||
if isinstance(content, str):
|
||||
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法确定种子文件集数")
|
||||
continue
|
||||
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||
torrent_episodes = TorrentHelper().get_torrent_episodes(torrent_files)
|
||||
logger.info(f"{meta.org_string} 解析种子文件集数为 {torrent_episodes}")
|
||||
if not torrent_episodes:
|
||||
continue
|
||||
@@ -600,14 +572,13 @@ class DownloadChain(ChainBase):
|
||||
logger.info(f"开始下载 {torrent.title} ...")
|
||||
download_id = self.download_single(
|
||||
context=context,
|
||||
torrent_file=content if isinstance(content, Path) else None,
|
||||
torrent_content=content,
|
||||
save_path=save_path,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
media_category=media_category,
|
||||
downloader=downloader,
|
||||
downloader=downloader
|
||||
)
|
||||
else:
|
||||
# 下载
|
||||
@@ -615,7 +586,6 @@ class DownloadChain(ChainBase):
|
||||
download_id = self.download_single(context, save_path=save_path,
|
||||
channel=channel, source=source,
|
||||
userid=userid, username=username,
|
||||
media_category=media_category,
|
||||
downloader=downloader)
|
||||
|
||||
if download_id:
|
||||
@@ -687,7 +657,6 @@ class DownloadChain(ChainBase):
|
||||
download_id = self.download_single(context, save_path=save_path,
|
||||
channel=channel, source=source,
|
||||
userid=userid, username=username,
|
||||
media_category=media_category,
|
||||
downloader=downloader)
|
||||
if download_id:
|
||||
# 下载成功
|
||||
@@ -758,7 +727,7 @@ class DownloadChain(ChainBase):
|
||||
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法解析种子文件集数")
|
||||
continue
|
||||
# 种子全部集
|
||||
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||
torrent_episodes = TorrentHelper().get_torrent_episodes(torrent_files)
|
||||
logger.info(f"{torrent.site_name} - {meta.org_string} 解析种子文件集数:{torrent_episodes}")
|
||||
# 选中的集
|
||||
selected_episodes = set(torrent_episodes).intersection(set(need_episodes))
|
||||
@@ -770,14 +739,13 @@ class DownloadChain(ChainBase):
|
||||
logger.info(f"开始下载 {torrent.title} ...")
|
||||
download_id = self.download_single(
|
||||
context=context,
|
||||
torrent_file=content if isinstance(content, Path) else None,
|
||||
torrent_content=content,
|
||||
episodes=selected_episodes,
|
||||
save_path=save_path,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
media_category=media_category,
|
||||
downloader=downloader
|
||||
)
|
||||
if not download_id:
|
||||
@@ -848,11 +816,12 @@ class DownloadChain(ChainBase):
|
||||
if not totals:
|
||||
totals = {}
|
||||
|
||||
mediaserver = MediaServerOper()
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
title=mediainfo.title,
|
||||
tmdbid=mediainfo.tmdb_id)
|
||||
itemid = mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
title=mediainfo.title,
|
||||
tmdbid=mediainfo.tmdb_id)
|
||||
exists_movies: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid)
|
||||
if exists_movies:
|
||||
logger.info(f"媒体库中已存在电影:{mediainfo.title_year}")
|
||||
@@ -863,7 +832,8 @@ class DownloadChain(ChainBase):
|
||||
# 补充媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
doubanid=mediainfo.douban_id,
|
||||
episode_group=mediainfo.episode_group)
|
||||
if not mediainfo:
|
||||
logger.error(f"媒体信息识别失败!")
|
||||
return False, {}
|
||||
@@ -871,10 +841,10 @@ class DownloadChain(ChainBase):
|
||||
logger.error(f"媒体信息中没有季集信息:{mediainfo.title_year}")
|
||||
return False, {}
|
||||
# 电视剧
|
||||
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
title=mediainfo.title,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=mediainfo.season)
|
||||
itemid = mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
title=mediainfo.title,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=mediainfo.season)
|
||||
# 媒体库已存在的剧集
|
||||
exists_tvs: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid)
|
||||
if not exists_tvs:
|
||||
@@ -930,7 +900,7 @@ class DownloadChain(ChainBase):
|
||||
# 全部存在
|
||||
return True, no_exists
|
||||
|
||||
def remote_downloading(self, channel: MessageChannel, userid: Union[str, int] = None, source: str = None):
|
||||
def remote_downloading(self, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None):
|
||||
"""
|
||||
查询正在下载的任务,并发送消息
|
||||
"""
|
||||
@@ -964,7 +934,7 @@ class DownloadChain(ChainBase):
|
||||
link=settings.MP_DOMAIN('#/downloading')
|
||||
))
|
||||
|
||||
def downloading(self, name: str = None) -> List[DownloadingTorrent]:
|
||||
def downloading(self, name: Optional[str] = None) -> List[DownloadingTorrent]:
|
||||
"""
|
||||
查询正在下载的任务
|
||||
"""
|
||||
@@ -973,7 +943,7 @@ class DownloadChain(ChainBase):
|
||||
return []
|
||||
ret_torrents = []
|
||||
for torrent in torrents:
|
||||
history = self.downloadhis.get_by_hash(torrent.hash)
|
||||
history = DownloadHistoryOper().get_by_hash(torrent.hash)
|
||||
if history:
|
||||
# 媒体信息
|
||||
torrent.media = {
|
||||
@@ -990,21 +960,21 @@ class DownloadChain(ChainBase):
|
||||
ret_torrents.append(torrent)
|
||||
return ret_torrents
|
||||
|
||||
def set_downloading(self, hash_str, oper: str) -> bool:
|
||||
def set_downloading(self, hash_str, oper: str, name: Optional[str] = None) -> bool:
|
||||
"""
|
||||
控制下载任务 start/stop
|
||||
"""
|
||||
if oper == "start":
|
||||
return self.start_torrents(hashs=[hash_str])
|
||||
return self.start_torrents(hashs=[hash_str], downloader=name)
|
||||
elif oper == "stop":
|
||||
return self.stop_torrents(hashs=[hash_str])
|
||||
return self.stop_torrents(hashs=[hash_str], downloader=name)
|
||||
return False
|
||||
|
||||
def remove_downloading(self, hash_str: str) -> bool:
|
||||
def remove_downloading(self, hash_str: str, name: Optional[str] = None) -> bool:
|
||||
"""
|
||||
删除下载任务
|
||||
"""
|
||||
return self.remove_torrents(hashs=[hash_str])
|
||||
return self.remove_torrents(hashs=[hash_str], downloader=name)
|
||||
|
||||
@eventmanager.register(EventType.DownloadFileDeleted)
|
||||
def download_file_deleted(self, event: Event):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
||||
import threading
|
||||
from typing import List, Union, Optional, Generator
|
||||
|
||||
from cachetools import cached, TTLCache
|
||||
from typing import List, Union, Optional, Generator, Any
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import global_vars
|
||||
@@ -18,18 +16,15 @@ class MediaServerChain(ChainBase):
|
||||
媒体服务器处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.dboper = MediaServerOper()
|
||||
|
||||
def librarys(self, server: str, username: str = None, hidden: bool = False) -> List[MediaServerLibrary]:
|
||||
def librarys(self, server: str, username: Optional[str] = None,
|
||||
hidden: bool = False) -> List[MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库
|
||||
"""
|
||||
return self.run_module("mediaserver_librarys", server=server, username=username, hidden=hidden)
|
||||
|
||||
def items(self, server: str, library_id: Union[str, int], start_index: int = 0, limit: Optional[int] = -1) \
|
||||
-> Optional[Generator]:
|
||||
def items(self, server: str, library_id: Union[str, int],
|
||||
start_index: Optional[int] = 0, limit: Optional[int] = -1) -> Generator[Any, None, None]:
|
||||
"""
|
||||
获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据
|
||||
|
||||
@@ -82,28 +77,30 @@ class MediaServerChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
|
||||
|
||||
def playing(self, server: str, count: int = 20, username: str = None) -> List[MediaServerPlayItem]:
|
||||
def playing(self, server: str, count: Optional[int] = 20,
|
||||
username: Optional[str] = None) -> List[MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器正在播放信息
|
||||
"""
|
||||
return self.run_module("mediaserver_playing", count=count, server=server, username=username)
|
||||
|
||||
def latest(self, server: str, count: int = 20, username: str = None) -> List[MediaServerPlayItem]:
|
||||
def latest(self, server: str, count: Optional[int] = 20,
|
||||
username: Optional[str] = None) -> List[MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
||||
def get_latest_wallpapers(self, server: str = None, count: int = 10,
|
||||
remote: bool = True, username: str = None) -> List[str]:
|
||||
def get_latest_wallpapers(self, server: Optional[str] = None, count: Optional[int] = 10,
|
||||
remote: bool = True, username: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
获取最新最新入库条目海报作为壁纸,缓存1小时
|
||||
"""
|
||||
return self.run_module("mediaserver_latest_images", server=server, count=count,
|
||||
remote=remote, username=username)
|
||||
|
||||
def get_latest_wallpaper(self, server: str = None, remote: bool = True, username: str = None) -> Optional[str]:
|
||||
def get_latest_wallpaper(self, server: Optional[str] = None,
|
||||
remote: bool = True, username: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
获取最新最新入库条目海报作为壁纸,缓存1小时
|
||||
"""
|
||||
@@ -128,7 +125,8 @@ class MediaServerChain(ChainBase):
|
||||
# 汇总统计
|
||||
total_count = 0
|
||||
# 清空登记薄
|
||||
self.dboper.empty()
|
||||
dboper = MediaServerOper()
|
||||
dboper.empty()
|
||||
# 遍历媒体服务器
|
||||
for mediaserver in mediaservers:
|
||||
if not mediaserver:
|
||||
@@ -172,7 +170,7 @@ class MediaServerChain(ChainBase):
|
||||
item_dict = item.dict()
|
||||
item_dict["seasoninfo"] = seasoninfo
|
||||
item_dict["item_type"] = item_type
|
||||
self.dboper.add(**item_dict)
|
||||
dboper.add(**item_dict)
|
||||
logger.info(f"{server_name} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
|
||||
# 总数累加
|
||||
total_count += library_count
|
||||
|
||||
1032
app/chain/message.py
1032
app/chain/message.py
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,15 @@
|
||||
import inspect
|
||||
import io
|
||||
import tempfile
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List
|
||||
from typing import List, Optional
|
||||
|
||||
import pillow_avif # noqa 用于自动注册AVIF支持
|
||||
from PIL import Image
|
||||
from cachetools import TTLCache
|
||||
from cachetools.keys import hashkey
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.cache import cached, FileCache
|
||||
from app.core.config import settings, global_vars
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
@@ -21,65 +18,24 @@ from app.utils.http import RequestUtils
|
||||
from app.utils.security import SecurityUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
# 推荐相关的专用缓存
|
||||
recommend_ttl = 24 * 3600
|
||||
recommend_cache = TTLCache(maxsize=256, ttl=recommend_ttl)
|
||||
|
||||
|
||||
# 推荐缓存装饰器,避免偶发网络获取数据为空时,页面由于缓存为空长时间渲染异常问题
|
||||
def cached_with_empty_check(func: Callable):
|
||||
"""
|
||||
缓存装饰器,用于缓存函数的返回结果,仅在结果非空时进行缓存
|
||||
|
||||
:param func: 被装饰的函数
|
||||
:return: 包装后的函数
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
signature = inspect.signature(func)
|
||||
resolved_kwargs = {}
|
||||
# 获取默认值并结合传递的参数(如果有)
|
||||
for param, value in signature.parameters.items():
|
||||
if param in kwargs:
|
||||
# 使用显式传递的参数
|
||||
resolved_kwargs[param] = kwargs[param]
|
||||
elif value.default is not inspect.Parameter.empty:
|
||||
# 没有传递参数时使用默认值
|
||||
resolved_kwargs[param] = value.default
|
||||
# 使用 cachetools 缓存,构造缓存键
|
||||
cache_key = f"{func.__name__}_{hashkey(*args, **resolved_kwargs)}"
|
||||
if cache_key in recommend_cache:
|
||||
return recommend_cache[cache_key]
|
||||
result = func(*args, **kwargs)
|
||||
# 如果返回值为空,则不缓存
|
||||
if result in [None, [], {}]:
|
||||
return result
|
||||
recommend_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
推荐处理链,单例运行
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.doubanchain = DoubanChain()
|
||||
self.bangumichain = BangumiChain()
|
||||
self.cache_max_pages = 5
|
||||
# 推荐缓存时间
|
||||
recommend_ttl = 24 * 3600
|
||||
# 推荐缓存页数
|
||||
cache_max_pages = 5
|
||||
# 推荐缓存区域
|
||||
recommend_cache_region = "recommend"
|
||||
|
||||
def refresh_recommend(self):
|
||||
"""
|
||||
刷新推荐
|
||||
"""
|
||||
logger.debug("Starting to refresh Recommend data.")
|
||||
recommend_cache.clear()
|
||||
logger.debug("Recommend Cache has been cleared.")
|
||||
|
||||
# 推荐来源方法
|
||||
recommend_methods = [
|
||||
@@ -147,27 +103,26 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
请求并保存图片
|
||||
:param url: 图片路径
|
||||
"""
|
||||
if not settings.GLOBAL_IMAGE_CACHE or not url:
|
||||
return
|
||||
|
||||
# 生成缓存路径
|
||||
sanitized_path = SecurityUtils.sanitize_url_path(url)
|
||||
cache_path = settings.CACHE_PATH / "images" / sanitized_path
|
||||
cache_path = Path("images") / sanitized_path
|
||||
# 没有文件类型,则添加后缀,在恶意文件类型和实际需求下的折衷选择
|
||||
if not cache_path.suffix:
|
||||
cache_path = cache_path.with_suffix(".jpg")
|
||||
|
||||
# 确保缓存路径和文件类型合法
|
||||
if not SecurityUtils.is_safe_path(settings.CACHE_PATH, cache_path, settings.SECURITY_IMAGE_SUFFIXES):
|
||||
logger.debug(f"Invalid cache path or file type for URL: {url}, sanitized path: {sanitized_path}")
|
||||
return
|
||||
# 获取缓存后端,并设置缓存时间为全局配置的缓存天数
|
||||
cache_backend = FileCache(base=settings.CACHE_PATH,
|
||||
ttl=settings.GLOBAL_IMAGE_CACHE_DAYS * 24 * 3600)
|
||||
|
||||
# 本地存在缓存图片,则直接跳过
|
||||
if cache_path.exists():
|
||||
if cache_backend.get(cache_path.as_posix(), region="images"):
|
||||
logger.debug(f"Cache hit: Image already exists at {cache_path}")
|
||||
return
|
||||
|
||||
# 请求远程图片
|
||||
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
|
||||
proxies = settings.PROXY if not referer else None
|
||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=proxies, referer=referer).get_res(url=url)
|
||||
response = RequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=proxies, referer=referer).get_res(url=url)
|
||||
if not response:
|
||||
logger.debug(f"Empty response for URL: {url}")
|
||||
return
|
||||
@@ -179,145 +134,316 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
logger.debug(f"Invalid image format for URL {url}: {e}")
|
||||
return
|
||||
|
||||
if not cache_path:
|
||||
return
|
||||
|
||||
try:
|
||||
if not cache_path.parent.exists():
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file:
|
||||
tmp_file.write(response.content)
|
||||
temp_path = Path(tmp_file.name)
|
||||
temp_path.replace(cache_path)
|
||||
logger.debug(f"Successfully cached image at {cache_path} for URL: {url}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to write cache file {cache_path} for URL {url}: {e}")
|
||||
# 保存缓存
|
||||
cache_backend.set(cache_path.as_posix(), response.content, region="images")
|
||||
logger.debug(f"Successfully cached image at {cache_path} for URL: {url}")
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def tmdb_movies(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
||||
with_original_language: str = "", page: int = 1) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def tmdb_movies(self, sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1) -> List[dict]:
|
||||
"""
|
||||
TMDB热门电影
|
||||
"""
|
||||
movies = self.tmdbchain.tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
return [movie.to_dict() for movie in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def tmdb_tvs(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
||||
with_original_language: str = "zh|en|ja|ko", page: int = 1) -> Any:
|
||||
"""
|
||||
TMDB热门电视剧
|
||||
"""
|
||||
tvs = self.tmdbchain.tmdb_discover(mtype=MediaType.TV,
|
||||
movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return [movie.to_dict() for movie in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "zh|en|ja|ko",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1) -> List[dict]:
|
||||
"""
|
||||
TMDB热门电视剧
|
||||
"""
|
||||
tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return [tv.to_dict() for tv in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def tmdb_trending(self, page: int = 1) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:
|
||||
"""
|
||||
TMDB流行趋势
|
||||
"""
|
||||
infos = self.tmdbchain.tmdb_trending(page=page)
|
||||
infos = TmdbChain().tmdb_trending(page=page)
|
||||
return [info.to_dict() for info in infos] if infos else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def bangumi_calendar(self, page: int = 1, count: int = 30) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
Bangumi每日放送
|
||||
"""
|
||||
medias = self.bangumichain.calendar()
|
||||
medias = BangumiChain().calendar()
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movie_showing(self, page: int = 1, count: int = 30) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
豆瓣正在热映
|
||||
"""
|
||||
movies = self.doubanchain.movie_showing(page=page, count=count)
|
||||
movies = DoubanChain().movie_showing(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movies(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "",
|
||||
page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
豆瓣最新电影
|
||||
"""
|
||||
movies = self.doubanchain.douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tvs(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def douban_tvs(self, sort: Optional[str] = "R", tags: Optional[str] = "",
|
||||
page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
豆瓣最新电视剧
|
||||
"""
|
||||
tvs = self.doubanchain.douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movie_top250(self, page: int = 1, count: int = 30) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
豆瓣电影TOP250
|
||||
"""
|
||||
movies = self.doubanchain.movie_top250(page=page, count=count)
|
||||
movies = DoubanChain().movie_top250(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
豆瓣国产剧集榜
|
||||
"""
|
||||
tvs = self.doubanchain.tv_weekly_chinese(page=page, count=count)
|
||||
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_weekly_global(self, page: int = 1, count: int = 30) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
豆瓣全球剧集榜
|
||||
"""
|
||||
tvs = self.doubanchain.tv_weekly_global(page=page, count=count)
|
||||
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_animation(self, page: int = 1, count: int = 30) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
豆瓣热门动漫
|
||||
"""
|
||||
tvs = self.doubanchain.tv_animation(page=page, count=count)
|
||||
tvs = DoubanChain().tv_animation(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movie_hot(self, page: int = 1, count: int = 30) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
豆瓣热门电影
|
||||
"""
|
||||
movies = self.doubanchain.movie_hot(page=page, count=count)
|
||||
movies = DoubanChain().movie_hot(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_hot(self, page: int = 1, count: int = 30) -> Any:
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
def douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
豆瓣热门电视剧
|
||||
"""
|
||||
tvs = self.doubanchain.tv_hot(page=page, count=count)
|
||||
tvs = DoubanChain().tv_hot(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_tmdb_movies(self, sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1) -> List[dict]:
|
||||
"""
|
||||
异步TMDB热门电影
|
||||
"""
|
||||
movies = await TmdbChain().async_run_module("async_tmdb_discover", mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return [movie.to_dict() for movie in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc",
|
||||
with_genres: Optional[str] = "",
|
||||
with_original_language: Optional[str] = "zh|en|ja|ko",
|
||||
with_keywords: Optional[str] = "",
|
||||
with_watch_providers: Optional[str] = "",
|
||||
vote_average: Optional[float] = 0.0,
|
||||
vote_count: Optional[int] = 0,
|
||||
release_date: Optional[str] = "",
|
||||
page: Optional[int] = 1) -> List[dict]:
|
||||
"""
|
||||
异步TMDB热门电视剧
|
||||
"""
|
||||
tvs = await TmdbChain().async_run_module("async_tmdb_discover", mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return [tv.to_dict() for tv in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:
|
||||
"""
|
||||
异步TMDB流行趋势
|
||||
"""
|
||||
infos = await TmdbChain().async_run_module("async_tmdb_trending", page=page)
|
||||
return [info.to_dict() for info in infos] if infos else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步Bangumi每日放送
|
||||
"""
|
||||
medias = await BangumiChain().async_run_module("async_bangumi_calendar")
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣正在热映
|
||||
"""
|
||||
movies = await DoubanChain().async_run_module("async_movie_showing", page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "",
|
||||
page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣最新电影
|
||||
"""
|
||||
movies = await DoubanChain().async_run_module("async_douban_discover", mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_tvs(self, sort: Optional[str] = "R", tags: Optional[str] = "",
|
||||
page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣最新电视剧
|
||||
"""
|
||||
tvs = await DoubanChain().async_run_module("async_douban_discover", mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣电影TOP250
|
||||
"""
|
||||
movies = await DoubanChain().async_run_module("async_movie_top250", page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣国产剧集榜
|
||||
"""
|
||||
tvs = await DoubanChain().async_run_module("async_tv_weekly_chinese", page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣全球剧集榜
|
||||
"""
|
||||
tvs = await DoubanChain().async_run_module("async_tv_weekly_global", page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣热门动漫
|
||||
"""
|
||||
tvs = await DoubanChain().async_run_module("async_tv_animation", page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣热门电影
|
||||
"""
|
||||
movies = await DoubanChain().async_run_module("async_movie_hot", page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||
async def async_douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
异步豆瓣热门电视剧
|
||||
"""
|
||||
tvs = await DoubanChain().async_run_module("async_tv_hot", page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import pickle
|
||||
import traceback
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
from typing import Dict, Tuple
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import global_vars
|
||||
from app.core.config import global_vars, settings
|
||||
from app.core.context import Context
|
||||
from app.core.context import MediaInfo, TorrentInfo
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import NotExistMediaInfo
|
||||
@@ -27,22 +30,18 @@ class SearchChain(ChainBase):
|
||||
|
||||
__result_temp_file = "__search_result__"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.siteshelper = SitesHelper()
|
||||
self.progress = ProgressHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
|
||||
def search_by_id(self, tmdbid: int = None, doubanid: str = None,
|
||||
mtype: MediaType = None, area: str = "title", season: int = None) -> List[Context]:
|
||||
def search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
|
||||
sites: List[int] = None, cache_local: bool = False) -> List[Context]:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID搜索资源,精确匹配,但不不过滤本地存在的资源
|
||||
根据TMDBID/豆瓣ID搜索资源,精确匹配,不过滤本地存在的资源
|
||||
:param tmdbid: TMDB ID
|
||||
:param doubanid: 豆瓣 ID
|
||||
:param mtype: 媒体,电影 or 电视剧
|
||||
:param area: 搜索范围,title or imdbid
|
||||
:param season: 季数
|
||||
:param sites: 站点ID列表
|
||||
:param cache_local: 是否缓存到本地
|
||||
"""
|
||||
mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
if not mediainfo:
|
||||
@@ -55,25 +54,27 @@ class SearchChain(ChainBase):
|
||||
season: NotExistMediaInfo(episodes=[])
|
||||
}
|
||||
}
|
||||
results = self.process(mediainfo=mediainfo, area=area, no_exists=no_exists)
|
||||
results = self.process(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists)
|
||||
# 保存到本地文件
|
||||
bytes_results = pickle.dumps(results)
|
||||
self.save_cache(bytes_results, self.__result_temp_file)
|
||||
if cache_local:
|
||||
self.save_cache(results, self.__result_temp_file)
|
||||
return results
|
||||
|
||||
def search_by_title(self, title: str, page: int = 0, site: int = None) -> List[Context]:
|
||||
def search_by_title(self, title: str, page: Optional[int] = 0,
|
||||
sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:
|
||||
"""
|
||||
根据标题搜索资源,不识别不过滤,直接返回站点内容
|
||||
:param title: 标题,为空时返回所有站点首页内容
|
||||
:param page: 页码
|
||||
:param site: 站点ID
|
||||
:param sites: 站点ID列表
|
||||
:param cache_local: 是否缓存到本地
|
||||
"""
|
||||
if title:
|
||||
logger.info(f'开始搜索资源,关键词:{title} ...')
|
||||
else:
|
||||
logger.info(f'开始浏览资源,站点:{site} ...')
|
||||
logger.info(f'开始浏览资源,站点:{sites} ...')
|
||||
# 搜索
|
||||
torrents = self.__search_all_sites(keywords=[title], sites=[site] if site else None, page=page) or []
|
||||
torrents = self.__search_all_sites(keyword=title, sites=sites, page=page) or []
|
||||
if not torrents:
|
||||
logger.warn(f'{title} 未搜索到资源')
|
||||
return []
|
||||
@@ -81,69 +82,86 @@ class SearchChain(ChainBase):
|
||||
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
torrent_info=torrent) for torrent in torrents]
|
||||
# 保存到本地文件
|
||||
bytes_results = pickle.dumps(contexts)
|
||||
self.save_cache(bytes_results, self.__result_temp_file)
|
||||
if cache_local:
|
||||
self.save_cache(contexts, self.__result_temp_file)
|
||||
return contexts
|
||||
|
||||
def last_search_results(self) -> List[Context]:
|
||||
"""
|
||||
获取上次搜索结果
|
||||
"""
|
||||
# 读取本地文件缓存
|
||||
content = self.load_cache(self.__result_temp_file)
|
||||
if not content:
|
||||
return []
|
||||
try:
|
||||
return pickle.loads(content)
|
||||
except Exception as e:
|
||||
logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
|
||||
return []
|
||||
return self.load_cache(self.__result_temp_file)
|
||||
|
||||
def process(self, mediainfo: MediaInfo,
|
||||
keyword: str = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
sites: List[int] = None,
|
||||
rule_groups: List[str] = None,
|
||||
area: str = "title",
|
||||
custom_words: List[str] = None,
|
||||
filter_params: Dict[str, str] = None) -> List[Context]:
|
||||
async def async_last_search_results(self) -> List[Context]:
|
||||
"""
|
||||
根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
||||
:param mediainfo: 媒体信息
|
||||
:param keyword: 搜索关键词
|
||||
:param no_exists: 缺失的媒体信息
|
||||
:param sites: 站点ID列表,为空时搜索所有站点
|
||||
:param rule_groups: 过滤规则组名称列表
|
||||
异步获取上次搜索结果
|
||||
"""
|
||||
return await self.async_load_cache(self.__result_temp_file)
|
||||
|
||||
async def async_search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
|
||||
sites: List[int] = None, cache_local: bool = False) -> List[Context]:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID异步搜索资源,精确匹配,不过滤本地存在的资源
|
||||
:param tmdbid: TMDB ID
|
||||
:param doubanid: 豆瓣 ID
|
||||
:param mtype: 媒体,电影 or 电视剧
|
||||
:param area: 搜索范围,title or imdbid
|
||||
:param custom_words: 自定义识别词列表
|
||||
:param filter_params: 过滤参数
|
||||
:param season: 季数
|
||||
:param sites: 站点ID列表
|
||||
:param cache_local: 是否缓存到本地
|
||||
"""
|
||||
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
if not mediainfo:
|
||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||
return []
|
||||
no_exists = None
|
||||
if season:
|
||||
no_exists = {
|
||||
tmdbid or doubanid: {
|
||||
season: NotExistMediaInfo(episodes=[])
|
||||
}
|
||||
}
|
||||
results = await self.async_process(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists)
|
||||
# 保存到本地文件
|
||||
if cache_local:
|
||||
await self.async_save_cache(results, self.__result_temp_file)
|
||||
return results
|
||||
|
||||
def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
|
||||
"""
|
||||
执行优先级过滤
|
||||
"""
|
||||
return self.filter_torrents(rule_groups=rule_groups,
|
||||
torrent_list=torrent_list,
|
||||
season_episodes=season_episodes,
|
||||
mediainfo=mediainfo) or []
|
||||
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||
|
||||
# 补充媒体信息
|
||||
if not mediainfo.names:
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
if not mediainfo:
|
||||
logger.error(f'媒体信息识别失败!')
|
||||
return []
|
||||
async def async_search_by_title(self, title: str, page: Optional[int] = 0,
|
||||
sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:
|
||||
"""
|
||||
根据标题异步搜索资源,不识别不过滤,直接返回站点内容
|
||||
:param title: 标题,为空时返回所有站点首页内容
|
||||
:param page: 页码
|
||||
:param sites: 站点ID列表
|
||||
:param cache_local: 是否缓存到本地
|
||||
"""
|
||||
if title:
|
||||
logger.info(f'开始搜索资源,关键词:{title} ...')
|
||||
else:
|
||||
logger.info(f'开始浏览资源,站点:{sites} ...')
|
||||
# 搜索
|
||||
torrents = await self.__async_search_all_sites(keyword=title, sites=sites, page=page) or []
|
||||
if not torrents:
|
||||
logger.warn(f'{title} 未搜索到资源')
|
||||
return []
|
||||
# 组装上下文
|
||||
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
torrent_info=torrent) for torrent in torrents]
|
||||
# 保存到本地文件
|
||||
if cache_local:
|
||||
await self.async_save_cache(contexts, self.__result_temp_file)
|
||||
return contexts
|
||||
|
||||
@staticmethod
|
||||
def __prepare_params(mediainfo: MediaInfo,
|
||||
keyword: Optional[str] = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None
|
||||
) -> Tuple[Dict[int, List[int]], List[str]]:
|
||||
"""
|
||||
准备搜索参数
|
||||
"""
|
||||
# 缺失的季集
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
if no_exists and no_exists.get(mediakey):
|
||||
@@ -167,29 +185,49 @@ class SearchChain(ChainBase):
|
||||
mediainfo.hk_title,
|
||||
mediainfo.tw_title,
|
||||
mediainfo.sg_title] if k]))
|
||||
# 限制搜索关键词数量
|
||||
if settings.MAX_SEARCH_NAME_LIMIT:
|
||||
keywords = keywords[:settings.MAX_SEARCH_NAME_LIMIT]
|
||||
|
||||
return season_episodes, keywords
|
||||
|
||||
def __parse_result(self, torrents: List[TorrentInfo],
|
||||
mediainfo: MediaInfo,
|
||||
keyword: Optional[str] = None,
|
||||
rule_groups: List[str] = None,
|
||||
season_episodes: Dict[int, List[int]] = None,
|
||||
custom_words: List[str] = None,
|
||||
filter_params: Dict[str, str] = None) -> List[Context]:
|
||||
"""
|
||||
处理搜索结果
|
||||
"""
|
||||
|
||||
def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
|
||||
"""
|
||||
执行优先级过滤
|
||||
"""
|
||||
return self.filter_torrents(rule_groups=rule_groups,
|
||||
torrent_list=torrent_list,
|
||||
mediainfo=mediainfo) or []
|
||||
|
||||
# 执行搜索
|
||||
torrents: List[TorrentInfo] = self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keywords=keywords,
|
||||
sites=sites,
|
||||
area=area
|
||||
)
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||
return []
|
||||
|
||||
# 开始新进度
|
||||
self.progress.start(ProgressKey.Search)
|
||||
progress = ProgressHelper(ProgressKey.Search)
|
||||
progress.start()
|
||||
|
||||
# 开始过滤
|
||||
self.progress.update(value=0, text=f'开始过滤,总 {len(torrents)} 个资源,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
|
||||
progress.update(value=0, text=f'开始过滤,总 {len(torrents)} 个资源,请稍候...')
|
||||
# 匹配订阅附加参数
|
||||
if filter_params:
|
||||
logger.info(f'开始附加参数过滤,附加参数:{filter_params} ...')
|
||||
torrents = [torrent for torrent in torrents if TorrentHelper().filter_torrent(torrent, filter_params)]
|
||||
# 开始过滤规则过滤
|
||||
if rule_groups is None:
|
||||
# 取搜索过滤规则
|
||||
rule_groups: List[str] = self.systemconfig.get(SystemConfigKey.SearchFilterRuleGroups)
|
||||
rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups)
|
||||
if rule_groups:
|
||||
logger.info(f'开始过滤规则/剧集过滤,使用规则组:{rule_groups} ...')
|
||||
torrents = __do_filter(torrents)
|
||||
@@ -199,39 +237,40 @@ class SearchChain(ChainBase):
|
||||
logger.info(f"过滤规则/剧集过滤完成,剩余 {len(torrents)} 个资源")
|
||||
|
||||
# 过滤完成
|
||||
self.progress.update(value=50, text=f'过滤完成,剩余 {len(torrents)} 个资源', key=ProgressKey.Search)
|
||||
progress.update(value=50, text=f'过滤完成,剩余 {len(torrents)} 个资源')
|
||||
|
||||
# 开始匹配
|
||||
_match_torrents = []
|
||||
# 总数
|
||||
_total = len(torrents)
|
||||
# 已处理数
|
||||
_count = 0
|
||||
|
||||
if mediainfo:
|
||||
# 开始匹配
|
||||
_match_torrents = []
|
||||
torrenthelper = TorrentHelper()
|
||||
try:
|
||||
# 英文标题应该在别名/原标题中,不需要再匹配
|
||||
logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
|
||||
self.progress.update(value=51, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
|
||||
progress.update(value=51, text=f'开始匹配,总 {_total} 个资源 ...')
|
||||
for torrent in torrents:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
_count += 1
|
||||
self.progress.update(value=(_count / _total) * 96,
|
||||
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
|
||||
key=ProgressKey.Search)
|
||||
progress.update(value=(_count / _total) * 96,
|
||||
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...')
|
||||
if not torrent.title:
|
||||
continue
|
||||
|
||||
# 匹配订阅附加参数
|
||||
if filter_params and not self.torrenthelper.filter_torrent(torrent_info=torrent,
|
||||
filter_params=filter_params):
|
||||
continue
|
||||
|
||||
# 识别元数据
|
||||
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description,
|
||||
custom_words=custom_words)
|
||||
if torrent.title != torrent_meta.org_string:
|
||||
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}")
|
||||
# 季集数过滤
|
||||
if season_episodes \
|
||||
and not torrenthelper.match_season_episodes(torrent=torrent,
|
||||
meta=torrent_meta,
|
||||
season_episodes=season_episodes):
|
||||
continue
|
||||
# 比对IMDBID
|
||||
if torrent.imdbid \
|
||||
and mediainfo.imdb_id \
|
||||
@@ -241,53 +280,221 @@ class SearchChain(ChainBase):
|
||||
continue
|
||||
|
||||
# 比对种子
|
||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent):
|
||||
if torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent):
|
||||
# 匹配成功
|
||||
_match_torrents.append((torrent, torrent_meta))
|
||||
continue
|
||||
# 匹配完成
|
||||
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
||||
self.progress.update(value=97,
|
||||
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
|
||||
key=ProgressKey.Search)
|
||||
else:
|
||||
_match_torrents = [(t, MetaInfo(title=t.title, subtitle=t.description)) for t in torrents]
|
||||
progress.update(value=97,
|
||||
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源')
|
||||
|
||||
# 去掉mediainfo中多余的数据
|
||||
mediainfo.clear()
|
||||
|
||||
# 组装上下文
|
||||
contexts = [Context(torrent_info=t[0],
|
||||
media_info=mediainfo,
|
||||
meta_info=t[1]) for t in _match_torrents]
|
||||
# 去掉mediainfo中多余的数据
|
||||
mediainfo.clear()
|
||||
# 组装上下文
|
||||
contexts = [Context(torrent_info=t[0],
|
||||
media_info=mediainfo,
|
||||
meta_info=t[1]) for t in _match_torrents]
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
_match_torrents.clear()
|
||||
del _match_torrents
|
||||
|
||||
# 排序
|
||||
self.progress.update(value=99,
|
||||
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
contexts = self.torrenthelper.sort_torrents(contexts)
|
||||
progress.update(value=99,
|
||||
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...')
|
||||
contexts = torrenthelper.sort_torrents(contexts)
|
||||
|
||||
# 结束进度
|
||||
logger.info(f'搜索完成,共 {len(contexts)} 个资源')
|
||||
self.progress.update(value=100,
|
||||
text=f'搜索完成,共 {len(contexts)} 个资源',
|
||||
key=ProgressKey.Search)
|
||||
self.progress.end(ProgressKey.Search)
|
||||
progress.update(value=100,
|
||||
text=f'搜索完成,共 {len(contexts)} 个资源')
|
||||
progress.end()
|
||||
|
||||
# 返回
|
||||
return contexts
|
||||
# 去重后返回
|
||||
return self.__remove_duplicate(contexts)
|
||||
|
||||
def __search_all_sites(self, keywords: List[str],
|
||||
@staticmethod
|
||||
def __remove_duplicate(_torrents: List[Context]) -> List[Context]:
|
||||
"""
|
||||
去除重复的种子
|
||||
:param _torrents: 种子列表
|
||||
:return: 去重后的种子列表
|
||||
"""
|
||||
if not settings.SEARCH_MULTIPLE_NAME:
|
||||
return _torrents
|
||||
# 通过encosure去重
|
||||
return list({f"{t.torrent_info.site_name}_{t.torrent_info.title}_{t.torrent_info.description}": t
|
||||
for t in _torrents}.values())
|
||||
|
||||
def process(self, mediainfo: MediaInfo,
|
||||
keyword: Optional[str] = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
sites: List[int] = None,
|
||||
rule_groups: List[str] = None,
|
||||
area: Optional[str] = "title",
|
||||
custom_words: List[str] = None,
|
||||
filter_params: Dict[str, str] = None) -> List[Context]:
|
||||
"""
|
||||
根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
||||
:param mediainfo: 媒体信息
|
||||
:param keyword: 搜索关键词
|
||||
:param no_exists: 缺失的媒体信息
|
||||
:param sites: 站点ID列表,为空时搜索所有站点
|
||||
:param rule_groups: 过滤规则组名称列表
|
||||
:param area: 搜索范围,title or imdbid
|
||||
:param custom_words: 自定义识别词列表
|
||||
:param filter_params: 过滤参数
|
||||
"""
|
||||
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||
|
||||
# 补充媒体信息
|
||||
if not mediainfo.names:
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
if not mediainfo:
|
||||
logger.error(f'媒体信息识别失败!')
|
||||
return []
|
||||
|
||||
# 准备搜索参数
|
||||
season_episodes, keywords = self.__prepare_params(
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
no_exists=no_exists
|
||||
)
|
||||
|
||||
# 站点搜索结果
|
||||
torrents: List[TorrentInfo] = []
|
||||
# 站点搜索次数
|
||||
search_count = 0
|
||||
|
||||
# 多关键字执行搜索
|
||||
for search_word in keywords:
|
||||
# 强制休眠 1-10 秒
|
||||
if search_count > 0:
|
||||
logger.info(f"已搜索 {search_count} 次,强制休眠 1-10 秒 ...")
|
||||
time.sleep(random.randint(1, 10))
|
||||
# 搜索站点
|
||||
torrents.extend(
|
||||
self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keyword=search_word,
|
||||
sites=sites,
|
||||
area=area
|
||||
) or []
|
||||
)
|
||||
search_count += 1
|
||||
|
||||
# 处理结果
|
||||
return self.__parse_result(
|
||||
torrents=torrents,
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
rule_groups=rule_groups,
|
||||
season_episodes=season_episodes,
|
||||
custom_words=custom_words,
|
||||
filter_params=filter_params
|
||||
)
|
||||
|
||||
async def async_process(self, mediainfo: MediaInfo,
|
||||
keyword: Optional[str] = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
sites: List[int] = None,
|
||||
rule_groups: List[str] = None,
|
||||
area: Optional[str] = "title",
|
||||
custom_words: List[str] = None,
|
||||
filter_params: Dict[str, str] = None) -> List[Context]:
|
||||
"""
|
||||
根据媒体信息异步搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
||||
:param mediainfo: 媒体信息
|
||||
:param keyword: 搜索关键词
|
||||
:param no_exists: 缺失的媒体信息
|
||||
:param sites: 站点ID列表,为空时搜索所有站点
|
||||
:param rule_groups: 过滤规则组名称列表
|
||||
:param area: 搜索范围,title or imdbid
|
||||
:param custom_words: 自定义识别词列表
|
||||
:param filter_params: 过滤参数
|
||||
"""
|
||||
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||
|
||||
# 补充媒体信息
|
||||
if not mediainfo.names:
|
||||
mediainfo: MediaInfo = await self.async_recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
if not mediainfo:
|
||||
logger.error(f'媒体信息识别失败!')
|
||||
return []
|
||||
|
||||
# 准备搜索参数
|
||||
season_episodes, keywords = self.__prepare_params(
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
no_exists=no_exists
|
||||
)
|
||||
|
||||
# 站点搜索结果
|
||||
torrents: List[TorrentInfo] = []
|
||||
# 站点搜索次数
|
||||
search_count = 0
|
||||
|
||||
# 多关键字执行搜索
|
||||
for search_word in keywords:
|
||||
# 强制休眠 1-10 秒
|
||||
if search_count > 0:
|
||||
logger.info(f"已搜索 {search_count} 次,强制休眠 1-10 秒 ...")
|
||||
await asyncio.sleep(random.randint(1, 10))
|
||||
# 搜索站点
|
||||
torrents.extend(
|
||||
await self.__async_search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keyword=search_word,
|
||||
sites=sites,
|
||||
area=area
|
||||
) or []
|
||||
)
|
||||
search_count += 1
|
||||
# 有结果则停止
|
||||
if torrents:
|
||||
logger.info(f"共搜索到 {len(torrents)} 个资源,停止搜索")
|
||||
break
|
||||
|
||||
# 处理结果
|
||||
return await run_in_threadpool(self.__parse_result,
|
||||
torrents=torrents,
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
rule_groups=rule_groups,
|
||||
season_episodes=season_episodes,
|
||||
custom_words=custom_words,
|
||||
filter_params=filter_params
|
||||
)
|
||||
|
||||
def __search_all_sites(self, keyword: str,
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
sites: List[int] = None,
|
||||
page: int = 0,
|
||||
area: str = "title") -> Optional[List[TorrentInfo]]:
|
||||
page: Optional[int] = 0,
|
||||
area: Optional[str] = "title") -> Optional[List[TorrentInfo]]:
|
||||
"""
|
||||
多线程搜索多个站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param keywords: 搜索关键词列表
|
||||
:param keyword: 搜索关键词
|
||||
:param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点
|
||||
:param page: 搜索页码
|
||||
:param area: 搜索区域 title or imdbid
|
||||
@@ -298,23 +505,19 @@ class SearchChain(ChainBase):
|
||||
|
||||
# 配置的索引站点
|
||||
if not sites:
|
||||
sites = self.systemconfig.get(SystemConfigKey.IndexerSites) or []
|
||||
sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
|
||||
|
||||
for indexer in self.siteshelper.get_indexers():
|
||||
for indexer in SitesHelper().get_indexers():
|
||||
# 检查站点索引开关
|
||||
if not sites or indexer.get("id") in sites:
|
||||
# 站点流控
|
||||
state, msg = self.siteshelper.check(indexer.get("domain"))
|
||||
if state:
|
||||
logger.warn(msg)
|
||||
continue
|
||||
indexer_sites.append(indexer)
|
||||
if not indexer_sites:
|
||||
logger.warn('未开启任何有效站点,无法搜索资源')
|
||||
return []
|
||||
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.Search)
|
||||
progress = ProgressHelper(ProgressKey.Search)
|
||||
progress.start()
|
||||
# 开始计时
|
||||
start_time = datetime.now()
|
||||
# 总数
|
||||
@@ -322,48 +525,131 @@ class SearchChain(ChainBase):
|
||||
# 完成数
|
||||
finish_count = 0
|
||||
# 更新进度
|
||||
self.progress.update(value=0,
|
||||
text=f"开始搜索,共 {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 多线程
|
||||
executor = ThreadPoolExecutor(max_workers=len(indexer_sites))
|
||||
all_task = []
|
||||
for site in indexer_sites:
|
||||
if area == "imdbid":
|
||||
# 搜索IMDBID
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=[mediainfo.imdb_id] if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
else:
|
||||
# 搜索标题
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=keywords,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
all_task.append(task)
|
||||
progress.update(value=0,
|
||||
text=f"开始搜索,共 {total_num} 个站点 ...")
|
||||
# 结果集
|
||||
results = []
|
||||
for future in as_completed(all_task):
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
finish_count += 1
|
||||
result = future.result()
|
||||
if result:
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
self.progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 多线程
|
||||
with ThreadPoolExecutor(max_workers=len(indexer_sites)) as executor:
|
||||
all_task = []
|
||||
for site in indexer_sites:
|
||||
if area == "imdbid":
|
||||
# 搜索IMDBID
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keyword=mediainfo.imdb_id if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
else:
|
||||
# 搜索标题
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keyword=keyword,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
all_task.append(task)
|
||||
for future in as_completed(all_task):
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
finish_count += 1
|
||||
result = future.result()
|
||||
if result:
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个站点 ...")
|
||||
# 计算耗时
|
||||
end_time = datetime.now()
|
||||
# 更新进度
|
||||
self.progress.update(value=100,
|
||||
text=f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒",
|
||||
key=ProgressKey.Search)
|
||||
progress.update(value=100,
|
||||
text=f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒")
|
||||
logger.info(f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒")
|
||||
# 结束进度
|
||||
self.progress.end(ProgressKey.Search)
|
||||
progress.end()
|
||||
|
||||
# 返回
|
||||
return results
|
||||
|
||||
async def __async_search_all_sites(self, keyword: str,
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
sites: List[int] = None,
|
||||
page: Optional[int] = 0,
|
||||
area: Optional[str] = "title") -> Optional[List[TorrentInfo]]:
|
||||
"""
|
||||
异步搜索多个站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param keyword: 搜索关键词
|
||||
:param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点
|
||||
:param page: 搜索页码
|
||||
:param area: 搜索区域 title or imdbid
|
||||
:reutrn: 资源列表
|
||||
"""
|
||||
# 未开启的站点不搜索
|
||||
indexer_sites = []
|
||||
|
||||
# 配置的索引站点
|
||||
if not sites:
|
||||
sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
|
||||
|
||||
for indexer in await SitesHelper().async_get_indexers():
|
||||
# 检查站点索引开关
|
||||
if not sites or indexer.get("id") in sites:
|
||||
indexer_sites.append(indexer)
|
||||
if not indexer_sites:
|
||||
logger.warn('未开启任何有效站点,无法搜索资源')
|
||||
return []
|
||||
|
||||
# 开始进度
|
||||
progress = ProgressHelper(ProgressKey.Search)
|
||||
progress.start()
|
||||
# 开始计时
|
||||
start_time = datetime.now()
|
||||
# 总数
|
||||
total_num = len(indexer_sites)
|
||||
# 完成数
|
||||
finish_count = 0
|
||||
# 更新进度
|
||||
progress.update(value=0,
|
||||
text=f"开始搜索,共 {total_num} 个站点 ...")
|
||||
# 结果集
|
||||
results = []
|
||||
|
||||
# 创建异步任务列表
|
||||
tasks = []
|
||||
for site in indexer_sites:
|
||||
if area == "imdbid":
|
||||
# 搜索IMDBID
|
||||
task = self.async_search_torrents(site=site,
|
||||
keyword=mediainfo.imdb_id if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
else:
|
||||
# 搜索标题
|
||||
task = self.async_search_torrents(site=site,
|
||||
keyword=keyword,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
tasks.append(task)
|
||||
|
||||
# 使用asyncio.as_completed来处理并发任务
|
||||
for future in asyncio.as_completed(tasks):
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
finish_count += 1
|
||||
result = await future
|
||||
if result:
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个站点 ...")
|
||||
|
||||
# 计算耗时
|
||||
end_time = datetime.now()
|
||||
# 更新进度
|
||||
progress.update(value=100,
|
||||
text=f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒")
|
||||
logger.info(f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒")
|
||||
# 结束进度
|
||||
progress.end()
|
||||
|
||||
# 返回
|
||||
return results
|
||||
|
||||
|
||||
@@ -5,11 +5,10 @@ from typing import Optional, Tuple, Union, Dict
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from lxml import etree
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import global_vars, settings
|
||||
from app.core.event import Event, EventManager, eventmanager
|
||||
from app.core.event import Event, eventmanager
|
||||
from app.db.models.site import Site
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
@@ -17,9 +16,8 @@ from app.helper.browser import PlaywrightHelper
|
||||
from app.helper.cloudflare import under_challenge
|
||||
from app.helper.cookie import CookieHelper
|
||||
from app.helper.cookiecloud import CookieCloudHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.log import logger
|
||||
from app.schemas import MessageChannel, Notification, SiteUserData
|
||||
from app.schemas.types import EventType, NotificationType
|
||||
@@ -35,13 +33,6 @@ class SiteChain(ChainBase):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.siteoper = SiteOper()
|
||||
self.siteshelper = SitesHelper()
|
||||
self.rsshelper = RssHelper()
|
||||
self.cookiehelper = CookieHelper()
|
||||
self.message = MessageHelper()
|
||||
self.cookiecloud = CookieCloudHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
# 特殊站点登录验证
|
||||
self.special_site_test = {
|
||||
@@ -52,9 +43,10 @@ class SiteChain(ChainBase):
|
||||
"1ptba.com": self.__indexphp_test,
|
||||
"star-space.net": self.__indexphp_test,
|
||||
"yemapt.org": self.__yema_test,
|
||||
"hddolby.com": self.__hddolby_test,
|
||||
}
|
||||
|
||||
def refresh_userdata(self, site: CommentedMap = None) -> Optional[SiteUserData]:
|
||||
def refresh_userdata(self, site: dict = None) -> Optional[SiteUserData]:
|
||||
"""
|
||||
刷新站点的用户数据
|
||||
:param site: 站点
|
||||
@@ -62,11 +54,11 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
userdata: SiteUserData = self.run_module("refresh_userdata", site=site)
|
||||
if userdata:
|
||||
self.siteoper.update_userdata(domain=StringUtils.get_url_domain(site.get("domain")),
|
||||
name=site.get("name"),
|
||||
payload=userdata.dict())
|
||||
SiteOper().update_userdata(domain=StringUtils.get_url_domain(site.get("domain")),
|
||||
name=site.get("name"),
|
||||
payload=userdata.dict())
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SiteRefreshed, {
|
||||
eventmanager.send_event(EventType.SiteRefreshed, {
|
||||
"site_id": site.get("id")
|
||||
})
|
||||
# 发送站点消息
|
||||
@@ -88,7 +80,7 @@ class SiteChain(ChainBase):
|
||||
))
|
||||
# 低分享率警告
|
||||
if userdata.ratio and float(userdata.ratio) < 1 and not bool(
|
||||
re.search(r"(贵宾|VIP?)", userdata.user_level, re.IGNORECASE)):
|
||||
re.search(r"(贵宾|VIP?)", userdata.user_level or "", re.IGNORECASE)):
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【站点分享率低预警】",
|
||||
@@ -100,10 +92,9 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
刷新所有站点的用户数据
|
||||
"""
|
||||
sites = self.siteshelper.get_indexers()
|
||||
any_site_updated = False
|
||||
result = {}
|
||||
for site in sites:
|
||||
for site in SitesHelper().get_indexers():
|
||||
if global_vars.is_system_stopped:
|
||||
return None
|
||||
if site.get("is_active"):
|
||||
@@ -112,9 +103,10 @@ class SiteChain(ChainBase):
|
||||
any_site_updated = True
|
||||
result[site.get("name")] = userdata
|
||||
if any_site_updated:
|
||||
EventManager().send_event(EventType.SiteRefreshed, {
|
||||
eventmanager.send_event(EventType.SiteRefreshed, {
|
||||
"site_id": "*"
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def is_special_site(self, domain: str) -> bool:
|
||||
@@ -137,10 +129,14 @@ class SiteChain(ChainBase):
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=site.timeout or 15
|
||||
).get_res(url=site.url)
|
||||
if res and res.status_code == 200:
|
||||
if res is None:
|
||||
return False, "无法打开网站!"
|
||||
if res.status_code == 200:
|
||||
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
|
||||
if csrf_token:
|
||||
token = csrf_token.group(1)
|
||||
else:
|
||||
return False, f"错误:{res.status_code} {res.reason}"
|
||||
if not token:
|
||||
return False, "无法获取Token"
|
||||
# 调用查询用户信息接口
|
||||
@@ -154,11 +150,15 @@ class SiteChain(ChainBase):
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=site.timeout or 15
|
||||
).get_res(url=f"{site.url}api/user/getInfo")
|
||||
if user_res and user_res.status_code == 200:
|
||||
if user_res is None:
|
||||
return False, "无法打开网站!"
|
||||
if user_res.status_code == 200:
|
||||
user_info = user_res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
return True, "连接成功"
|
||||
return False, "Cookie已失效"
|
||||
return False, "Cookie已失效"
|
||||
else:
|
||||
return False, f"错误:{user_res.status_code} {user_res.reason}"
|
||||
|
||||
@staticmethod
|
||||
def __mteam_test(site: Site) -> Tuple[bool, str]:
|
||||
@@ -169,30 +169,24 @@ class SiteChain(ChainBase):
|
||||
domain = StringUtils.get_url_domain(site.url)
|
||||
url = f"https://api.{domain}/api/member/profile"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": user_agent,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Authorization": site.token
|
||||
"x-api-key": site.apikey,
|
||||
}
|
||||
res = RequestUtils(
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=site.timeout or 15
|
||||
).post_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
user_info = res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
# 更新最后访问时间
|
||||
res = RequestUtils(headers=headers,
|
||||
timeout=site.timeout or 15,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
referer=f"{site.url}index"
|
||||
).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse")
|
||||
if res:
|
||||
return True, "连接成功"
|
||||
else:
|
||||
return True, f"连接成功,但更新状态失败"
|
||||
return False, "鉴权已过期或无效"
|
||||
if res is None:
|
||||
return False, "无法打开网站!"
|
||||
if res.status_code == 200:
|
||||
user_info = res.json() or {}
|
||||
if user_info.get("data"):
|
||||
return True, "连接成功"
|
||||
return False, user_info.get("message", "鉴权已过期或无效")
|
||||
else:
|
||||
return False, f"错误:{res.status_code} {res.reason}"
|
||||
|
||||
@staticmethod
|
||||
def __yema_test(site: Site) -> Tuple[bool, str]:
|
||||
@@ -212,11 +206,15 @@ class SiteChain(ChainBase):
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=site.timeout or 15
|
||||
).get_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
if res is None:
|
||||
return False, "无法打开网站!"
|
||||
if res.status_code == 200:
|
||||
user_info = res.json()
|
||||
if user_info and user_info.get("success"):
|
||||
return True, "连接成功"
|
||||
return False, "Cookie已过期"
|
||||
return False, "Cookie已过期"
|
||||
else:
|
||||
return False, f"错误:{res.status_code} {res.reason}"
|
||||
|
||||
def __indexphp_test(self, site: Site) -> Tuple[bool, str]:
|
||||
"""
|
||||
@@ -225,6 +223,32 @@ class SiteChain(ChainBase):
|
||||
site.url = f"{site.url}index.php"
|
||||
return self.__test(site)
|
||||
|
||||
@staticmethod
|
||||
def __hddolby_test(site: Site) -> Tuple[bool, str]:
|
||||
"""
|
||||
判断站点是否已经登陆:hddolby
|
||||
"""
|
||||
url = f"{site.url}api/v1/user/data"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"x-api-key": site.apikey,
|
||||
}
|
||||
res = RequestUtils(
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=site.timeout or 15
|
||||
).get_res(url=url)
|
||||
if res is None:
|
||||
return False, "无法打开网站!"
|
||||
if res.status_code == 200:
|
||||
user_info = res.json()
|
||||
if user_info and user_info.get("status") == 0:
|
||||
return True, "连接成功"
|
||||
return False, "APIKEY已过期"
|
||||
else:
|
||||
return False, f"错误:{res.status_code} {res.reason}"
|
||||
|
||||
@staticmethod
|
||||
def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
@@ -242,16 +266,20 @@ class SiteChain(ChainBase):
|
||||
logger.error(f"获取站点页面失败:{url}")
|
||||
return favicon_url, None
|
||||
html = etree.HTML(html_text)
|
||||
if StringUtils.is_valid_html_element(html):
|
||||
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
|
||||
if fav_link:
|
||||
favicon_url = urljoin(url, fav_link[0])
|
||||
try:
|
||||
if StringUtils.is_valid_html_element(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=15, ua=ua).get_res(url=favicon_url)
|
||||
if res:
|
||||
return favicon_url, base64.b64encode(res.content).decode()
|
||||
else:
|
||||
logger.error(f"获取站点图标失败:{favicon_url}")
|
||||
res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)
|
||||
if res:
|
||||
return favicon_url, base64.b64encode(res.content).decode()
|
||||
else:
|
||||
logger.error(f"获取站点图标失败:{favicon_url}")
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
return favicon_url, None
|
||||
|
||||
def sync_cookies(self, manual=False) -> Tuple[bool, str]:
|
||||
@@ -265,28 +293,36 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
if StringUtils.get_url_domain(inx.get("domain")) == sub_domain:
|
||||
return inx.get("domain")
|
||||
for ext_d in inx.get("ext_domains"):
|
||||
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()
|
||||
cookies, msg = CookieCloudHelper().download()
|
||||
if not cookies:
|
||||
logger.error(f"CookieCloud同步失败:{msg}")
|
||||
if manual:
|
||||
self.message.put(msg, title="CookieCloud同步失败", role="system")
|
||||
self.messagehelper.put(msg, title="CookieCloud同步失败", role="system")
|
||||
return False, msg
|
||||
# 保存Cookie或新增站点
|
||||
_update_count = 0
|
||||
_add_count = 0
|
||||
_fail_count = 0
|
||||
siteshelper = SitesHelper()
|
||||
siteoper = SiteOper()
|
||||
rsshelper = RssHelper()
|
||||
for domain, cookie in cookies.items():
|
||||
# 检查系统是否停止
|
||||
if global_vars.is_system_stopped:
|
||||
logger.info("系统正在停止,中断CookieCloud同步")
|
||||
return False, "系统正在停止,同步被中断"
|
||||
|
||||
# 索引器信息
|
||||
indexer = self.siteshelper.get_indexer(domain)
|
||||
indexer = siteshelper.get_indexer(domain)
|
||||
# 数据库的站点信息
|
||||
site_info = self.siteoper.get_by_domain(domain)
|
||||
if site_info and site_info.is_active == 1:
|
||||
site_info = siteoper.get_by_domain(domain)
|
||||
if site_info and site_info.is_active:
|
||||
# 站点已存在,检查站点连通性
|
||||
status, msg = self.test(domain)
|
||||
# 更新站点Cookie
|
||||
@@ -295,21 +331,22 @@ class SiteChain(ChainBase):
|
||||
# 更新站点rss地址
|
||||
if not site_info.public and not site_info.rss:
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(
|
||||
rss_url, errmsg = 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
|
||||
proxy=True if site_info.proxy else False,
|
||||
timeout=site_info.timeout or 15
|
||||
)
|
||||
if rss_url:
|
||||
logger.info(f"更新站点 {domain} RSS地址 ...")
|
||||
self.siteoper.update_rss(domain=domain, rss=rss_url)
|
||||
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)
|
||||
siteoper.update_cookie(domain=domain, cookies=cookie)
|
||||
_update_count += 1
|
||||
elif indexer:
|
||||
if settings.COOKIECLOUD_BLACKLIST and any(
|
||||
@@ -324,9 +361,10 @@ class SiteChain(ChainBase):
|
||||
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):
|
||||
content = res.text
|
||||
if not indexer.get("public") and not SiteUtils.is_logged_in(content):
|
||||
_fail_count += 1
|
||||
if under_challenge(res.text):
|
||||
if under_challenge(content):
|
||||
logger.warn(f"站点 {indexer.get('name')} 被Cloudflare防护,无法登录,无法添加站点")
|
||||
continue
|
||||
logger.warn(
|
||||
@@ -364,26 +402,26 @@ class SiteChain(ChainBase):
|
||||
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,
|
||||
proxy=proxy)
|
||||
rss_url, errmsg = rsshelper.get_rss_link(url=domain_url,
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT,
|
||||
proxy=proxy)
|
||||
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,
|
||||
proxy=1 if proxy else 0,
|
||||
public=1 if indexer.get("public") else 0)
|
||||
siteoper.add(name=indexer.get("name"),
|
||||
url=domain_url,
|
||||
domain=domain,
|
||||
cookie=cookie,
|
||||
rss=rss_url,
|
||||
proxy=1 if proxy else 0,
|
||||
public=1 if indexer.get("public") else 0)
|
||||
_add_count += 1
|
||||
|
||||
# 通知站点更新
|
||||
if indexer:
|
||||
EventManager().send_event(EventType.SiteUpdated, {
|
||||
eventmanager.send_event(EventType.SiteUpdated, {
|
||||
"domain": domain,
|
||||
})
|
||||
# 处理完成
|
||||
@@ -391,7 +429,7 @@ class SiteChain(ChainBase):
|
||||
if _fail_count > 0:
|
||||
ret_msg += f",{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
|
||||
if manual:
|
||||
self.message.put(ret_msg, title="CookieCloud同步成功", role="system")
|
||||
self.messagehelper.put(ret_msg, title="CookieCloud同步成功", role="system")
|
||||
logger.info(f"CookieCloud同步成功:{ret_msg}")
|
||||
return True, ret_msg
|
||||
|
||||
@@ -410,29 +448,31 @@ class SiteChain(ChainBase):
|
||||
if str(domain).startswith("http"):
|
||||
domain = StringUtils.get_url_domain(domain)
|
||||
# 站点信息
|
||||
siteinfo = self.siteoper.get_by_domain(domain)
|
||||
siteoper = SiteOper()
|
||||
siteshelper = SitesHelper()
|
||||
siteinfo = siteoper.get_by_domain(domain)
|
||||
if not siteinfo:
|
||||
logger.warn(f"未维护站点 {domain} 信息!")
|
||||
return
|
||||
# Cookie
|
||||
cookie = siteinfo.cookie
|
||||
# 索引器
|
||||
indexer = self.siteshelper.get_indexer(domain)
|
||||
indexer = siteshelper.get_indexer(domain)
|
||||
if not indexer:
|
||||
logger.warn(f"站点 {domain} 索引器不存在!")
|
||||
return
|
||||
# 查询站点图标
|
||||
site_icon = self.siteoper.get_icon_by_domain(domain)
|
||||
site_icon = siteoper.get_icon_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.siteoper.update_icon(name=indexer.get("name"),
|
||||
domain=domain,
|
||||
icon_url=icon_url,
|
||||
icon_base64=icon_base64)
|
||||
siteoper.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')} 图标失败")
|
||||
@@ -452,11 +492,12 @@ class SiteChain(ChainBase):
|
||||
# 获取主域名中间那段
|
||||
domain_host = StringUtils.get_url_host(domain)
|
||||
# 查询以"site.domain_host"开头的配置项,并清除
|
||||
site_keys = self.systemconfig.all().keys()
|
||||
systemconfig = SystemConfigOper()
|
||||
site_keys = systemconfig.all().keys()
|
||||
for key in site_keys:
|
||||
if key.startswith(f"site.{domain_host}"):
|
||||
logger.info(f"清理站点配置:{key}")
|
||||
self.systemconfig.delete(key)
|
||||
systemconfig.delete(key)
|
||||
|
||||
@eventmanager.register(EventType.SiteUpdated)
|
||||
def cache_site_userdata(self, event: Event):
|
||||
@@ -472,7 +513,7 @@ class SiteChain(ChainBase):
|
||||
return
|
||||
if str(domain).startswith("http"):
|
||||
domain = StringUtils.get_url_domain(domain)
|
||||
indexer = self.siteshelper.get_indexer(domain)
|
||||
indexer = SitesHelper().get_indexer(domain)
|
||||
if not indexer:
|
||||
return
|
||||
# 刷新站点用户数据
|
||||
@@ -486,7 +527,8 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
# 检查域名是否可用
|
||||
domain = StringUtils.get_url_domain(url)
|
||||
site_info = self.siteoper.get_by_domain(domain)
|
||||
siteoper = SiteOper()
|
||||
site_info = siteoper.get_by_domain(domain)
|
||||
if not site_info:
|
||||
return False, f"站点【{url}】不存在"
|
||||
|
||||
@@ -503,9 +545,9 @@ class SiteChain(ChainBase):
|
||||
# 统计
|
||||
seconds = (datetime.now() - start_time).seconds
|
||||
if state:
|
||||
self.siteoper.success(domain=domain, seconds=seconds)
|
||||
siteoper.success(domain=domain, seconds=seconds)
|
||||
else:
|
||||
self.siteoper.fail(domain)
|
||||
siteoper.fail(domain)
|
||||
return state, message
|
||||
except Exception as e:
|
||||
return False, f"{str(e)}!"
|
||||
@@ -522,13 +564,15 @@ class SiteChain(ChainBase):
|
||||
public = site_info.public
|
||||
proxies = settings.PROXY if site_info.proxy else None
|
||||
proxy_server = settings.PROXY_SERVER if site_info.proxy else None
|
||||
timeout = site_info.timeout or 60
|
||||
|
||||
# 访问链接
|
||||
if render:
|
||||
page_source = PlaywrightHelper().get_page_source(url=site_url,
|
||||
cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxy_server)
|
||||
proxies=proxy_server,
|
||||
timeout=timeout)
|
||||
if not public and not SiteUtils.is_logged_in(page_source):
|
||||
if under_challenge(page_source):
|
||||
return False, f"无法通过Cloudflare!"
|
||||
@@ -540,28 +584,29 @@ class SiteChain(ChainBase):
|
||||
).get_res(url=site_url)
|
||||
# 判断登录状态
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
if not public and not SiteUtils.is_logged_in(res.text):
|
||||
if under_challenge(res.text):
|
||||
content = res.text
|
||||
if not public and not SiteUtils.is_logged_in(content):
|
||||
if under_challenge(content):
|
||||
msg = "站点被Cloudflare防护,请打开站点浏览器仿真"
|
||||
elif res.status_code == 200:
|
||||
msg = "Cookie已失效"
|
||||
else:
|
||||
msg = f"状态码:{res.status_code}"
|
||||
msg = f"错误:{res.status_code} {res.reason}"
|
||||
return False, f"{msg}!"
|
||||
elif public and res.status_code != 200:
|
||||
return False, f"状态码:{res.status_code}!"
|
||||
return False, f"错误:{res.status_code} {res.reason}!"
|
||||
elif res is not None:
|
||||
return False, f"状态码:{res.status_code}!"
|
||||
return False, f"错误:{res.status_code} {res.reason}!"
|
||||
else:
|
||||
return False, f"无法打开网站!"
|
||||
return True, "连接成功"
|
||||
|
||||
def remote_list(self, channel: MessageChannel,
|
||||
userid: Union[str, int] = None, source: str = None):
|
||||
userid: Union[str, int] = None, source: Optional[str] = None):
|
||||
"""
|
||||
查询所有站点,发送消息
|
||||
"""
|
||||
site_list = self.siteoper.list()
|
||||
site_list = SiteOper().list()
|
||||
if not site_list:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
@@ -591,7 +636,7 @@ class SiteChain(ChainBase):
|
||||
)
|
||||
|
||||
def remote_disable(self, arg_str: str, channel: MessageChannel,
|
||||
userid: Union[str, int] = None, source: str = None):
|
||||
userid: Union[str, int] = None, source: Optional[str] = None):
|
||||
"""
|
||||
禁用站点
|
||||
"""
|
||||
@@ -601,7 +646,8 @@ class SiteChain(ChainBase):
|
||||
if not arg_str.isdigit():
|
||||
return
|
||||
site_id = int(arg_str)
|
||||
site = self.siteoper.get(site_id)
|
||||
siteoper = SiteOper()
|
||||
site = siteoper.get(site_id)
|
||||
if not site:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
@@ -609,40 +655,42 @@ class SiteChain(ChainBase):
|
||||
userid=userid))
|
||||
return
|
||||
# 禁用站点
|
||||
self.siteoper.update(site_id, {
|
||||
siteoper.update(site_id, {
|
||||
"is_active": False
|
||||
})
|
||||
# 重新发送消息
|
||||
self.remote_list(channel=channel, userid=userid, source=source)
|
||||
|
||||
def remote_enable(self, arg_str: str, channel: MessageChannel,
|
||||
userid: Union[str, int] = None, source: str = None):
|
||||
userid: Union[str, int] = None, source: Optional[str] = None):
|
||||
"""
|
||||
启用站点
|
||||
"""
|
||||
if not arg_str:
|
||||
return
|
||||
arg_strs = str(arg_str).split()
|
||||
siteoper = SiteOper()
|
||||
for arg_str in arg_strs:
|
||||
arg_str = arg_str.strip()
|
||||
if not arg_str.isdigit():
|
||||
continue
|
||||
site_id = int(arg_str)
|
||||
site = self.siteoper.get(site_id)
|
||||
site = siteoper.get(site_id)
|
||||
if not site:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
title=f"站点编号 {site_id} 不存在!", userid=userid))
|
||||
return
|
||||
# 禁用站点
|
||||
self.siteoper.update(site_id, {
|
||||
siteoper.update(site_id, {
|
||||
"is_active": True
|
||||
})
|
||||
# 重新发送消息
|
||||
self.remote_list(channel=channel, userid=userid, source=source)
|
||||
|
||||
def update_cookie(self, site_info: Site,
|
||||
username: str, password: str, two_step_code: str = None) -> Tuple[bool, str]:
|
||||
@staticmethod
|
||||
def update_cookie(site_info: Site,
|
||||
username: str, password: str, two_step_code: Optional[str] = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
根据用户名密码更新站点Cookie
|
||||
:param site_info: 站点信息
|
||||
@@ -652,18 +700,19 @@ class SiteChain(ChainBase):
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
# 更新站点Cookie
|
||||
result = self.cookiehelper.get_site_cookie_ua(
|
||||
result = CookieHelper().get_site_cookie_ua(
|
||||
url=site_info.url,
|
||||
username=username,
|
||||
password=password,
|
||||
two_step_code=two_step_code,
|
||||
proxies=settings.PROXY_HOST if site_info.proxy else None
|
||||
proxies=settings.PROXY_SERVER if site_info.proxy else None,
|
||||
timeout=site_info.timeout or 60
|
||||
)
|
||||
if result:
|
||||
cookie, ua, msg = result
|
||||
if not cookie:
|
||||
return False, msg
|
||||
self.siteoper.update(site_info.id, {
|
||||
SiteOper().update(site_info.id, {
|
||||
"cookie": cookie,
|
||||
"ua": ua
|
||||
})
|
||||
@@ -671,7 +720,7 @@ class SiteChain(ChainBase):
|
||||
return False, "未知错误"
|
||||
|
||||
def remote_cookie(self, arg_str: str, channel: MessageChannel,
|
||||
userid: Union[str, int] = None, source: str = None):
|
||||
userid: Union[str, int] = None, source: Optional[str] = None):
|
||||
"""
|
||||
使用用户名密码更新站点Cookie
|
||||
"""
|
||||
@@ -705,7 +754,7 @@ class SiteChain(ChainBase):
|
||||
# 站点ID
|
||||
site_id = int(site_id)
|
||||
# 站点信息
|
||||
site_info = self.siteoper.get(site_id)
|
||||
site_info = SiteOper().get(site_id)
|
||||
if not site_info:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
@@ -741,7 +790,7 @@ class SiteChain(ChainBase):
|
||||
userid=userid))
|
||||
|
||||
def remote_refresh_userdatas(self, channel: MessageChannel,
|
||||
userid: Union[str, int] = None, source: str = None):
|
||||
userid: Union[str, int] = None, source: Optional[str] = None):
|
||||
"""
|
||||
刷新所有站点用户数据
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,6 @@ from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
|
||||
|
||||
class StorageChain(ChainBase):
|
||||
@@ -14,16 +13,18 @@ class StorageChain(ChainBase):
|
||||
存储处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
|
||||
def save_config(self, storage: str, conf: dict) -> None:
|
||||
"""
|
||||
保存存储配置
|
||||
"""
|
||||
self.run_module("save_config", storage=storage, conf=conf)
|
||||
|
||||
def reset_config(self, storage: str) -> None:
|
||||
"""
|
||||
重置存储配置
|
||||
"""
|
||||
self.run_module("reset_config", storage=storage)
|
||||
|
||||
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
|
||||
"""
|
||||
生成二维码
|
||||
@@ -63,7 +64,7 @@ class StorageChain(ChainBase):
|
||||
return self.run_module("download_file", fileitem=fileitem, path=path)
|
||||
|
||||
def upload_file(self, fileitem: schemas.FileItem, path: Path,
|
||||
new_name: str = None) -> Optional[schemas.FileItem]:
|
||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
:param fileitem: 保存目录项
|
||||
@@ -84,6 +85,12 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("rename_file", fileitem=fileitem, name=name)
|
||||
|
||||
def exists(self, fileitem: schemas.FileItem) -> Optional[bool]:
|
||||
"""
|
||||
判断文件或目录是否存在
|
||||
"""
|
||||
return True if self.get_item(fileitem) else False
|
||||
|
||||
def get_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
查询目录或文件
|
||||
@@ -102,11 +109,17 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("get_parent_item", fileitem=fileitem)
|
||||
|
||||
def snapshot_storage(self, storage: str, path: Path) -> Optional[Dict[str, float]]:
|
||||
def snapshot_storage(self, storage: str, path: Path,
|
||||
last_snapshot_time: float = None, max_depth: int = 5) -> Optional[Dict[str, Dict]]:
|
||||
"""
|
||||
快照存储
|
||||
:param storage: 存储类型
|
||||
:param path: 路径
|
||||
:param last_snapshot_time: 上次快照时间,用于增量快照
|
||||
:param max_depth: 最大递归深度,避免过深遍历
|
||||
"""
|
||||
return self.run_module("snapshot_storage", storage=storage, path=path)
|
||||
return self.run_module("snapshot_storage", storage=storage, path=path,
|
||||
last_snapshot_time=last_snapshot_time, max_depth=max_depth)
|
||||
|
||||
def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]:
|
||||
"""
|
||||
@@ -120,58 +133,79 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("support_transtype", storage=storage)
|
||||
|
||||
def delete_media_file(self, fileitem: schemas.FileItem,
|
||||
mtype: MediaType = None, delete_self: bool = True) -> bool:
|
||||
def delete_media_file(self, fileitem: schemas.FileItem, delete_self: bool = True) -> bool:
|
||||
"""
|
||||
删除媒体文件,以及不含媒体文件的目录
|
||||
"""
|
||||
|
||||
def __is_bluray_dir(_fileitem: schemas.FileItem) -> bool:
|
||||
"""
|
||||
检查是否蓝光目录
|
||||
"""
|
||||
_dir_files = self.list_files(fileitem=_fileitem, recursion=False)
|
||||
if _dir_files:
|
||||
for _f in _dir_files:
|
||||
if _f.type == "dir" and _f.name in ["BDMV", "CERTIFICATE"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
media_exts = settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT
|
||||
if fileitem.path == "/" or len(Path(fileitem.path).parts) <= 2:
|
||||
fileitem_path = Path(fileitem.path) if fileitem.path else Path("")
|
||||
if len(fileitem_path.parts) <= 2:
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 根目录或一级目录不允许删除")
|
||||
return False
|
||||
if fileitem.type == "dir":
|
||||
# 本身是目录
|
||||
if _blue_dir := self.list_files(fileitem=fileitem, recursion=False):
|
||||
# 删除蓝光目录
|
||||
for _f in _blue_dir:
|
||||
if _f.type == "dir" and _f.name in ["BDMV", "CERTIFICATE"]:
|
||||
logger.warn(f"【{fileitem.storage}】{_f.path} 删除蓝光目录")
|
||||
self.delete_file(_f)
|
||||
if self.any_files(fileitem, extensions=media_exts) is False:
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 不存在其它媒体文件,删除空目录")
|
||||
return self.delete_file(fileitem)
|
||||
return False
|
||||
if __is_bluray_dir(fileitem):
|
||||
logger.warn(f"正在删除蓝光原盘目录:【{fileitem.storage}】{fileitem.path}")
|
||||
if not self.delete_file(fileitem):
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||
return False
|
||||
|
||||
elif delete_self:
|
||||
# 本身是文件
|
||||
logger.warn(f"正在删除【{fileitem.storage}】{fileitem.path}")
|
||||
# 本身是文件,需要删除文件
|
||||
logger.warn(f"正在删除文件【{fileitem.storage}】{fileitem.path}")
|
||||
if not self.delete_file(fileitem):
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||
return False
|
||||
if mtype:
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mtype == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
# 计算重命名中的文件夹层数
|
||||
rename_format_level = len(rename_format.split("/")) - 1
|
||||
if rename_format_level < 1:
|
||||
return True
|
||||
# 处理上级目录
|
||||
dir_item = self.get_file_item(storage=fileitem.storage,
|
||||
path=Path(fileitem.path).parents[rename_format_level - 1])
|
||||
else:
|
||||
dir_item = self.get_parent_item(fileitem)
|
||||
if dir_item and len(Path(dir_item.path).parts) > 2:
|
||||
# 如何目录是所有下载目录、媒体库目录的上级,则不处理
|
||||
for d in self.directoryhelper.get_dirs():
|
||||
if d.download_path and Path(d.download_path).is_relative_to(Path(dir_item.path)):
|
||||
logger.debug(f"【{dir_item.storage}】{dir_item.path} 是下载目录本级或上级目录,不删除")
|
||||
return True
|
||||
if d.library_path and Path(d.library_path).is_relative_to(Path(dir_item.path)):
|
||||
logger.debug(f"【{dir_item.storage}】{dir_item.path} 是媒体库目录本级或上级目录,不删除")
|
||||
return True
|
||||
# 不存在其他媒体文件,删除空目录
|
||||
if self.any_files(dir_item, extensions=media_exts) is False:
|
||||
logger.warn(f"【{dir_item.storage}】{dir_item.path} 不存在其它媒体文件,删除空目录")
|
||||
return self.delete_file(dir_item)
|
||||
|
||||
# 检查和删除上级空目录
|
||||
dir_item = fileitem if fileitem.type == "dir" else self.get_parent_item(fileitem)
|
||||
if not dir_item:
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 上级目录不存在")
|
||||
return False
|
||||
|
||||
# 查找操作文件项匹配的配置目录(资源目录、媒体库目录)
|
||||
associated_dir = max(
|
||||
(
|
||||
Path(p)
|
||||
for d in DirectoryHelper().get_dirs()
|
||||
for p in (d.download_path, d.library_path)
|
||||
if p and fileitem_path.is_relative_to(p)
|
||||
),
|
||||
key=lambda path: len(path.parts),
|
||||
default=None,
|
||||
)
|
||||
|
||||
while dir_item and len(Path(dir_item.path).parts) > 2:
|
||||
# 目录是资源目录、媒体库目录的上级,则不处理
|
||||
if associated_dir and associated_dir.is_relative_to(Path(dir_item.path)):
|
||||
logger.debug(f"【{dir_item.storage}】{dir_item.path} 位于资源或媒体库目录结构中,不删除")
|
||||
break
|
||||
|
||||
elif not associated_dir and self.list_files(dir_item, recursion=False):
|
||||
logger.debug(f"【{dir_item.storage}】{dir_item.path} 不是空目录,不删除")
|
||||
break
|
||||
|
||||
if self.any_files(dir_item, extensions=media_exts) is not False:
|
||||
logger.debug(f"【{dir_item.storage}】{dir_item.path} 存在媒体文件,不删除")
|
||||
break
|
||||
|
||||
# 删除空目录并继续处理父目录
|
||||
logger.warn(f"【{dir_item.storage}】{dir_item.path} 不存在其它媒体文件,正在删除空目录")
|
||||
if not self.delete_file(dir_item):
|
||||
logger.warn(f"【{dir_item.storage}】{dir_item.path} 删除失败")
|
||||
return False
|
||||
dir_item = self.get_parent_item(dir_item)
|
||||
|
||||
return True
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,28 @@
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from typing import Union, Optional
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.plugin import PluginManager
|
||||
from app.helper.system import SystemHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, MessageChannel
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
from version import FRONTEND_VERSION, APP_VERSION
|
||||
|
||||
|
||||
class SystemChain(ChainBase, metaclass=Singleton):
|
||||
class SystemChain(ChainBase):
|
||||
"""
|
||||
系统级处理链
|
||||
"""
|
||||
|
||||
_restart_file = "__system_restart__"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 重启完成检测
|
||||
self.restart_finish()
|
||||
|
||||
def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str], source: str = None):
|
||||
def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None):
|
||||
"""
|
||||
清理系统缓存
|
||||
"""
|
||||
@@ -33,10 +30,12 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
||||
self.post_message(Notification(channel=channel, source=source,
|
||||
title=f"缓存清理完成!", userid=userid))
|
||||
|
||||
def restart(self, channel: MessageChannel, userid: Union[int, str], source: str = None):
|
||||
def restart(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None):
|
||||
"""
|
||||
重启系统
|
||||
"""
|
||||
from app.core.config import global_vars
|
||||
|
||||
if channel and userid:
|
||||
self.post_message(Notification(channel=channel, source=source,
|
||||
title="系统正在重启,请耐心等候!", userid=userid))
|
||||
@@ -45,7 +44,119 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
||||
"channel": channel.value,
|
||||
"userid": userid
|
||||
}, self._restart_file)
|
||||
SystemUtils.restart()
|
||||
# 主动备份一次插件
|
||||
self.backup_plugins()
|
||||
# 设置停止标志,通知所有模块准备停止
|
||||
global_vars.stop_system()
|
||||
# 重启
|
||||
SystemHelper.restart()
|
||||
|
||||
@staticmethod
|
||||
def backup_plugins():
|
||||
"""
|
||||
备份插件到用户配置目录(仅docker环境)
|
||||
"""
|
||||
|
||||
# 非docker环境不处理
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
|
||||
try:
|
||||
# 使用绝对路径确保准确性
|
||||
plugins_dir = settings.ROOT_PATH / "app" / "plugins"
|
||||
backup_dir = settings.CONFIG_PATH / "plugins_backup"
|
||||
|
||||
if not plugins_dir.exists():
|
||||
logger.info("插件目录不存在,跳过备份")
|
||||
return
|
||||
|
||||
# 确保备份目录存在
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 需要排除的文件和目录
|
||||
exclude_items = {"__init__.py", "__pycache__", ".DS_Store"}
|
||||
|
||||
# 遍历插件目录,备份除排除项外的所有内容
|
||||
for item in plugins_dir.iterdir():
|
||||
if item.name in exclude_items:
|
||||
continue
|
||||
|
||||
target_path = backup_dir / item.name
|
||||
|
||||
# 如果是目录
|
||||
if item.is_dir():
|
||||
if target_path.exists():
|
||||
continue
|
||||
shutil.copytree(item, target_path)
|
||||
logger.info(f"已备份插件目录: {item.name}")
|
||||
# 如果是文件
|
||||
elif item.is_file():
|
||||
if target_path.exists():
|
||||
continue
|
||||
shutil.copy2(item, target_path)
|
||||
logger.info(f"已备份插件文件: {item.name}")
|
||||
|
||||
logger.info(f"插件备份完成,备份位置: {backup_dir}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"插件备份失败: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def restore_plugins():
|
||||
"""
|
||||
从备份恢复插件到app/plugins目录,恢复完成后删除备份(仅docker环境)
|
||||
"""
|
||||
|
||||
# 非docker环境不处理
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
|
||||
# 使用绝对路径确保准确性
|
||||
plugins_dir = settings.ROOT_PATH / "app" / "plugins"
|
||||
backup_dir = settings.CONFIG_PATH / "plugins_backup"
|
||||
|
||||
if not backup_dir.exists():
|
||||
logger.info("插件备份目录不存在,跳过恢复")
|
||||
return
|
||||
|
||||
# 系统被重置才恢复插件
|
||||
if SystemHelper().is_system_reset():
|
||||
|
||||
# 确保插件目录存在
|
||||
plugins_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 遍历备份目录,恢复所有内容
|
||||
restored_count = 0
|
||||
for item in backup_dir.iterdir():
|
||||
target_path = plugins_dir / item.name
|
||||
try:
|
||||
# 如果是目录,且目录内有内容
|
||||
if item.is_dir() and any(item.iterdir()):
|
||||
if target_path.exists():
|
||||
shutil.rmtree(target_path)
|
||||
shutil.copytree(item, target_path)
|
||||
logger.info(f"已恢复插件目录: {item.name}")
|
||||
restored_count += 1
|
||||
# 如果是文件
|
||||
elif item.is_file():
|
||||
shutil.copy2(item, target_path)
|
||||
logger.info(f"已恢复插件文件: {item.name}")
|
||||
restored_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"恢复插件 {item.name} 时发生错误: {str(e)}")
|
||||
continue
|
||||
|
||||
logger.info(f"插件恢复完成,共恢复 {restored_count} 个项目")
|
||||
|
||||
# 安装缺少的依赖
|
||||
PluginManager.install_plugin_missing_dependencies()
|
||||
|
||||
# 删除备份目录
|
||||
try:
|
||||
shutil.rmtree(backup_dir)
|
||||
logger.info(f"已删除插件备份目录: {backup_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"删除备份目录失败: {str(e)}")
|
||||
|
||||
def __get_version_message(self) -> str:
|
||||
"""
|
||||
@@ -65,7 +176,7 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
||||
title += f"当前前端版本:{front_local_version},远程版本:{front_release_version}"
|
||||
return title
|
||||
|
||||
def version(self, channel: MessageChannel, userid: Union[int, str], source: str = None):
|
||||
def version(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None):
|
||||
"""
|
||||
查看当前版本、远程版本
|
||||
"""
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
import random
|
||||
from typing import Optional, List
|
||||
|
||||
from cachetools import cached, TTLCache
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import MediaInfo
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
class TmdbChain(ChainBase):
|
||||
"""
|
||||
TheMovieDB处理链,单例运行
|
||||
"""
|
||||
|
||||
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str,
|
||||
with_original_language: str, page: int = 1) -> Optional[List[MediaInfo]]:
|
||||
def tmdb_discover(self, mtype: MediaType,
|
||||
sort_by: str,
|
||||
with_genres: str,
|
||||
with_original_language: str,
|
||||
with_keywords: str,
|
||||
with_watch_providers: str,
|
||||
vote_average: float,
|
||||
vote_count: int,
|
||||
release_date: str,
|
||||
page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
:param mtype: 媒体类型
|
||||
:param sort_by: 排序方式
|
||||
:param with_genres: 类型
|
||||
:param with_original_language: 语言
|
||||
:param with_keywords: 关键字
|
||||
:param with_watch_providers: 提供商
|
||||
:param vote_average: 评分
|
||||
:param vote_count: 评分人数
|
||||
:param release_date: 上映日期
|
||||
:param page: 页码
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
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_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
|
||||
def tmdb_trending(self, page: int = 1) -> Optional[List[MediaInfo]]:
|
||||
def tmdb_trending(self, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
TMDB流行趋势
|
||||
:param page: 第几页
|
||||
@@ -38,6 +54,13 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("tmdb_trending", page=page)
|
||||
|
||||
def tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据合集ID查询集合
|
||||
:param collection_id: 合集ID
|
||||
"""
|
||||
return self.run_module("tmdb_collection", collection_id=collection_id)
|
||||
|
||||
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
||||
"""
|
||||
根据TMDBID查询themoviedb所有季信息
|
||||
@@ -45,13 +68,21 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("tmdb_seasons", tmdbid=tmdbid)
|
||||
|
||||
def tmdb_episodes(self, tmdbid: int, season: int) -> List[schemas.TmdbEpisode]:
|
||||
def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
|
||||
"""
|
||||
根据剧集组ID查询themoviedb所有季集信息
|
||||
:param group_id: 剧集组ID
|
||||
"""
|
||||
return self.run_module("tmdb_group_seasons", group_id=group_id)
|
||||
|
||||
def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]:
|
||||
"""
|
||||
根据TMDBID查询某季的所有信信息
|
||||
:param tmdbid: TMDBID
|
||||
:param season: 季
|
||||
:param episode_group: 剧集组
|
||||
"""
|
||||
return self.run_module("tmdb_episodes", tmdbid=tmdbid, season=season)
|
||||
return self.run_module("tmdb_episodes", tmdbid=tmdbid, season=season, episode_group=episode_group)
|
||||
|
||||
def movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
@@ -81,7 +112,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid)
|
||||
|
||||
def movie_credits(self, tmdbid: int, page: int = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||
def movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电影演职人员
|
||||
:param tmdbid: TMDBID
|
||||
@@ -89,7 +120,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page)
|
||||
|
||||
def tv_credits(self, tmdbid: int, page: int = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||
def tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职人员
|
||||
:param tmdbid: TMDBID
|
||||
@@ -104,7 +135,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("tmdb_person_detail", person_id=person_id)
|
||||
|
||||
def person_credits(self, person_id: int, page: int = 1) -> Optional[List[MediaInfo]]:
|
||||
def person_credits(self, person_id: int, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
:param person_id: 人物ID
|
||||
@@ -112,7 +143,6 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
||||
def get_random_wallpager(self) -> Optional[str]:
|
||||
"""
|
||||
获取随机壁纸,缓存1个小时
|
||||
@@ -126,8 +156,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
return info.backdrop_path
|
||||
return None
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
||||
def get_trending_wallpapers(self, num: int = 10) -> List[str]:
|
||||
def get_trending_wallpapers(self, num: Optional[int] = 10) -> List[str]:
|
||||
"""
|
||||
获取所有流行壁纸
|
||||
"""
|
||||
@@ -135,3 +164,159 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
if infos:
|
||||
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
|
||||
return []
|
||||
|
||||
async def async_tmdb_discover(self, mtype: MediaType,
|
||||
sort_by: str,
|
||||
with_genres: str,
|
||||
with_original_language: str,
|
||||
with_keywords: str,
|
||||
with_watch_providers: str,
|
||||
vote_average: float,
|
||||
vote_count: int,
|
||||
release_date: str,
|
||||
page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现TMDB电影、剧集(异步版本)
|
||||
:param mtype: 媒体类型
|
||||
:param sort_by: 排序方式
|
||||
:param with_genres: 类型
|
||||
:param with_original_language: 语言
|
||||
:param with_keywords: 关键字
|
||||
:param with_watch_providers: 提供商
|
||||
:param vote_average: 评分
|
||||
:param vote_count: 评分人数
|
||||
:param release_date: 上映日期
|
||||
:param page: 页码
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_discover", mtype=mtype,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
|
||||
async def async_tmdb_trending(self, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
TMDB流行趋势(异步版本)
|
||||
:param page: 第几页
|
||||
:return: TMDB信息列表
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_trending", page=page)
|
||||
|
||||
async def async_tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据合集ID查询集合(异步版本)
|
||||
:param collection_id: 合集ID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_collection", collection_id=collection_id)
|
||||
|
||||
async def async_tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
||||
"""
|
||||
根据TMDBID查询themoviedb所有季信息(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_seasons", tmdbid=tmdbid)
|
||||
|
||||
async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
|
||||
"""
|
||||
根据剧集组ID查询themoviedb所有季集信息(异步版本)
|
||||
:param group_id: 剧集组ID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_group_seasons", group_id=group_id)
|
||||
|
||||
async def async_tmdb_episodes(self, tmdbid: int, season: int,
|
||||
episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]:
|
||||
"""
|
||||
根据TMDBID查询某季的所有信信息(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
:param season: 季
|
||||
:param episode_group: 剧集组
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_episodes", tmdbid=tmdbid, season=season,
|
||||
episode_group=episode_group)
|
||||
|
||||
async def async_movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询类似电影(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_movie_similar", tmdbid=tmdbid)
|
||||
|
||||
async def async_tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询类似电视剧(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_tv_similar", tmdbid=tmdbid)
|
||||
|
||||
async def async_movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询推荐电影(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_movie_recommend", tmdbid=tmdbid)
|
||||
|
||||
async def async_tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询推荐电视剧(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_tv_recommend", tmdbid=tmdbid)
|
||||
|
||||
async def async_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电影演职人员(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
:param page: 页码
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_movie_credits", tmdbid=tmdbid, page=page)
|
||||
|
||||
async def async_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职人员(异步版本)
|
||||
:param tmdbid: TMDBID
|
||||
:param page: 页码
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_tv_credits", tmdbid=tmdbid, page=page)
|
||||
|
||||
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
根据TMDBID查询演职员详情(异步版本)
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_person_detail", person_id=person_id)
|
||||
|
||||
async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据人物ID查询人物参演作品(异步版本)
|
||||
:param person_id: 人物ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return await self.async_run_module("async_tmdb_person_credits", person_id=person_id, page=page)
|
||||
|
||||
async def async_get_random_wallpager(self) -> Optional[str]:
|
||||
"""
|
||||
获取随机壁纸(异步版本),缓存1个小时
|
||||
"""
|
||||
infos = await self.async_tmdb_trending()
|
||||
if infos:
|
||||
# 随机一个电影
|
||||
while True:
|
||||
info = random.choice(infos)
|
||||
if info and info.backdrop_path:
|
||||
return info.backdrop_path
|
||||
return None
|
||||
|
||||
async def async_get_trending_wallpapers(self, num: Optional[int] = 10) -> List[str]:
|
||||
"""
|
||||
获取所有流行壁纸(异步版本)
|
||||
"""
|
||||
infos = await self.async_tmdb_trending()
|
||||
if infos:
|
||||
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
|
||||
return []
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import re
|
||||
import traceback
|
||||
from typing import Dict, List, Union
|
||||
|
||||
from cachetools import cached, TTLCache
|
||||
from typing import Dict, List, Union, Optional
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
@@ -12,16 +10,15 @@ from app.core.metainfo import MetaInfo
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
class TorrentsChain(ChainBase):
|
||||
"""
|
||||
站点首页或RSS种子处理链,服务于订阅、刷流等
|
||||
"""
|
||||
@@ -29,14 +26,14 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
_spider_file = "__torrents_cache__"
|
||||
_rss_file = "__rss_cache__"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.siteshelper = SitesHelper()
|
||||
self.siteoper = SiteOper()
|
||||
self.rsshelper = RssHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.mediachain = MediaChain()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
@property
|
||||
def cache_file(self) -> str:
|
||||
"""
|
||||
返回缓存文件列表
|
||||
"""
|
||||
if settings.SUBSCRIBE_MODE == 'spider':
|
||||
return self._spider_file
|
||||
return self._rss_file
|
||||
|
||||
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
@@ -48,7 +45,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"种子刷新完成!", userid=userid))
|
||||
|
||||
def get_torrents(self, stype: str = None) -> Dict[str, List[Context]]:
|
||||
def get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:
|
||||
"""
|
||||
获取当前缓存的种子
|
||||
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
|
||||
@@ -59,9 +56,34 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
# 读取缓存
|
||||
if stype == 'spider':
|
||||
return self.load_cache(self._spider_file) or {}
|
||||
torrents_cache = self.load_cache(self._spider_file) or {}
|
||||
else:
|
||||
return self.load_cache(self._rss_file) or {}
|
||||
torrents_cache = self.load_cache(self._rss_file) or {}
|
||||
|
||||
# 兼容性处理:为旧版本的Context对象添加失败次数字段
|
||||
self._ensure_context_compatibility(torrents_cache)
|
||||
|
||||
return torrents_cache
|
||||
|
||||
async def async_get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:
|
||||
"""
|
||||
异步获取当前缓存的种子
|
||||
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
|
||||
"""
|
||||
|
||||
if not stype:
|
||||
stype = settings.SUBSCRIBE_MODE
|
||||
|
||||
# 异步读取缓存
|
||||
if stype == 'spider':
|
||||
torrents_cache = await self.async_load_cache(self._spider_file) or {}
|
||||
else:
|
||||
torrents_cache = await self.async_load_cache(self._rss_file) or {}
|
||||
|
||||
# 兼容性处理:为旧版本的Context对象添加失败次数字段
|
||||
self._ensure_context_compatibility(torrents_cache)
|
||||
|
||||
return torrents_cache
|
||||
|
||||
def clear_torrents(self):
|
||||
"""
|
||||
@@ -72,35 +94,63 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
self.remove_cache(self._rss_file)
|
||||
logger.info(f'种子缓存数据清理完成')
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=595))
|
||||
def browse(self, domain: str) -> List[TorrentInfo]:
|
||||
async def async_clear_torrents(self):
|
||||
"""
|
||||
浏览站点首页内容,返回种子清单,TTL缓存10分钟
|
||||
异步清理种子缓存数据
|
||||
"""
|
||||
logger.info(f'开始异步清理种子缓存数据 ...')
|
||||
await self.async_remove_cache(self._spider_file)
|
||||
await self.async_remove_cache(self._rss_file)
|
||||
logger.info(f'异步种子缓存数据清理完成')
|
||||
|
||||
def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
|
||||
page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
浏览站点首页内容,返回种子清单,TTL缓存5分钟
|
||||
:param domain: 站点域名
|
||||
:param keyword: 搜索标题
|
||||
:param cat: 搜索分类
|
||||
:param page: 页码
|
||||
"""
|
||||
logger.info(f'开始获取站点 {domain} 最新种子 ...')
|
||||
site = self.siteshelper.get_indexer(domain)
|
||||
site = SitesHelper().get_indexer(domain)
|
||||
if not site:
|
||||
logger.error(f'站点 {domain} 不存在!')
|
||||
return []
|
||||
return self.refresh_torrents(site=site)
|
||||
return self.refresh_torrents(site=site, keyword=keyword, cat=cat, page=page)
|
||||
|
||||
async def async_browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
|
||||
page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
异步浏览站点首页内容,返回种子清单,TTL缓存5分钟
|
||||
:param domain: 站点域名
|
||||
:param keyword: 搜索标题
|
||||
:param cat: 搜索分类
|
||||
:param page: 页码
|
||||
"""
|
||||
logger.info(f'开始获取站点 {domain} 最新种子 ...')
|
||||
site = await SitesHelper().async_get_indexer(domain)
|
||||
if not site:
|
||||
logger.error(f'站点 {domain} 不存在!')
|
||||
return []
|
||||
return await self.async_refresh_torrents(site=site, keyword=keyword, cat=cat, page=page)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=295))
|
||||
def rss(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
获取站点RSS内容,返回种子清单,TTL缓存5分钟
|
||||
获取站点RSS内容,返回种子清单,TTL缓存3分钟
|
||||
:param domain: 站点域名
|
||||
"""
|
||||
logger.info(f'开始获取站点 {domain} RSS ...')
|
||||
site = self.siteshelper.get_indexer(domain)
|
||||
site = SitesHelper().get_indexer(domain)
|
||||
if not site:
|
||||
logger.error(f'站点 {domain} 不存在!')
|
||||
return []
|
||||
if not site.get("rss"):
|
||||
logger.error(f'站点 {domain} 未配置RSS地址!')
|
||||
return []
|
||||
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False,
|
||||
timeout=int(site.get("timeout") or 30))
|
||||
# 解析RSS
|
||||
rss_items = RssHelper().parse(site.get("rss"), True if site.get("proxy") else False,
|
||||
timeout=int(site.get("timeout") or 30))
|
||||
if rss_items is None:
|
||||
# rss过期,尝试保留原配置生成新的rss
|
||||
self.__renew_rss_url(domain=domain, site=site)
|
||||
@@ -110,40 +160,53 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
return []
|
||||
# 组装种子
|
||||
ret_torrents: List[TorrentInfo] = []
|
||||
for item in rss_items:
|
||||
if not item.get("title"):
|
||||
continue
|
||||
torrentinfo = TorrentInfo(
|
||||
site=site.get("id"),
|
||||
site_name=site.get("name"),
|
||||
site_cookie=site.get("cookie"),
|
||||
site_ua=site.get("ua") or settings.USER_AGENT,
|
||||
site_proxy=site.get("proxy"),
|
||||
site_order=site.get("pri"),
|
||||
site_downloader=site.get("downloader"),
|
||||
title=item.get("title"),
|
||||
enclosure=item.get("enclosure"),
|
||||
page_url=item.get("link"),
|
||||
size=item.get("size"),
|
||||
pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None,
|
||||
)
|
||||
ret_torrents.append(torrentinfo)
|
||||
|
||||
try:
|
||||
for item in rss_items:
|
||||
if not item.get("title"):
|
||||
continue
|
||||
torrentinfo = TorrentInfo(
|
||||
site=site.get("id"),
|
||||
site_name=site.get("name"),
|
||||
site_cookie=site.get("cookie"),
|
||||
site_ua=site.get("ua") or settings.USER_AGENT,
|
||||
site_proxy=site.get("proxy"),
|
||||
site_order=site.get("pri"),
|
||||
site_downloader=site.get("downloader"),
|
||||
title=item.get("title"),
|
||||
enclosure=item.get("enclosure"),
|
||||
page_url=item.get("link"),
|
||||
size=item.get("size"),
|
||||
pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None,
|
||||
)
|
||||
ret_torrents.append(torrentinfo)
|
||||
finally:
|
||||
rss_items.clear()
|
||||
del rss_items
|
||||
return ret_torrents
|
||||
|
||||
def refresh(self, stype: str = None, sites: List[int] = None) -> Dict[str, List[Context]]:
|
||||
def refresh(self, stype: Optional[str] = None, sites: List[int] = None) -> Dict[str, List[Context]]:
|
||||
"""
|
||||
刷新站点最新资源,识别并缓存起来
|
||||
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
|
||||
:param sites: 强制指定站点ID列表,为空则读取设置的订阅站点
|
||||
"""
|
||||
|
||||
def __is_no_cache_site(_domain: str) -> bool:
|
||||
"""
|
||||
判断站点是否不需要缓存
|
||||
"""
|
||||
for url_key in settings.NO_CACHE_SITE_KEY.split(','):
|
||||
if url_key in _domain:
|
||||
return True
|
||||
return False
|
||||
|
||||
# 刷新类型
|
||||
if not stype:
|
||||
stype = settings.SUBSCRIBE_MODE
|
||||
|
||||
# 刷新站点
|
||||
if not sites:
|
||||
sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
|
||||
sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
||||
|
||||
# 读取缓存
|
||||
torrents_cache = self.get_torrents()
|
||||
@@ -151,14 +214,12 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# 缓存过滤掉无效种子
|
||||
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)]
|
||||
if not TorrentHelper().is_invalid(_torrent.torrent_info.enclosure)]
|
||||
|
||||
# 所有站点索引
|
||||
indexers = self.siteshelper.get_indexers()
|
||||
# 需要刷新的站点domain
|
||||
domains = []
|
||||
# 遍历站点缓存资源
|
||||
for indexer in indexers:
|
||||
for indexer in SitesHelper().get_indexers():
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
# 未开启的站点不刷新
|
||||
@@ -168,57 +229,75 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
domains.append(domain)
|
||||
if stype == "spider":
|
||||
# 刷新首页种子
|
||||
torrents: List[TorrentInfo] = self.browse(domain=domain)
|
||||
torrents: List[TorrentInfo] = []
|
||||
# 读取第0页和第1页
|
||||
for page in range(2):
|
||||
page_torrents = self.browse(domain=domain, page=page)
|
||||
if page_torrents:
|
||||
torrents.extend(page_torrents)
|
||||
else:
|
||||
# 如果某一页没有数据,说明已经到最后一页,停止获取
|
||||
break
|
||||
else:
|
||||
# 刷新RSS种子
|
||||
torrents: List[TorrentInfo] = self.rss(domain=domain)
|
||||
# 按pubdate降序排列
|
||||
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
|
||||
# 取前N条
|
||||
torrents = torrents[:settings.CACHE_CONF["refresh"]]
|
||||
torrents = torrents[:settings.CONF.refresh]
|
||||
if torrents:
|
||||
# 过滤出没有处理过的种子
|
||||
torrents = [torrent for torrent in torrents
|
||||
if f'{torrent.title}{torrent.description}'
|
||||
not in [f'{t.torrent_info.title}{t.torrent_info.description}'
|
||||
for t in torrents_cache.get(domain) or []]]
|
||||
if __is_no_cache_site(domain):
|
||||
# 不需要缓存的站点,直接处理
|
||||
logger.info(f'{indexer.get("name")} 有 {len(torrents)} 个种子 (不缓存)')
|
||||
torrents_cache[domain] = []
|
||||
else:
|
||||
# 过滤出没有处理过的种子 - 优化:使用集合查找,避免重复创建字符串列表
|
||||
cached_signatures = {f'{t.torrent_info.title}{t.torrent_info.description}'
|
||||
for t in torrents_cache.get(domain) or []}
|
||||
torrents = [torrent for torrent in torrents
|
||||
if f'{torrent.title}{torrent.description}' not in cached_signatures]
|
||||
if torrents:
|
||||
logger.info(f'{indexer.get("name")} 有 {len(torrents)} 个新种子')
|
||||
else:
|
||||
logger.info(f'{indexer.get("name")} 没有新种子')
|
||||
continue
|
||||
for torrent in torrents:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
logger.info(f'处理资源:{torrent.title} ...')
|
||||
# 识别
|
||||
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.mediachain.recognize_by_meta(meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{torrent.title} 未识别到媒体信息')
|
||||
# 存储空的媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
# 清理多余数据
|
||||
mediainfo.clear()
|
||||
# 上下文
|
||||
context = Context(meta_info=meta, media_info=mediainfo, torrent_info=torrent)
|
||||
# 添加到缓存
|
||||
if not torrents_cache.get(domain):
|
||||
torrents_cache[domain] = [context]
|
||||
else:
|
||||
torrents_cache[domain].append(context)
|
||||
# 如果超过了限制条数则移除掉前面的
|
||||
if len(torrents_cache[domain]) > settings.CACHE_CONF["torrents"]:
|
||||
torrents_cache[domain] = torrents_cache[domain][-settings.CACHE_CONF["torrents"]:]
|
||||
# 回收资源
|
||||
del torrents
|
||||
try:
|
||||
for torrent in torrents:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
logger.info(f'处理资源:{torrent.title} ...')
|
||||
# 识别
|
||||
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 = MediaChain().recognize_by_meta(meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{torrent.title} 未识别到媒体信息')
|
||||
# 存储空的媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
# 清理多余数据,减少内存占用
|
||||
mediainfo.clear()
|
||||
# 上下文
|
||||
context = Context(meta_info=meta, media_info=mediainfo, torrent_info=torrent)
|
||||
# 如果未识别到媒体信息,设置初始失败次数为1
|
||||
if not mediainfo or (not mediainfo.tmdb_id and not mediainfo.douban_id):
|
||||
context.media_recognize_fail_count = 1
|
||||
# 添加到缓存
|
||||
if not torrents_cache.get(domain):
|
||||
torrents_cache[domain] = [context]
|
||||
else:
|
||||
torrents_cache[domain].append(context)
|
||||
# 如果超过了限制条数则移除掉前面的
|
||||
if len(torrents_cache[domain]) > settings.CONF.torrents:
|
||||
torrents_cache[domain] = torrents_cache[domain][-settings.CONF.torrents:]
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
else:
|
||||
logger.info(f'{indexer.get("name")} 没有获取到种子')
|
||||
|
||||
@@ -231,8 +310,24 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# 去除不在站点范围内的缓存种子
|
||||
if sites and torrents_cache:
|
||||
torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains}
|
||||
|
||||
return torrents_cache
|
||||
|
||||
@staticmethod
|
||||
def _ensure_context_compatibility(torrents_cache: Dict[str, List[Context]]):
|
||||
"""
|
||||
确保Context对象的兼容性,为旧版本添加缺失的字段
|
||||
"""
|
||||
for domain, contexts in torrents_cache.items():
|
||||
for context in contexts:
|
||||
# 如果Context对象没有media_recognize_fail_count字段,添加默认值
|
||||
if not hasattr(context, 'media_recognize_fail_count'):
|
||||
context.media_recognize_fail_count = 0
|
||||
# 如果媒体信息未识别,设置初始失败次数
|
||||
if (not context.media_info or
|
||||
(not context.media_info.tmdb_id and not context.media_info.douban_id)):
|
||||
context.media_recognize_fail_count = 1
|
||||
|
||||
def __renew_rss_url(self, domain: str, site: dict):
|
||||
"""
|
||||
保留原配置生成新的rss地址
|
||||
@@ -241,11 +336,12 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# RSS链接过期
|
||||
logger.error(f"站点 {domain} RSS链接已过期,正在尝试自动获取!")
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(
|
||||
rss_url, errmsg = RssHelper().get_rss_link(
|
||||
url=site.get("url"),
|
||||
cookie=site.get("cookie"),
|
||||
ua=site.get("ua") or settings.USER_AGENT,
|
||||
proxy=True if site.get("proxy") else False
|
||||
proxy=True if site.get("proxy") else False,
|
||||
timeout=site.get("timeout"),
|
||||
)
|
||||
if rss_url:
|
||||
# 获取新的日期的passkey
|
||||
@@ -255,7 +351,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# 获取过期rss除去passkey部分
|
||||
new_rss = re.sub(r'&passkey=([a-zA-Z0-9]+)', f'&passkey={new_passkey}', site.get("rss"))
|
||||
logger.info(f"更新站点 {domain} RSS地址 ...")
|
||||
self.siteoper.update_rss(domain=domain, rss=new_rss)
|
||||
SiteOper().update_rss(domain=domain, rss=new_rss)
|
||||
else:
|
||||
# 发送消息
|
||||
self.post_message(
|
||||
|
||||
917
app/chain/transfer.py
Normal file → Executable file
917
app/chain/transfer.py
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
13
app/chain/tvdb.py
Normal file
13
app/chain/tvdb.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from typing import List
|
||||
|
||||
from app.chain import ChainBase
|
||||
|
||||
|
||||
class TvdbChain(ChainBase):
|
||||
"""
|
||||
Tvdb处理链,单例运行
|
||||
"""
|
||||
|
||||
def get_tvdbid_by_name(self, title: str) -> List[int]:
|
||||
tvdb_info_list = self.run_module("search_tvdb", title=title)
|
||||
return [int(item["tvdb_id"]) for item in tvdb_info_list]
|
||||
@@ -10,27 +10,22 @@ from app.log import logger
|
||||
from app.schemas import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas.types import ChainEventType
|
||||
from app.utils.otp import OtpUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
PASSWORD_INVALID_CREDENTIALS_MESSAGE = "用户名或密码或二次校验码不正确"
|
||||
|
||||
|
||||
class UserChain(ChainBase, metaclass=Singleton):
|
||||
class UserChain(ChainBase):
|
||||
"""
|
||||
用户链,处理多种认证协议
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.user_oper = UserOper()
|
||||
|
||||
def user_authenticate(
|
||||
self,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
mfa_code: Optional[str] = None,
|
||||
code: Optional[str] = None,
|
||||
grant_type: str = "password"
|
||||
grant_type: Optional[str] = "password"
|
||||
) -> Union[Tuple[bool, Optional[str]], Tuple[bool, Optional[User]]]:
|
||||
"""
|
||||
认证用户,根据不同的 grant_type 处理不同的认证流程
|
||||
@@ -90,7 +85,8 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
logger.debug(f"辅助认证未启用,认证类型 {grant_type} 未实现")
|
||||
return False, "不支持的认证类型"
|
||||
|
||||
def password_authenticate(self, credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:
|
||||
@staticmethod
|
||||
def password_authenticate(credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:
|
||||
"""
|
||||
密码认证
|
||||
|
||||
@@ -103,7 +99,7 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
logger.info("密码认证失败,认证类型不匹配")
|
||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||
|
||||
user = self.user_oper.get_by_name(name=credentials.username)
|
||||
user = UserOper().get_by_name(name=credentials.username)
|
||||
if not user:
|
||||
logger.info(f"密码认证失败,用户 {credentials.username} 不存在")
|
||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||
@@ -131,8 +127,9 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
return False, "认证凭证无效"
|
||||
|
||||
# 检查是否因为用户被禁用
|
||||
useroper = UserOper()
|
||||
if credentials.username:
|
||||
user = self.user_oper.get_by_name(name=credentials.username)
|
||||
user = useroper.get_by_name(name=credentials.username)
|
||||
if user and not user.is_active:
|
||||
logger.info(f"用户 {user.name} 已被禁用,跳过后续身份校验")
|
||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||
@@ -156,7 +153,7 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
success = self._process_auth_success(username=credentials.username, credentials=credentials)
|
||||
if success:
|
||||
logger.info(f"用户 {credentials.username} 辅助认证通过")
|
||||
return True, self.user_oper.get_by_name(credentials.username)
|
||||
return True, useroper.get_by_name(credentials.username)
|
||||
else:
|
||||
logger.warning(f"用户 {credentials.username} 辅助认证未通过")
|
||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||
@@ -213,7 +210,8 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
return False
|
||||
|
||||
# 检查用户是否存在,如果不存在且当前为密码认证时则创建新用户
|
||||
user = self.user_oper.get_by_name(name=username)
|
||||
useroper = UserOper()
|
||||
user = useroper.get_by_name(name=username)
|
||||
if user:
|
||||
# 如果用户存在,但是已经被禁用,则直接响应
|
||||
if not user.is_active:
|
||||
@@ -226,8 +224,8 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
return True
|
||||
else:
|
||||
if credentials.grant_type == "password":
|
||||
self.user_oper.add(name=username, is_active=True, is_superuser=False,
|
||||
hashed_password=get_password_hash(secrets.token_urlsafe(16)))
|
||||
useroper.add(name=username, is_active=True, is_superuser=False,
|
||||
hashed_password=get_password_hash(secrets.token_urlsafe(16)))
|
||||
logger.info(f"用户 {username} 不存在,已通过 {credentials.grant_type} 认证并已创建普通用户")
|
||||
return True
|
||||
else:
|
||||
|
||||
@@ -2,10 +2,9 @@ from typing import Any
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class WebhookChain(ChainBase, metaclass=Singleton):
|
||||
class WebhookChain(ChainBase):
|
||||
"""
|
||||
Webhook处理链
|
||||
"""
|
||||
|
||||
275
app/chain/workflow.py
Normal file
275
app/chain/workflow.py
Normal file
@@ -0,0 +1,275 @@
|
||||
import base64
|
||||
import pickle
|
||||
import threading
|
||||
from collections import defaultdict, deque
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from time import sleep
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
from pydantic.fields import Callable
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import global_vars
|
||||
from app.core.event import Event, eventmanager
|
||||
from app.core.workflow import WorkFlowManager
|
||||
from app.db.models import Workflow
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
from app.log import logger
|
||||
from app.schemas import ActionContext, ActionFlow, Action, ActionExecution
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
class WorkflowExecutor:
|
||||
"""
|
||||
工作流执行器
|
||||
"""
|
||||
|
||||
def __init__(self, workflow: Workflow, step_callback: Callable = None):
|
||||
"""
|
||||
初始化工作流执行器
|
||||
:param workflow: 工作流对象
|
||||
:param step_callback: 步骤回调函数
|
||||
"""
|
||||
# 工作流数据
|
||||
self.workflow = workflow
|
||||
self.step_callback = step_callback
|
||||
self.actions = {action['id']: Action(**action) for action in workflow.actions}
|
||||
self.flows = [ActionFlow(**flow) for flow in workflow.flows]
|
||||
self.total_actions = len(self.actions)
|
||||
self.finished_actions = 0
|
||||
|
||||
self.success = True
|
||||
self.errmsg = ""
|
||||
|
||||
# 工作流管理器
|
||||
self.workflowmanager = WorkFlowManager()
|
||||
# 线程安全队列
|
||||
self.queue = deque()
|
||||
# 锁用于保证线程安全
|
||||
self.lock = threading.Lock()
|
||||
# 线程池
|
||||
self.executor = ThreadPoolExecutor()
|
||||
# 跟踪运行中的任务数
|
||||
self.running_tasks = 0
|
||||
|
||||
# 构建邻接表、入度表
|
||||
self.adjacency = defaultdict(list)
|
||||
self.indegree = defaultdict(int)
|
||||
for flow in self.flows:
|
||||
source = flow.source
|
||||
target = flow.target
|
||||
self.adjacency[source].append(target)
|
||||
self.indegree[target] += 1
|
||||
|
||||
# 初始化所有节点的入度(确保未被引用的节点入度为0)
|
||||
for action_id in self.actions:
|
||||
if action_id not in self.indegree:
|
||||
self.indegree[action_id] = 0
|
||||
|
||||
# 初始上下文
|
||||
if workflow.current_action and workflow.context:
|
||||
logger.info(f"工作流已执行动作:{workflow.current_action}")
|
||||
# Base64解码
|
||||
decoded_data = base64.b64decode(workflow.context["content"])
|
||||
# 反序列化数据
|
||||
self.context = pickle.loads(decoded_data)
|
||||
else:
|
||||
self.context = ActionContext()
|
||||
|
||||
# 恢复工作流
|
||||
global_vars.workflow_resume(self.workflow.id)
|
||||
# 初始化队列,添加入度为0的节点
|
||||
for action_id in self.actions:
|
||||
if self.indegree[action_id] == 0:
|
||||
self.queue.append(action_id)
|
||||
|
||||
def execute(self):
|
||||
"""
|
||||
执行工作流
|
||||
"""
|
||||
while True:
|
||||
with self.lock:
|
||||
# 退出条件:队列为空且无运行任务
|
||||
if not self.queue and self.running_tasks == 0:
|
||||
break
|
||||
# 退出条件:出现了错误
|
||||
if not self.success:
|
||||
break
|
||||
if not self.queue:
|
||||
sleep(0.1)
|
||||
continue
|
||||
# 取出队首节点
|
||||
node_id = self.queue.popleft()
|
||||
# 标记任务开始
|
||||
self.running_tasks += 1
|
||||
|
||||
# 已停机
|
||||
if global_vars.is_workflow_stopped(self.workflow.id):
|
||||
global_vars.workflow_resume(self.workflow.id)
|
||||
break
|
||||
|
||||
# 已执行的跳过
|
||||
if (self.workflow.current_action
|
||||
and node_id in self.workflow.current_action.split(',')):
|
||||
continue
|
||||
|
||||
# 提交任务到线程池
|
||||
future = self.executor.submit(
|
||||
self.execute_node,
|
||||
self.workflow.id,
|
||||
node_id,
|
||||
self.context
|
||||
)
|
||||
future.add_done_callback(self.on_node_complete)
|
||||
|
||||
def execute_node(self, workflow_id: int, node_id: int,
|
||||
context: ActionContext) -> Tuple[Action, bool, str, ActionContext]:
|
||||
"""
|
||||
执行单个节点操作,返回修改后的上下文和节点ID
|
||||
"""
|
||||
action = self.actions[node_id]
|
||||
state, message, result_ctx = self.workflowmanager.excute(workflow_id, action, context=context)
|
||||
return action, state, message, result_ctx
|
||||
|
||||
def on_node_complete(self, future):
|
||||
"""
|
||||
节点完成回调:更新上下文、处理后继节点
|
||||
"""
|
||||
action, state, message, result_ctx = future.result()
|
||||
|
||||
try:
|
||||
self.finished_actions += 1
|
||||
# 更新当前进度
|
||||
self.context.progress = round(self.finished_actions / self.total_actions) * 100
|
||||
|
||||
# 补充执行历史
|
||||
self.context.execute_history.append(
|
||||
ActionExecution(
|
||||
action=action.name,
|
||||
result=state,
|
||||
message=message
|
||||
)
|
||||
)
|
||||
|
||||
# 节点执行失败
|
||||
if not state:
|
||||
self.success = False
|
||||
self.errmsg = f"{action.name} 失败"
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
# 更新主上下文
|
||||
self.merge_context(result_ctx)
|
||||
# 回调
|
||||
if self.step_callback:
|
||||
self.step_callback(action, self.context)
|
||||
|
||||
# 处理后继节点
|
||||
successors = self.adjacency.get(action.id, [])
|
||||
for succ_id in successors:
|
||||
with self.lock:
|
||||
self.indegree[succ_id] -= 1
|
||||
if self.indegree[succ_id] == 0:
|
||||
self.queue.append(succ_id)
|
||||
finally:
|
||||
# 标记任务完成
|
||||
with self.lock:
|
||||
self.running_tasks -= 1
|
||||
|
||||
def merge_context(self, context: ActionContext):
|
||||
"""
|
||||
合并上下文
|
||||
"""
|
||||
for key, value in context.dict().items():
|
||||
if not getattr(self.context, key, None):
|
||||
setattr(self.context, key, value)
|
||||
|
||||
|
||||
class WorkflowChain(ChainBase):
|
||||
"""
|
||||
工作流链
|
||||
"""
|
||||
|
||||
@eventmanager.register(EventType.WorkflowExecute)
|
||||
def event_process(self, event: Event):
|
||||
"""
|
||||
事件触发工作流执行
|
||||
"""
|
||||
workflow_id = event.event_data.get('workflow_id')
|
||||
if not workflow_id:
|
||||
return
|
||||
self.process(workflow_id, from_begin=False)
|
||||
|
||||
@staticmethod
|
||||
def process(workflow_id: int, from_begin: Optional[bool] = True) -> Tuple[bool, str]:
|
||||
"""
|
||||
处理工作流
|
||||
:param workflow_id: 工作流ID
|
||||
:param from_begin: 是否从头开始,默认为True
|
||||
"""
|
||||
workflowoper = WorkflowOper()
|
||||
|
||||
def save_step(action: Action, context: ActionContext):
|
||||
"""
|
||||
保存上下文到数据库
|
||||
"""
|
||||
# 序列化数据
|
||||
serialized_data = pickle.dumps(context)
|
||||
# 使用Base64编码字节流
|
||||
encoded_data = base64.b64encode(serialized_data).decode('utf-8')
|
||||
workflowoper.step(workflow_id, action_id=action.id, context={
|
||||
"content": encoded_data
|
||||
})
|
||||
|
||||
# 重置工作流
|
||||
if from_begin:
|
||||
workflowoper.reset(workflow_id)
|
||||
|
||||
# 查询工作流数据
|
||||
workflow = workflowoper.get(workflow_id)
|
||||
if not workflow:
|
||||
logger.warn(f"工作流 {workflow_id} 不存在")
|
||||
return False, "工作流不存在"
|
||||
if not workflow.actions:
|
||||
logger.warn(f"工作流 {workflow.name} 无动作")
|
||||
return False, "工作流无动作"
|
||||
if not workflow.flows:
|
||||
logger.warn(f"工作流 {workflow.name} 无流程")
|
||||
return False, "工作流无流程"
|
||||
|
||||
logger.info(f"开始执行工作流 {workflow.name},共 {len(workflow.actions)} 个动作 ...")
|
||||
workflowoper.start(workflow_id)
|
||||
|
||||
# 执行工作流
|
||||
executor = WorkflowExecutor(workflow, step_callback=save_step)
|
||||
executor.execute()
|
||||
|
||||
if not executor.success:
|
||||
logger.info(f"工作流 {workflow.name} 执行失败:{executor.errmsg}")
|
||||
workflowoper.fail(workflow_id, result=executor.errmsg)
|
||||
return False, executor.errmsg
|
||||
else:
|
||||
logger.info(f"工作流 {workflow.name} 执行完成")
|
||||
workflowoper.success(workflow_id)
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def get_workflows() -> List[Workflow]:
|
||||
"""
|
||||
获取工作流列表
|
||||
"""
|
||||
return WorkflowOper().list_enabled()
|
||||
|
||||
@staticmethod
|
||||
def get_timer_workflows() -> List[Workflow]:
|
||||
"""
|
||||
获取定时触发的工作流列表
|
||||
"""
|
||||
return WorkflowOper().get_timer_triggered_workflows()
|
||||
|
||||
@staticmethod
|
||||
def get_event_workflows() -> List[Workflow]:
|
||||
"""
|
||||
获取事件触发的工作流列表
|
||||
"""
|
||||
return WorkflowOper().get_event_triggered_workflows()
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Any, Union, Dict, Optional
|
||||
@@ -8,7 +9,6 @@ from app.chain.site import SiteChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.system import SystemChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import Event as ManagerEvent, eventmanager, Event
|
||||
from app.core.plugin import PluginManager
|
||||
from app.helper.message import MessageHelper
|
||||
@@ -161,10 +161,6 @@ class Command(metaclass=Singleton):
|
||||
"""
|
||||
初始化菜单命令
|
||||
"""
|
||||
if settings.DEV:
|
||||
logger.debug("Development mode active. Skipping command initialization.")
|
||||
return
|
||||
|
||||
# 使用线程池提交后台任务,避免引起阻塞
|
||||
ThreadHelper().submit(self.__init_commands_background, pid)
|
||||
|
||||
@@ -229,6 +225,9 @@ class Command(metaclass=Singleton):
|
||||
添加命令集合
|
||||
"""
|
||||
for cmd, command in source.items():
|
||||
if not command.get("show", True):
|
||||
continue
|
||||
|
||||
command_data = {
|
||||
"type": command_type,
|
||||
"description": command.get("description"),
|
||||
@@ -265,6 +264,7 @@ class Command(metaclass=Singleton):
|
||||
"func": self.send_plugin_event,
|
||||
"description": command.get("desc"),
|
||||
"category": command.get("category"),
|
||||
"show": command.get("show", True),
|
||||
"data": {
|
||||
"etype": command.get("event"),
|
||||
"data": command.get("data")
|
||||
@@ -272,8 +272,8 @@ class Command(metaclass=Singleton):
|
||||
}
|
||||
return plugin_commands
|
||||
|
||||
def __run_command(self, command: Dict[str, any], data_str: str = "",
|
||||
channel: MessageChannel = None, source: str = None, userid: Union[str, int] = None):
|
||||
def __run_command(self, command: Dict[str, any], data_str: Optional[str] = "",
|
||||
channel: MessageChannel = None, source: Optional[str] = None, userid: Union[str, int] = None):
|
||||
"""
|
||||
运行定时服务
|
||||
"""
|
||||
@@ -303,7 +303,7 @@ class Command(metaclass=Singleton):
|
||||
)
|
||||
else:
|
||||
# 命令
|
||||
cmd_data = command['data'] if command.get('data') else {}
|
||||
cmd_data = copy.deepcopy(command['data']) if command.get('data') else {}
|
||||
args_num = ObjectUtils.arguments(command['func'])
|
||||
if args_num > 0:
|
||||
if cmd_data:
|
||||
@@ -338,8 +338,9 @@ class Command(metaclass=Singleton):
|
||||
"""
|
||||
return self._commands.get(cmd, {})
|
||||
|
||||
def register(self, cmd: str, func: Any, data: dict = None,
|
||||
desc: str = None, category: str = None) -> None:
|
||||
def register(self, cmd: str, func: Any, data: Optional[dict] = None,
|
||||
desc: Optional[str] = None, category: Optional[str] = None,
|
||||
show: bool = True) -> None:
|
||||
"""
|
||||
注册单个命令
|
||||
"""
|
||||
@@ -348,11 +349,12 @@ class Command(metaclass=Singleton):
|
||||
"func": func,
|
||||
"description": desc,
|
||||
"category": category,
|
||||
"data": data or {}
|
||||
"data": data or {},
|
||||
"show": show
|
||||
}
|
||||
|
||||
def execute(self, cmd: str, data_str: str = "",
|
||||
channel: MessageChannel = None, source: str = None,
|
||||
def execute(self, cmd: str, data_str: Optional[str] = "",
|
||||
channel: MessageChannel = None, source: Optional[str] = None,
|
||||
userid: Union[str, int] = None) -> None:
|
||||
"""
|
||||
执行命令
|
||||
|
||||
1423
app/core/cache.py
Normal file
1423
app/core/cache.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,47 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from dotenv import set_key
|
||||
from pydantic import BaseModel, BaseSettings, validator, Field
|
||||
|
||||
from app.log import logger, log_settings, LogConfigModel
|
||||
from app.schemas import MediaType
|
||||
from app.utils.system import SystemUtils
|
||||
from app.utils.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class SystemConfModel(BaseModel):
|
||||
"""
|
||||
系统关键资源大小配置
|
||||
"""
|
||||
# 缓存种子数量
|
||||
torrents: int = 0
|
||||
# 订阅刷新处理数量
|
||||
refresh: int = 0
|
||||
# TMDB请求缓存数量
|
||||
tmdb: int = 0
|
||||
# 豆瓣请求缓存数量
|
||||
douban: int = 0
|
||||
# Bangumi请求缓存数量
|
||||
bangumi: int = 0
|
||||
# Fanart请求缓存数量
|
||||
fanart: int = 0
|
||||
# 元数据缓存过期时间(秒)
|
||||
meta: int = 0
|
||||
# 调度器数量
|
||||
scheduler: int = 0
|
||||
# 线程池大小
|
||||
threadpool: int = 0
|
||||
|
||||
|
||||
class ConfigModel(BaseModel):
|
||||
@@ -23,14 +52,31 @@ class ConfigModel(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore" # 忽略未定义的配置项
|
||||
|
||||
# ==================== 基础应用配置 ====================
|
||||
# 项目名称
|
||||
PROJECT_NAME = "MoviePilot"
|
||||
PROJECT_NAME: str = "MoviePilot"
|
||||
# 域名 格式;https://movie-pilot.org
|
||||
APP_DOMAIN: str = ""
|
||||
# API路径
|
||||
API_V1_STR: str = "/api/v1"
|
||||
# 前端资源路径
|
||||
FRONTEND_PATH: str = "/public"
|
||||
# 时区
|
||||
TZ: str = "Asia/Shanghai"
|
||||
# API监听地址
|
||||
HOST: str = "0.0.0.0"
|
||||
# API监听端口
|
||||
PORT: int = 3001
|
||||
# 前端监听端口
|
||||
NGINX_PORT: int = 3000
|
||||
# 配置文件目录
|
||||
CONFIG_DIR: Optional[str] = None
|
||||
# 是否调试模式
|
||||
DEBUG: bool = False
|
||||
# 是否开发模式
|
||||
DEV: bool = False
|
||||
|
||||
# ==================== 安全认证配置 ====================
|
||||
# 密钥
|
||||
SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||
# RESOURCE密钥
|
||||
@@ -41,78 +87,72 @@ class ConfigModel(BaseModel):
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
||||
# RESOURCE_TOKEN过期时间
|
||||
RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS: int = 60 * 30
|
||||
# 时区
|
||||
TZ: str = "Asia/Shanghai"
|
||||
# API监听地址
|
||||
HOST: str = "0.0.0.0"
|
||||
# API监听端口
|
||||
PORT: int = 3001
|
||||
# 前端监听端口
|
||||
NGINX_PORT: int = 3000
|
||||
# 是否调试模式
|
||||
DEBUG: bool = False
|
||||
# 是否开发模式
|
||||
DEV: bool = False
|
||||
# 是否在控制台输出 SQL 语句,默认关闭
|
||||
DB_ECHO: bool = False
|
||||
# 数据库连接池类型,QueuePool, NullPool
|
||||
DB_POOL_TYPE: str = "QueuePool"
|
||||
# 是否在获取连接时进行预先 ping 操作,默认关闭
|
||||
DB_POOL_PRE_PING: bool = False
|
||||
# 数据库连接池的大小,默认 100
|
||||
DB_POOL_SIZE: int = 100
|
||||
# 数据库连接的回收时间(秒),默认 1800 秒
|
||||
DB_POOL_RECYCLE: int = 1800
|
||||
# 数据库连接池获取连接的超时时间(秒),默认 60 秒
|
||||
DB_POOL_TIMEOUT: int = 60
|
||||
# 数据库连接池最大溢出连接数,默认 500
|
||||
DB_MAX_OVERFLOW: int = 500
|
||||
# SQLite 的 busy_timeout 参数,默认为 60 秒
|
||||
DB_TIMEOUT: int = 60
|
||||
# SQLite 是否启用 WAL 模式,默认关闭
|
||||
DB_WAL_ENABLE: bool = False
|
||||
# 配置文件目录
|
||||
CONFIG_DIR: Optional[str] = None
|
||||
# 超级管理员
|
||||
SUPERUSER: str = "admin"
|
||||
# 辅助认证,允许通过外部服务进行认证、单点登录以及自动创建用户
|
||||
AUXILIARY_AUTH_ENABLE: bool = False
|
||||
# API密钥,需要更换
|
||||
API_TOKEN: Optional[str] = None
|
||||
# 网络代理 IP:PORT
|
||||
PROXY_HOST: Optional[str] = None
|
||||
# 登录页面电影海报,tmdb/bing/mediaserver
|
||||
WALLPAPER: str = "tmdb"
|
||||
# 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔
|
||||
SEARCH_SOURCE: str = "themoviedb,douban,bangumi"
|
||||
# 媒体识别来源 themoviedb/douban
|
||||
RECOGNIZE_SOURCE: str = "themoviedb"
|
||||
# 刮削来源 themoviedb/douban
|
||||
SCRAP_SOURCE: str = "themoviedb"
|
||||
# 新增已入库媒体是否跟随TMDB信息变化
|
||||
SCRAP_FOLLOW_TMDB: bool = True
|
||||
# TMDB图片地址
|
||||
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
|
||||
# TMDB API地址
|
||||
TMDB_API_DOMAIN: str = "api.themoviedb.org"
|
||||
# TMDB API Key
|
||||
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
||||
# TVDB API Key
|
||||
TVDB_API_KEY: str = "6b481081-10aa-440c-99f2-21d17717ee02"
|
||||
# Fanart开关
|
||||
FANART_ENABLE: bool = True
|
||||
# Fanart API Key
|
||||
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
|
||||
# 元数据识别缓存过期时间(小时)
|
||||
META_CACHE_EXPIRE: int = 0
|
||||
# 电视剧动漫的分类genre_ids
|
||||
ANIME_GENREIDS = [16]
|
||||
# 用户认证站点
|
||||
AUTH_SITE: str = ""
|
||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||
AUTO_UPDATE_RESOURCE: bool = True
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
# 数据库类型,支持 sqlite 和 postgresql,默认使用 sqlite
|
||||
DB_TYPE: str = "sqlite"
|
||||
# 是否在控制台输出 SQL 语句,默认关闭
|
||||
DB_ECHO: bool = False
|
||||
# 数据库连接超时时间(秒),默认为 60 秒
|
||||
DB_TIMEOUT: int = 60
|
||||
# 是否启用 WAL 模式,仅适用于SQLite,默认开启
|
||||
DB_WAL_ENABLE: bool = True
|
||||
# 数据库连接池类型,QueuePool, NullPool
|
||||
DB_POOL_TYPE: str = "QueuePool"
|
||||
# 是否在获取连接时进行预先 ping 操作
|
||||
DB_POOL_PRE_PING: bool = True
|
||||
# 数据库连接的回收时间(秒)
|
||||
DB_POOL_RECYCLE: int = 300
|
||||
# 数据库连接池获取连接的超时时间(秒)
|
||||
DB_POOL_TIMEOUT: int = 30
|
||||
# SQLite 连接池大小
|
||||
DB_SQLITE_POOL_SIZE: int = 30
|
||||
# SQLite 连接池溢出数量
|
||||
DB_SQLITE_MAX_OVERFLOW: int = 50
|
||||
# PostgreSQL 主机地址
|
||||
DB_POSTGRESQL_HOST: str = "localhost"
|
||||
# PostgreSQL 端口
|
||||
DB_POSTGRESQL_PORT: int = 5432
|
||||
# PostgreSQL 数据库名
|
||||
DB_POSTGRESQL_DATABASE: str = "moviepilot"
|
||||
# PostgreSQL 用户名
|
||||
DB_POSTGRESQL_USERNAME: str = "moviepilot"
|
||||
# PostgreSQL 密码
|
||||
DB_POSTGRESQL_PASSWORD: str = "moviepilot"
|
||||
# PostgreSQL 连接池大小
|
||||
DB_POSTGRESQL_POOL_SIZE: int = 30
|
||||
# PostgreSQL 连接池溢出数量
|
||||
DB_POSTGRESQL_MAX_OVERFLOW: int = 50
|
||||
|
||||
# ==================== 缓存配置 ====================
|
||||
# 缓存类型,支持 cachetools 和 redis,默认使用 cachetools
|
||||
CACHE_BACKEND_TYPE: str = "cachetools"
|
||||
# 缓存连接字符串,仅外部缓存(如 Redis、Memcached)需要
|
||||
CACHE_BACKEND_URL: Optional[str] = "redis://localhost:6379"
|
||||
# Redis 缓存最大内存限制,未配置时,如开启大内存模式时为 "1024mb",未开启时为 "256mb"
|
||||
CACHE_REDIS_MAXMEMORY: Optional[str] = None
|
||||
# 全局图片缓存,将媒体图片缓存到本地
|
||||
GLOBAL_IMAGE_CACHE: bool = False
|
||||
# 全局图片缓存保留天数
|
||||
GLOBAL_IMAGE_CACHE_DAYS: int = 7
|
||||
# 临时文件保留天数
|
||||
TEMP_FILE_DAYS: int = 3
|
||||
# 元数据识别缓存过期时间(小时),0为自动
|
||||
META_CACHE_EXPIRE: int = 0
|
||||
|
||||
# ==================== 网络代理配置 ====================
|
||||
# 网络代理服务器地址
|
||||
PROXY_HOST: Optional[str] = None
|
||||
# 是否启用DOH解析域名
|
||||
DOH_ENABLE: bool = True
|
||||
DOH_ENABLE: bool = False
|
||||
# 使用 DOH 解析的域名列表
|
||||
DOH_DOMAINS: str = ("api.themoviedb.org,"
|
||||
"api.tmdb.org,"
|
||||
@@ -120,9 +160,59 @@ class ConfigModel(BaseModel):
|
||||
"api.github.com,"
|
||||
"github.com,"
|
||||
"raw.githubusercontent.com,"
|
||||
"codeload.github.com,"
|
||||
"api.telegram.org")
|
||||
# DOH 解析服务器列表
|
||||
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
|
||||
|
||||
# ==================== 媒体元数据配置 ====================
|
||||
# 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔
|
||||
SEARCH_SOURCE: str = "themoviedb,douban,bangumi"
|
||||
# 媒体识别来源 themoviedb/douban
|
||||
RECOGNIZE_SOURCE: str = "themoviedb"
|
||||
# 刮削来源 themoviedb/douban
|
||||
SCRAP_SOURCE: str = "themoviedb"
|
||||
# 电视剧动漫的分类genre_ids
|
||||
ANIME_GENREIDS: List[int] = Field(default=[16])
|
||||
|
||||
# ==================== TMDB配置 ====================
|
||||
# TMDB图片地址
|
||||
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
|
||||
# TMDB API地址
|
||||
TMDB_API_DOMAIN: str = "api.themoviedb.org"
|
||||
# TMDB元数据语言
|
||||
TMDB_LOCALE: str = "zh"
|
||||
# 刮削使用TMDB原始语种图片
|
||||
TMDB_SCRAP_ORIGINAL_IMAGE: bool = False
|
||||
# TMDB API Key
|
||||
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
||||
|
||||
# ==================== TVDB配置 ====================
|
||||
# TVDB API Key
|
||||
TVDB_V4_API_KEY: str = "ed2aa66b-7899-4677-92a7-67bc9ce3d93a"
|
||||
TVDB_V4_API_PIN: str = ""
|
||||
|
||||
# ==================== Fanart配置 ====================
|
||||
# Fanart开关
|
||||
FANART_ENABLE: bool = True
|
||||
# Fanart语言
|
||||
FANART_LANG: str = "zh,en"
|
||||
# Fanart API Key
|
||||
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
|
||||
|
||||
# ==================== 云盘配置 ====================
|
||||
# 115 AppId
|
||||
U115_APP_ID: str = "100196807"
|
||||
# Alipan AppId
|
||||
ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403"
|
||||
|
||||
# ==================== 系统升级配置 ====================
|
||||
# 重启自动升级
|
||||
MOVIEPILOT_AUTO_UPDATE: str = 'release'
|
||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||
AUTO_UPDATE_RESOURCE: bool = True
|
||||
|
||||
# ==================== 媒体文件格式配置 ====================
|
||||
# 支持的后缀格式
|
||||
RMT_MEDIAEXT: list = Field(
|
||||
default_factory=lambda: ['.mp4', '.mkv', '.ts', '.iso',
|
||||
@@ -145,10 +235,12 @@ class ConfigModel(BaseModel):
|
||||
'.aifc', '.aiff', '.alac', '.adif', '.adts',
|
||||
'.flac', '.midi', '.opus', '.sfalc']
|
||||
)
|
||||
# 下载器临时文件后缀
|
||||
DOWNLOAD_TMPEXT: list = Field(default_factory=lambda: ['.!qb', '.part'])
|
||||
|
||||
# ==================== 媒体服务器配置 ====================
|
||||
# 媒体服务器同步间隔(小时)
|
||||
MEDIASERVER_SYNC_INTERVAL: int = 6
|
||||
|
||||
# ==================== 订阅配置 ====================
|
||||
# 订阅模式
|
||||
SUBSCRIBE_MODE: str = "spider"
|
||||
# RSS订阅模式刷新时间间隔(分钟)
|
||||
@@ -159,18 +251,38 @@ class ConfigModel(BaseModel):
|
||||
SUBSCRIBE_SEARCH: bool = False
|
||||
# 检查本地媒体库是否存在资源开关
|
||||
LOCAL_EXISTS_SEARCH: bool = False
|
||||
# 搜索多个名称
|
||||
SEARCH_MULTIPLE_NAME: bool = False
|
||||
|
||||
# ==================== 站点配置 ====================
|
||||
# 站点数据刷新间隔(小时)
|
||||
SITEDATA_REFRESH_INTERVAL: int = 6
|
||||
# 读取和发送站点消息
|
||||
SITE_MESSAGE: bool = True
|
||||
# 不能缓存站点资源的站点域名,多个使用,分隔
|
||||
NO_CACHE_SITE_KEY: str = "m-team"
|
||||
# OCR服务器地址,用于识别站点验证码
|
||||
OCR_HOST: str = "https://movie-pilot.org"
|
||||
# 仿真类型:playwright 或 flaresolverr
|
||||
BROWSER_EMULATION: str = "playwright"
|
||||
# FlareSolverr 服务地址,例如 http://127.0.0.1:8191
|
||||
FLARESOLVERR_URL: Optional[str] = None
|
||||
|
||||
# ==================== 搜索配置 ====================
|
||||
# 搜索多个名称
|
||||
SEARCH_MULTIPLE_NAME: bool = False
|
||||
# 最大搜索名称数量
|
||||
MAX_SEARCH_NAME_LIMIT: int = 2
|
||||
|
||||
# ==================== 下载配置 ====================
|
||||
# 种子标签
|
||||
TORRENT_TAG: str = "MOVIEPILOT"
|
||||
# 下载站点字幕
|
||||
DOWNLOAD_SUBTITLE: bool = True
|
||||
# 交互搜索自动下载用户ID,使用,分割
|
||||
AUTO_DOWNLOAD_USER: Optional[str] = None
|
||||
# 下载器临时文件后缀
|
||||
DOWNLOAD_TMPEXT: list = Field(default_factory=lambda: ['.!qb', '.part'])
|
||||
|
||||
# ==================== CookieCloud配置 ====================
|
||||
# CookieCloud是否启动本地服务
|
||||
COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False
|
||||
# CookieCloud服务器地址
|
||||
@@ -183,8 +295,8 @@ class ConfigModel(BaseModel):
|
||||
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
||||
# CookieCloud同步黑名单,多个域名,分割
|
||||
COOKIECLOUD_BLACKLIST: Optional[str] = None
|
||||
# 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"
|
||||
|
||||
# ==================== 整理配置 ====================
|
||||
# 电影重命名格式
|
||||
MOVIE_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \
|
||||
"/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}" \
|
||||
@@ -194,54 +306,99 @@ class ConfigModel(BaseModel):
|
||||
"/Season {{season}}" \
|
||||
"/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}" \
|
||||
"{{fileExt}}"
|
||||
# OCR服务器地址
|
||||
OCR_HOST: str = "https://movie-pilot.org"
|
||||
# 重命名时支持的S0别名
|
||||
RENAME_FORMAT_S0_NAMES: list = Field(default=["Specials", "SPs"])
|
||||
# 为指定默认字幕添加.default后缀
|
||||
DEFAULT_SUB: Optional[str] = "zh-cn"
|
||||
# 新增已入库媒体是否跟随TMDB信息变化
|
||||
SCRAP_FOLLOW_TMDB: bool = True
|
||||
|
||||
# ==================== 服务地址配置 ====================
|
||||
# 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目
|
||||
MP_SERVER_HOST: str = "https://movie-pilot.org"
|
||||
|
||||
# ==================== 个性化 ====================
|
||||
# 登录页面电影海报,tmdb/bing/mediaserver
|
||||
WALLPAPER: str = "tmdb"
|
||||
# 自定义壁纸api地址
|
||||
CUSTOMIZE_WALLPAPER_API_URL: Optional[str] = None
|
||||
|
||||
# ==================== 插件配置 ====================
|
||||
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
||||
PLUGIN_MARKET: str = ("https://github.com/jxxghp/MoviePilot-Plugins,"
|
||||
"https://github.com/thsrite/MoviePilot-Plugins,"
|
||||
"https://github.com/honue/MoviePilot-Plugins,"
|
||||
"https://github.com/InfinityPacer/MoviePilot-Plugins")
|
||||
"https://github.com/InfinityPacer/MoviePilot-Plugins,"
|
||||
"https://github.com/DDS-Derek/MoviePilot-Plugins,"
|
||||
"https://github.com/madrays/MoviePilot-Plugins,"
|
||||
"https://github.com/justzerock/MoviePilot-Plugins,"
|
||||
"https://github.com/KoWming/MoviePilot-Plugins,"
|
||||
"https://github.com/wikrin/MoviePilot-Plugins,"
|
||||
"https://github.com/HankunYu/MoviePilot-Plugins,"
|
||||
"https://github.com/baozaodetudou/MoviePilot-Plugins,"
|
||||
"https://github.com/Aqr-K/MoviePilot-Plugins,"
|
||||
"https://github.com/hotlcc/MoviePilot-Plugins-Third,"
|
||||
"https://github.com/gxterry/MoviePilot-Plugins,"
|
||||
"https://github.com/DzAvril/MoviePilot-Plugins")
|
||||
# 插件安装数据共享
|
||||
PLUGIN_STATISTIC_SHARE: bool = True
|
||||
# 是否开启插件热加载
|
||||
PLUGIN_AUTO_RELOAD: bool = False
|
||||
|
||||
# ==================== Github & PIP ====================
|
||||
# Github token,提高请求api限流阈值 ghp_****
|
||||
GITHUB_TOKEN: Optional[str] = None
|
||||
# Github代理服务器,格式:https://mirror.ghproxy.com/
|
||||
GITHUB_PROXY: Optional[str] = ''
|
||||
# pip镜像站点,格式:https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
# pip镜像站点,格式:https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
PIP_PROXY: Optional[str] = ''
|
||||
# 指定的仓库Github token,多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****
|
||||
REPO_GITHUB_TOKEN: Optional[str] = None
|
||||
|
||||
# ==================== 性能配置 ====================
|
||||
# 大内存模式
|
||||
BIG_MEMORY_MODE: bool = False
|
||||
# 全局图片缓存,将媒体图片缓存到本地
|
||||
GLOBAL_IMAGE_CACHE: bool = False
|
||||
# FastApi性能监控
|
||||
PERFORMANCE_MONITOR_ENABLE: bool = False
|
||||
# 是否启用编码探测的性能模式
|
||||
ENCODING_DETECTION_PERFORMANCE_MODE: bool = True
|
||||
# 编码探测的最低置信度阈值
|
||||
ENCODING_DETECTION_MIN_CONFIDENCE: float = 0.8
|
||||
|
||||
# ==================== 安全配置 ====================
|
||||
# 允许的图片缓存域名
|
||||
SECURITY_IMAGE_DOMAINS: List[str] = Field(
|
||||
default_factory=lambda: ["image.tmdb.org",
|
||||
"static-mdb.v.geilijiasu.com",
|
||||
"doubanio.com",
|
||||
"lain.bgm.tv",
|
||||
"raw.githubusercontent.com",
|
||||
"github.com"]
|
||||
)
|
||||
SECURITY_IMAGE_DOMAINS: list = Field(default=[
|
||||
"image.tmdb.org",
|
||||
"static-mdb.v.geilijiasu.com",
|
||||
"bing.com",
|
||||
"doubanio.com",
|
||||
"lain.bgm.tv",
|
||||
"raw.githubusercontent.com",
|
||||
"github.com",
|
||||
"thetvdb.com",
|
||||
"cctvpic.com",
|
||||
"iqiyipic.com",
|
||||
"hdslb.com",
|
||||
"cmvideo.cn",
|
||||
"ykimg.com",
|
||||
"qpic.cn"
|
||||
])
|
||||
# 允许的图片文件后缀格式
|
||||
SECURITY_IMAGE_SUFFIXES: List[str] = Field(
|
||||
default_factory=lambda: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"]
|
||||
)
|
||||
# 重命名时支持的S0别名
|
||||
RENAME_FORMAT_S0_NAMES: List[str] = Field(
|
||||
default_factory=lambda: ["Specials", "SPs"]
|
||||
)
|
||||
# 启用分词搜索
|
||||
TOKENIZED_SEARCH: bool = False
|
||||
SECURITY_IMAGE_SUFFIXES: list = Field(default=[".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"])
|
||||
|
||||
# ==================== 工作流配置 ====================
|
||||
# 工作流数据共享
|
||||
WORKFLOW_STATISTIC_SHARE: bool = True
|
||||
|
||||
# ==================== 存储配置 ====================
|
||||
# 对rclone进行快照对比时,是否检查文件夹的修改时间
|
||||
RCLONE_SNAPSHOT_CHECK_FOLDER_MODTIME = True
|
||||
# 对OpenList进行快照对比时,是否检查文件夹的修改时间
|
||||
OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME = True
|
||||
|
||||
# ==================== Docker配置 ====================
|
||||
# Docker Client API地址
|
||||
DOCKER_CLIENT_API: Optional[str] = "tcp://127.0.0.1:38379"
|
||||
|
||||
|
||||
class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
@@ -288,6 +445,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
raise_exception: bool = False) -> Tuple[Any, bool]:
|
||||
"""
|
||||
通用类型转换函数,根据预期类型转换值。如果转换失败,返回默认值
|
||||
:return: 元组 (转换后的值, 是否需要更新)
|
||||
"""
|
||||
if isinstance(value, (list, dict, set)):
|
||||
value = copy.deepcopy(value)
|
||||
@@ -328,19 +486,17 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
converted = float(value)
|
||||
return converted, str(converted) != str(original_value)
|
||||
elif expected_type is str:
|
||||
# 清理 value 中所有空白字符的字段
|
||||
fields_not_keep_spaces = {"AUTO_DOWNLOAD_USER", "REPO_GITHUB_TOKEN", "PLUGIN_MARKET"}
|
||||
if field_name in fields_not_keep_spaces:
|
||||
value = re.sub(r"\s+", "", value)
|
||||
return value, str(value) != str(original_value)
|
||||
# # 后续考虑支持 list 类型的处理
|
||||
# elif expected_type is list:
|
||||
# if isinstance(value, list):
|
||||
# return value, False
|
||||
# if isinstance(value, str):
|
||||
# items = [item.strip() for item in value.split(",") if item.strip()]
|
||||
# return items, items != original_value.split(",")
|
||||
# 可根据需要添加更多类型处理
|
||||
converted = str(value).strip()
|
||||
return converted, converted != str(original_value)
|
||||
elif expected_type is list:
|
||||
if isinstance(value, list):
|
||||
return value, str(value) != str(original_value)
|
||||
if isinstance(value, str):
|
||||
items = json.loads(value)
|
||||
if isinstance(original_value, list):
|
||||
return items, items != original_value
|
||||
else:
|
||||
return items, str(items) != str(original_value)
|
||||
else:
|
||||
return value, str(value) != str(original_value)
|
||||
except (ValueError, TypeError) as e:
|
||||
@@ -348,10 +504,10 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
raise ValueError(f"配置项 '{field_name}' 的值 '{value}' 无法转换成正确的类型") from e
|
||||
logger.error(
|
||||
f"配置项 '{field_name}' 的值 '{value}' 无法转换成正确的类型,使用默认值 '{default}',错误信息: {e}")
|
||||
return default, True
|
||||
return default, True
|
||||
|
||||
@validator('*', pre=True, always=True)
|
||||
def generic_type_validator(cls, value: Any, field): # noqa
|
||||
def generic_type_validator(cls, value: Any, field): # noqa
|
||||
"""
|
||||
通用校验器,尝试将配置值转换为期望的类型
|
||||
"""
|
||||
@@ -380,14 +536,24 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
logger.warning(message)
|
||||
return False, message
|
||||
else:
|
||||
set_key(SystemUtils.get_env_path(), field.name, str(converted_value) if converted_value is not None else "")
|
||||
# 如果是列表、字典或集合类型,将其转换为JSON字符串
|
||||
if isinstance(converted_value, (list, dict, set)):
|
||||
value_to_write = json.dumps(converted_value)
|
||||
else:
|
||||
value_to_write = str(converted_value) if converted_value is not None else ""
|
||||
|
||||
set_key(dotenv_path=SystemUtils.get_env_path(), key_to_set=field.name, value_to_set=value_to_write,
|
||||
quote_mode="always")
|
||||
if is_converted:
|
||||
logger.info(f"配置项 '{field.name}' 已自动修正并写入到 'app.env' 文件")
|
||||
return True, message
|
||||
|
||||
def update_setting(self, key: str, value: Any) -> Tuple[bool, str]:
|
||||
def update_setting(self, key: str, value: Any) -> Tuple[Optional[bool], str]:
|
||||
"""
|
||||
更新单个配置项
|
||||
:param key: 配置项的名称
|
||||
:param value: 配置项的新值
|
||||
:return: (是否成功 True 成功/False 失败/None 无需更新, 错误信息)
|
||||
"""
|
||||
if not hasattr(self, key):
|
||||
return False, f"配置项 '{key}' 不存在"
|
||||
@@ -398,8 +564,11 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
if field.name == "API_TOKEN":
|
||||
converted_value, needs_update = self.validate_api_token(value, original_value)
|
||||
else:
|
||||
converted_value, needs_update = self.generic_type_converter(value, original_value, field.type_,
|
||||
field.default, key)
|
||||
converted_value, needs_update = self.generic_type_converter(value,
|
||||
original_value,
|
||||
field.type_,
|
||||
field.default,
|
||||
key)
|
||||
# 如果没有抛出异常,则统一使用 converted_value 进行更新
|
||||
if needs_update or str(value) != str(converted_value):
|
||||
success, message = self.update_env_config(field, value, converted_value)
|
||||
@@ -409,30 +578,17 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
if hasattr(log_settings, key):
|
||||
setattr(log_settings, key, converted_value)
|
||||
return success, message
|
||||
return True, ""
|
||||
return None, ""
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def update_settings(self, env: Dict[str, Any]) -> Dict[str, Tuple[bool, str]]:
|
||||
def update_settings(self, env: Dict[str, Any]) -> Dict[str, Tuple[Optional[bool], str]]:
|
||||
"""
|
||||
更新多个配置项
|
||||
"""
|
||||
results = {}
|
||||
log_updated, plugin_monitor_updated = False, False
|
||||
for k, v in env.items():
|
||||
results[k] = self.update_setting(k, v)
|
||||
if hasattr(log_settings, k):
|
||||
log_updated = True
|
||||
if k in ["PLUGIN_AUTO_RELOAD", "DEV"]:
|
||||
plugin_monitor_updated = True
|
||||
# 本次更新存在日志配置项更新,需要重新加载日志配置
|
||||
if log_updated:
|
||||
logger.update_loggers()
|
||||
# 本次更新存在插件监控配置项更新,需要重新加载插件监控
|
||||
if plugin_monitor_updated:
|
||||
# 解决顶层循环导入问题
|
||||
from app.core.plugin import PluginManager
|
||||
PluginManager().reload_monitor()
|
||||
return results
|
||||
|
||||
@property
|
||||
@@ -442,6 +598,20 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"""
|
||||
return "v2"
|
||||
|
||||
@property
|
||||
def USER_AGENT(self) -> str:
|
||||
"""
|
||||
全局用户代理字符串
|
||||
"""
|
||||
return f"{self.PROJECT_NAME}/{APP_VERSION[1:]} ({platform.system()} {platform.release()}; {SystemUtils.cpu_arch()})"
|
||||
|
||||
@property
|
||||
def NORMAL_USER_AGENT(self) -> str:
|
||||
"""
|
||||
默认浏览器用户代理字符串
|
||||
"""
|
||||
return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
|
||||
|
||||
@property
|
||||
def INNER_CONFIG_PATH(self):
|
||||
return self.ROOT_PATH / "config"
|
||||
@@ -481,36 +651,33 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
return self.CONFIG_PATH / "cookies"
|
||||
|
||||
@property
|
||||
def CACHE_CONF(self):
|
||||
def CONF(self) -> SystemConfModel:
|
||||
"""
|
||||
{
|
||||
"torrents": "缓存种子数量",
|
||||
"refresh": "订阅刷新处理数量",
|
||||
"tmdb": "TMDB请求缓存数量",
|
||||
"douban": "豆瓣请求缓存数量",
|
||||
"fanart": "Fanart请求缓存数量",
|
||||
"meta": "元数据缓存过期时间(秒)"
|
||||
}
|
||||
根据内存模式返回系统配置
|
||||
"""
|
||||
if self.BIG_MEMORY_MODE:
|
||||
return {
|
||||
"torrents": 200,
|
||||
"refresh": 100,
|
||||
"tmdb": 1024,
|
||||
"douban": 512,
|
||||
"bangumi": 512,
|
||||
"fanart": 512,
|
||||
"meta": (self.META_CACHE_EXPIRE or 24) * 3600
|
||||
}
|
||||
return {
|
||||
"torrents": 100,
|
||||
"refresh": 50,
|
||||
"tmdb": 256,
|
||||
"douban": 256,
|
||||
"bangumi": 256,
|
||||
"fanart": 128,
|
||||
"meta": (self.META_CACHE_EXPIRE or 2) * 3600
|
||||
}
|
||||
return SystemConfModel(
|
||||
torrents=200,
|
||||
refresh=100,
|
||||
tmdb=1024,
|
||||
douban=512,
|
||||
bangumi=512,
|
||||
fanart=512,
|
||||
meta=(self.META_CACHE_EXPIRE or 24) * 3600,
|
||||
scheduler=100,
|
||||
threadpool=100
|
||||
)
|
||||
return SystemConfModel(
|
||||
torrents=100,
|
||||
refresh=50,
|
||||
tmdb=256,
|
||||
douban=256,
|
||||
bangumi=256,
|
||||
fanart=128,
|
||||
meta=(self.META_CACHE_EXPIRE or 2) * 3600,
|
||||
scheduler=50,
|
||||
threadpool=50
|
||||
)
|
||||
|
||||
@property
|
||||
def PROXY(self):
|
||||
@@ -524,9 +691,23 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
@property
|
||||
def PROXY_SERVER(self):
|
||||
if self.PROXY_HOST:
|
||||
return {
|
||||
"server": self.PROXY_HOST
|
||||
}
|
||||
try:
|
||||
parsed = urlparse(self.PROXY_HOST)
|
||||
if not parsed.scheme:
|
||||
return {"server": self.PROXY_HOST}
|
||||
host = parsed.hostname or ""
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
server = f"{parsed.scheme}://{host}{port}"
|
||||
proxy = {"server": server}
|
||||
if parsed.username:
|
||||
proxy["username"] = parsed.username
|
||||
if parsed.password:
|
||||
proxy["password"] = parsed.password
|
||||
return proxy
|
||||
except Exception as err:
|
||||
logger.error(f"解析代理服务器地址 '{self.PROXY_HOST}' 时出错: {err}")
|
||||
return {"server": self.PROXY_HOST}
|
||||
return None
|
||||
|
||||
@property
|
||||
def GITHUB_HEADERS(self):
|
||||
@@ -535,7 +716,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"""
|
||||
if self.GITHUB_TOKEN:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.GITHUB_TOKEN}"
|
||||
"Authorization": f"Bearer {self.GITHUB_TOKEN}",
|
||||
"User-Agent": self.NORMAL_USER_AGENT,
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -563,7 +745,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
print(f"无效的令牌或仓库信息: {token_pair}")
|
||||
continue
|
||||
headers[repo_info] = {
|
||||
"Authorization": f"Bearer {token}"
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": self.NORMAL_USER_AGENT,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"处理令牌对 '{token_pair}' 时出错: {e}")
|
||||
@@ -583,6 +766,27 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
return None
|
||||
return UrlUtils.combine_url(host=self.APP_DOMAIN, path=url)
|
||||
|
||||
def RENAME_FORMAT(self, media_type: MediaType):
|
||||
"""
|
||||
获取指定类型的重命名格式
|
||||
|
||||
:param media_type: MediaType.TV 或 MediaType.Movie
|
||||
:return: 重命名格式
|
||||
"""
|
||||
rename_format = (
|
||||
self.TV_RENAME_FORMAT
|
||||
if media_type == MediaType.TV
|
||||
else self.MOVIE_RENAME_FORMAT
|
||||
)
|
||||
# 规范重命名格式
|
||||
rename_format = rename_format.replace("\\", "/")
|
||||
rename_format = re.sub(r'/+', '/', rename_format)
|
||||
return rename_format.strip("/")
|
||||
|
||||
|
||||
# 实例化配置
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class GlobalVar(object):
|
||||
"""
|
||||
@@ -592,6 +796,10 @@ class GlobalVar(object):
|
||||
STOP_EVENT: threading.Event = threading.Event()
|
||||
# webpush订阅
|
||||
SUBSCRIPTIONS: List[dict] = []
|
||||
# 需应急停止的工作流
|
||||
EMERGENCY_STOP_WORKFLOWS: List[int] = []
|
||||
# 需应急停止文件整理
|
||||
EMERGENCY_STOP_TRANSFER: List[str] = []
|
||||
|
||||
def stop_system(self):
|
||||
"""
|
||||
@@ -618,9 +826,44 @@ class GlobalVar(object):
|
||||
"""
|
||||
self.SUBSCRIPTIONS.append(subscription)
|
||||
|
||||
def stop_workflow(self, workflow_id: int):
|
||||
"""
|
||||
停止工作流
|
||||
"""
|
||||
if workflow_id not in self.EMERGENCY_STOP_WORKFLOWS:
|
||||
self.EMERGENCY_STOP_WORKFLOWS.append(workflow_id)
|
||||
|
||||
def workflow_resume(self, workflow_id: int):
|
||||
"""
|
||||
恢复工作流
|
||||
"""
|
||||
if workflow_id in self.EMERGENCY_STOP_WORKFLOWS:
|
||||
self.EMERGENCY_STOP_WORKFLOWS.remove(workflow_id)
|
||||
|
||||
def is_workflow_stopped(self, workflow_id: int) -> bool:
|
||||
"""
|
||||
是否停止工作流
|
||||
"""
|
||||
return self.is_system_stopped or workflow_id in self.EMERGENCY_STOP_WORKFLOWS
|
||||
|
||||
def stop_transfer(self, path: str):
|
||||
"""
|
||||
停止文件整理
|
||||
"""
|
||||
if path not in self.EMERGENCY_STOP_TRANSFER:
|
||||
self.EMERGENCY_STOP_TRANSFER.append(path)
|
||||
|
||||
def is_transfer_stopped(self, path: str) -> bool:
|
||||
"""
|
||||
是否停止文件整理
|
||||
"""
|
||||
if self.is_system_stopped:
|
||||
return True
|
||||
if path in self.EMERGENCY_STOP_TRANSFER:
|
||||
self.EMERGENCY_STOP_TRANSFER.remove(path)
|
||||
return True
|
||||
return False
|
||||
|
||||
# 实例化配置
|
||||
settings = Settings()
|
||||
|
||||
# 全局标识
|
||||
global_vars = GlobalVar()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Tuple
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
@@ -37,7 +37,7 @@ class TorrentInfo:
|
||||
# 详情页面
|
||||
page_url: str = None
|
||||
# 种子大小
|
||||
size: float = 0
|
||||
size: float = 0.0
|
||||
# 做种者
|
||||
seeders: int = 0
|
||||
# 下载者
|
||||
@@ -178,6 +178,8 @@ class MediaInfo:
|
||||
douban_id: str = None
|
||||
# Bangumi ID
|
||||
bangumi_id: int = None
|
||||
# 合集ID
|
||||
collection_id: int = None
|
||||
# 媒体原语种
|
||||
original_language: str = None
|
||||
# 媒体原发行标题
|
||||
@@ -191,7 +193,7 @@ class MediaInfo:
|
||||
# LOGO
|
||||
logo_path: str = None
|
||||
# 评分
|
||||
vote_average: float = 0
|
||||
vote_average: float = None
|
||||
# 描述
|
||||
overview: str = None
|
||||
# 风格ID
|
||||
@@ -235,9 +237,9 @@ class MediaInfo:
|
||||
# 流媒体平台
|
||||
networks: list = field(default_factory=list)
|
||||
# 集数
|
||||
number_of_episodes: int = 0
|
||||
number_of_episodes: int = None
|
||||
# 季数
|
||||
number_of_seasons: int = 0
|
||||
number_of_seasons: int = None
|
||||
# 原产国
|
||||
origin_country: list = field(default_factory=list)
|
||||
# 原名
|
||||
@@ -253,13 +255,19 @@ class MediaInfo:
|
||||
# 标签
|
||||
tagline: str = None
|
||||
# 评价数量
|
||||
vote_count: int = 0
|
||||
vote_count: int = None
|
||||
# 流行度
|
||||
popularity: int = 0
|
||||
popularity: int = None
|
||||
# 时长
|
||||
runtime: int = None
|
||||
# 下一集
|
||||
next_episode_to_air: dict = field(default_factory=dict)
|
||||
# 内容分级
|
||||
content_rating: str = None
|
||||
# 全部剧集组
|
||||
episode_groups: List[dict] = field(default_factory=list)
|
||||
# 剧集组
|
||||
episode_group: str = None
|
||||
|
||||
def __post_init__(self):
|
||||
# 设置媒体信息
|
||||
@@ -397,6 +405,8 @@ class MediaInfo:
|
||||
if info.get("external_ids"):
|
||||
self.tvdb_id = info.get("external_ids", {}).get("tvdb_id")
|
||||
self.imdb_id = info.get("external_ids", {}).get("imdb_id")
|
||||
# 合集ID
|
||||
self.collection_id = info.get('collection_id')
|
||||
# 评分
|
||||
self.vote_average = round(float(info.get('vote_average')), 1) if info.get('vote_average') else 0
|
||||
# 描述
|
||||
@@ -448,6 +458,10 @@ class MediaInfo:
|
||||
air_date = seainfo.get("air_date")
|
||||
if air_date:
|
||||
self.season_years[season] = air_date[:4]
|
||||
# 剧集组
|
||||
if info.get("episode_groups"):
|
||||
self.episode_groups = info.pop("episode_groups").get("results") or []
|
||||
|
||||
# 海报
|
||||
if info.get('poster_path'):
|
||||
self.poster_path = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.get('poster_path')}"
|
||||
@@ -460,7 +474,16 @@ class MediaInfo:
|
||||
self.names = info.get('names') or []
|
||||
# 剩余属性赋值
|
||||
for key, value in info.items():
|
||||
if hasattr(self, key) and not getattr(self, key):
|
||||
if not value:
|
||||
continue
|
||||
if not hasattr(self, key):
|
||||
continue
|
||||
current_value = getattr(self, key)
|
||||
if current_value:
|
||||
continue
|
||||
if current_value is None:
|
||||
setattr(self, key, value)
|
||||
elif type(current_value) is type(value):
|
||||
setattr(self, key, value)
|
||||
|
||||
def set_douban_info(self, info: dict):
|
||||
@@ -592,7 +615,16 @@ class MediaInfo:
|
||||
self.production_countries = [{"id": country, "name": country} for country in info.get("countries") or []]
|
||||
# 剩余属性赋值
|
||||
for key, value in info.items():
|
||||
if not value:
|
||||
continue
|
||||
if not hasattr(self, key):
|
||||
continue
|
||||
current_value = getattr(self, key)
|
||||
if current_value:
|
||||
continue
|
||||
if current_value is None:
|
||||
setattr(self, key, value)
|
||||
elif type(current_value) is type(value):
|
||||
setattr(self, key, value)
|
||||
|
||||
def set_bangumi_info(self, info: dict):
|
||||
@@ -708,7 +740,7 @@ class MediaInfo:
|
||||
return self.backdrop_path.replace("original", "w500")
|
||||
return default or ""
|
||||
|
||||
def get_message_image(self, default: bool = None):
|
||||
def get_message_image(self, default: Optional[bool] = None):
|
||||
"""
|
||||
返回消息图片地址
|
||||
"""
|
||||
@@ -716,7 +748,7 @@ class MediaInfo:
|
||||
return self.backdrop_path.replace("original", "w500")
|
||||
return self.get_poster_image(default=default)
|
||||
|
||||
def get_poster_image(self, default: bool = None):
|
||||
def get_poster_image(self, default: Optional[bool] = None):
|
||||
"""
|
||||
返回海报图片地址
|
||||
"""
|
||||
@@ -724,7 +756,7 @@ class MediaInfo:
|
||||
return self.poster_path.replace("original", "w500")
|
||||
return default or ""
|
||||
|
||||
def get_overview_string(self, max_len: int = 140):
|
||||
def get_overview_string(self, max_len: Optional[int] = 140):
|
||||
"""
|
||||
返回带限定长度的简介信息
|
||||
:param max_len: 内容长度
|
||||
@@ -767,6 +799,7 @@ class MediaInfo:
|
||||
self.spoken_languages = []
|
||||
self.networks = []
|
||||
self.next_episode_to_air = {}
|
||||
self.episode_groups = []
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -781,6 +814,8 @@ class Context:
|
||||
media_info: MediaInfo = None
|
||||
# 种子信息
|
||||
torrent_info: TorrentInfo = None
|
||||
# 媒体识别失败次数
|
||||
media_recognize_fail_count: int = 0
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
@@ -789,5 +824,6 @@ class Context:
|
||||
return {
|
||||
"meta_info": self.meta_info.to_dict() if self.meta_info else None,
|
||||
"torrent_info": self.torrent_info.to_dict() if self.torrent_info else None,
|
||||
"media_info": self.media_info.to_dict() if self.media_info else None
|
||||
"media_info": self.media_info.to_dict() if self.media_info else None,
|
||||
"media_recognize_fail_count": self.media_recognize_fail_count
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import copy
|
||||
import importlib
|
||||
import inspect
|
||||
import random
|
||||
@@ -6,11 +5,11 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
from queue import Empty, PriorityQueue
|
||||
from typing import Callable, Dict, List, Optional, Union
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union, Any
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ChainEventData
|
||||
@@ -31,7 +30,7 @@ class Event:
|
||||
|
||||
def __init__(self, event_type: Union[EventType, ChainEventType],
|
||||
event_data: Optional[Union[Dict, ChainEventData]] = None,
|
||||
priority: int = DEFAULT_EVENT_PRIORITY):
|
||||
priority: Optional[int] = DEFAULT_EVENT_PRIORITY):
|
||||
"""
|
||||
:param event_type: 事件的类型,支持 EventType 或 ChainEventType
|
||||
:param event_data: 可选,事件携带的数据,默认为空字典
|
||||
@@ -71,11 +70,7 @@ class EventManager(metaclass=Singleton):
|
||||
EventManager 负责管理和调度广播事件和链式事件,包括订阅、发送和处理事件
|
||||
"""
|
||||
|
||||
# 退出事件
|
||||
__event = threading.Event()
|
||||
|
||||
def __init__(self):
|
||||
self.__messagehelper = MessageHelper()
|
||||
self.__executor = ThreadHelper() # 动态线程池,用于消费事件
|
||||
self.__consumer_threads = [] # 用于保存启动的事件消费者线程
|
||||
self.__event_queue = PriorityQueue() # 优先级队列
|
||||
@@ -84,6 +79,7 @@ class EventManager(metaclass=Singleton):
|
||||
self.__disabled_handlers = set() # 禁用的事件处理器集合
|
||||
self.__disabled_classes = set() # 禁用的事件处理器类集合
|
||||
self.__lock = threading.Lock() # 线程锁
|
||||
self.__event = threading.Event() # 退出事件
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
@@ -130,7 +126,7 @@ class EventManager(metaclass=Singleton):
|
||||
)
|
||||
|
||||
def send_event(self, etype: Union[EventType, ChainEventType], data: Optional[Union[Dict, ChainEventData]] = None,
|
||||
priority: int = DEFAULT_EVENT_PRIORITY) -> Optional[Event]:
|
||||
priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]:
|
||||
"""
|
||||
发送事件,根据事件类型决定是广播事件还是链式事件
|
||||
:param etype: 事件类型 (EventType 或 ChainEventType)
|
||||
@@ -140,14 +136,34 @@ class EventManager(metaclass=Singleton):
|
||||
"""
|
||||
event = Event(etype, data, priority)
|
||||
if isinstance(etype, EventType):
|
||||
self.__trigger_broadcast_event(event)
|
||||
return self.__trigger_broadcast_event(event)
|
||||
elif isinstance(etype, ChainEventType):
|
||||
return self.__trigger_chain_event(event)
|
||||
else:
|
||||
logger.error(f"Unknown event type: {etype}")
|
||||
return None
|
||||
|
||||
async def async_send_event(self, etype: Union[EventType, ChainEventType],
|
||||
data: Optional[Union[Dict, ChainEventData]] = None,
|
||||
priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]:
|
||||
"""
|
||||
异步发送事件,根据事件类型决定是广播事件还是链式事件
|
||||
:param etype: 事件类型 (EventType 或 ChainEventType)
|
||||
:param data: 可选,事件数据
|
||||
:param priority: 广播事件的优先级,默认为 10
|
||||
:return: 如果是链式事件,返回处理后的事件数据;否则返回 None
|
||||
"""
|
||||
event = Event(etype, data, priority)
|
||||
if isinstance(etype, EventType):
|
||||
return self.__trigger_broadcast_event(event)
|
||||
elif isinstance(etype, ChainEventType):
|
||||
return await self.__trigger_chain_event_async(event)
|
||||
else:
|
||||
logger.error(f"Unknown event type: {etype}")
|
||||
return None
|
||||
|
||||
def add_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable,
|
||||
priority: int = DEFAULT_EVENT_PRIORITY):
|
||||
priority: Optional[int] = DEFAULT_EVENT_PRIORITY):
|
||||
"""
|
||||
注册事件处理器,将处理器添加到对应的事件订阅列表中
|
||||
:param event_type: 事件类型 (EventType 或 ChainEventType)
|
||||
@@ -264,7 +280,6 @@ class EventManager(metaclass=Singleton):
|
||||
return handler_info
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=1000)
|
||||
def __get_handler_identifier(cls, target: Union[Callable, type]) -> Optional[str]:
|
||||
"""
|
||||
获取处理器或处理器类的唯一标识符,包括模块名和类名/方法名
|
||||
@@ -280,7 +295,6 @@ class EventManager(metaclass=Singleton):
|
||||
return f"{module_name}.{qualname}"
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=1000)
|
||||
def __get_class_from_callable(cls, handler: Callable) -> Optional[str]:
|
||||
"""
|
||||
获取可调用对象所属类的唯一标识符
|
||||
@@ -293,7 +307,7 @@ class EventManager(metaclass=Singleton):
|
||||
|
||||
# 对于类实例(实现了 __call__ 方法)
|
||||
if not inspect.isfunction(handler) and hasattr(handler, "__call__"):
|
||||
handler_cls = handler.__class__ # noqa
|
||||
handler_cls = handler.__class__ # noqa
|
||||
return cls.__get_handler_identifier(handler_cls)
|
||||
|
||||
# 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息
|
||||
@@ -303,6 +317,7 @@ class EventManager(metaclass=Singleton):
|
||||
module = inspect.getmodule(handler)
|
||||
module_name = module.__name__ if module else "unknown_module"
|
||||
return f"{module_name}.{class_name}"
|
||||
return None
|
||||
|
||||
def __is_handler_enabled(self, handler: Callable) -> bool:
|
||||
"""
|
||||
@@ -330,6 +345,14 @@ class EventManager(metaclass=Singleton):
|
||||
dispatch = self.__dispatch_chain_event(event)
|
||||
return event if dispatch else None
|
||||
|
||||
async def __trigger_chain_event_async(self, event: Event) -> Optional[Event]:
|
||||
"""
|
||||
异步触发链式事件,按顺序调用订阅的处理器,并记录处理耗时
|
||||
"""
|
||||
logger.debug(f"Triggering asynchronous chain event: {event}")
|
||||
dispatch = await self.__dispatch_chain_event_async(event)
|
||||
return event if dispatch else None
|
||||
|
||||
def __trigger_broadcast_event(self, event: Event):
|
||||
"""
|
||||
触发广播事件,将事件插入到优先级队列中
|
||||
@@ -367,6 +390,35 @@ class EventManager(metaclass=Singleton):
|
||||
self.__log_event_lifecycle(event, "Completed")
|
||||
return True
|
||||
|
||||
async def __dispatch_chain_event_async(self, event: Event) -> bool:
|
||||
"""
|
||||
异步方式调度链式事件,按优先级顺序逐个调用事件处理器,并记录每个处理器的处理时间
|
||||
:param event: 要调度的事件对象
|
||||
"""
|
||||
handlers = self.__chain_subscribers.get(event.event_type, {})
|
||||
if not handlers:
|
||||
logger.debug(f"No handlers found for chain event: {event}")
|
||||
return False
|
||||
|
||||
# 过滤出启用的处理器
|
||||
enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()
|
||||
if self.__is_handler_enabled(handler)}
|
||||
|
||||
if not enabled_handlers:
|
||||
logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.")
|
||||
return False
|
||||
|
||||
self.__log_event_lifecycle(event, "Started")
|
||||
for handler_id, (priority, handler) in enabled_handlers.items():
|
||||
start_time = time.time()
|
||||
await self.__safe_invoke_handler_async(handler, event)
|
||||
logger.debug(
|
||||
f"{self.__get_handler_identifier(handler)} (Priority: {priority}), "
|
||||
f"completed in {time.time() - start_time:.3f}s for event: {event}"
|
||||
)
|
||||
self.__log_event_lifecycle(event, "Completed")
|
||||
return True
|
||||
|
||||
def __dispatch_broadcast_event(self, event: Event):
|
||||
"""
|
||||
异步方式调度广播事件,通过线程池逐个调用事件处理器
|
||||
@@ -376,8 +428,17 @@ class EventManager(metaclass=Singleton):
|
||||
if not handlers:
|
||||
logger.debug(f"No handlers found for broadcast event: {event}")
|
||||
return
|
||||
# 为每个处理器提供独立的事件实例,防止某个处理器对 event_data 的修改影响其他处理器
|
||||
for handler_id, handler in handlers.items():
|
||||
self.__executor.submit(self.__safe_invoke_handler, handler, event)
|
||||
# 仅浅拷贝顶层字典,避免不必要的深拷贝开销;这样可以隔离键级别的替换/赋值
|
||||
if isinstance(event.event_data, dict):
|
||||
event_data_copy = event.event_data.copy()
|
||||
else:
|
||||
event_data_copy = event.event_data
|
||||
isolated_event = Event(event_type=event.event_type,
|
||||
event_data=event_data_copy,
|
||||
priority=event.priority)
|
||||
self.__executor.submit(self.__safe_invoke_handler, handler, isolated_event)
|
||||
|
||||
def __safe_invoke_handler(self, handler: Callable, event: Event):
|
||||
"""
|
||||
@@ -389,36 +450,161 @@ class EventManager(metaclass=Singleton):
|
||||
logger.debug(f"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution")
|
||||
return
|
||||
|
||||
# 根据事件类型判断是否需要深复制
|
||||
is_broadcast_event = isinstance(event.event_type, EventType)
|
||||
event_to_process = copy.deepcopy(event) if is_broadcast_event else event
|
||||
self.__invoke_handler_by_type_sync(handler, event)
|
||||
|
||||
async def __safe_invoke_handler_async(self, handler: Callable, event: Event):
|
||||
"""
|
||||
异步调用处理器,处理链式事件
|
||||
:param handler: 处理器
|
||||
:param event: 事件对象
|
||||
"""
|
||||
if not self.__is_handler_enabled(handler):
|
||||
logger.debug(f"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution")
|
||||
return
|
||||
|
||||
await self.__invoke_handler_by_type_async(handler, event)
|
||||
|
||||
def __invoke_handler_by_type_sync(self, handler: Callable, event: Event):
|
||||
"""
|
||||
同步方式根据处理器类型调用相应的方法
|
||||
:param handler: 处理器
|
||||
:param event: 要处理的事件对象
|
||||
"""
|
||||
class_name, method_name = self.__parse_handler_names(handler)
|
||||
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.module import ModuleManager
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
module_manager = ModuleManager()
|
||||
|
||||
if class_name in plugin_manager.get_plugin_ids():
|
||||
# 插件处理器
|
||||
plugin = plugin_manager.running_plugins.get(class_name)
|
||||
if not plugin:
|
||||
return
|
||||
method = getattr(plugin, method_name, None)
|
||||
if not method:
|
||||
return
|
||||
try:
|
||||
method(event)
|
||||
except Exception as e:
|
||||
self.__handle_event_error(event=event, module_name=plugin.name,
|
||||
class_name=class_name, method_name=method_name, e=e)
|
||||
elif class_name in module_manager.get_module_ids():
|
||||
# 模块处理器
|
||||
module = module_manager.get_running_module(class_name)
|
||||
if not module:
|
||||
return
|
||||
method = getattr(module, method_name, None)
|
||||
if not method:
|
||||
return
|
||||
try:
|
||||
method(event)
|
||||
except Exception as e:
|
||||
self.__handle_event_error(event=event, module_name=module.get_name(),
|
||||
class_name=class_name, method_name=method_name, e=e)
|
||||
else:
|
||||
# 全局处理器
|
||||
class_obj = self.__get_class_instance(class_name)
|
||||
if not class_obj or not hasattr(class_obj, method_name):
|
||||
return
|
||||
method = getattr(class_obj, method_name, None)
|
||||
if not method:
|
||||
return
|
||||
try:
|
||||
method(event)
|
||||
except Exception as e:
|
||||
self.__handle_event_error(event=event, module_name=class_name,
|
||||
class_name=class_name, method_name=method_name, e=e)
|
||||
|
||||
async def __invoke_handler_by_type_async(self, handler: Callable, event: Event):
|
||||
"""
|
||||
异步方式根据处理器类型调用相应的方法
|
||||
:param handler: 处理器
|
||||
:param event: 要处理的事件对象
|
||||
"""
|
||||
class_name, method_name = self.__parse_handler_names(handler)
|
||||
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.module import ModuleManager
|
||||
|
||||
plugin_manager = PluginManager()
|
||||
module_manager = ModuleManager()
|
||||
|
||||
if class_name in plugin_manager.get_plugin_ids():
|
||||
await self.__invoke_plugin_method_async(plugin_manager, class_name, method_name, event)
|
||||
elif class_name in module_manager.get_module_ids():
|
||||
await self.__invoke_module_method_async(module_manager, class_name, method_name, event)
|
||||
else:
|
||||
await self.__invoke_global_method_async(class_name, method_name, event)
|
||||
|
||||
@staticmethod
|
||||
def __parse_handler_names(handler: Callable) -> Tuple[str, str]:
|
||||
"""
|
||||
解析处理器的类名和方法名
|
||||
:param handler: 处理器
|
||||
:return: (class_name, method_name)
|
||||
"""
|
||||
names = handler.__qualname__.split(".")
|
||||
class_name, method_name = names[0], names[1]
|
||||
return names[0], names[1]
|
||||
|
||||
async def __invoke_plugin_method_async(self, handler: Any, class_name: str, method_name: str, event: Event):
|
||||
"""
|
||||
异步调用插件方法
|
||||
"""
|
||||
plugin = handler.running_plugins.get(class_name)
|
||||
if not plugin:
|
||||
return
|
||||
method = getattr(plugin, method_name, None)
|
||||
if not method:
|
||||
return
|
||||
try:
|
||||
from app.core.plugin import PluginManager
|
||||
|
||||
if class_name in PluginManager().get_plugin_ids():
|
||||
# 定义一个插件调用函数
|
||||
def plugin_callable():
|
||||
PluginManager().run_plugin_method(class_name, method_name, event_to_process)
|
||||
|
||||
if is_broadcast_event:
|
||||
self.__executor.submit(plugin_callable)
|
||||
else:
|
||||
plugin_callable()
|
||||
if inspect.iscoroutinefunction(method):
|
||||
await method(event)
|
||||
else:
|
||||
# 获取全局对象或模块类的实例
|
||||
class_obj = self.__get_class_instance(class_name)
|
||||
if class_obj and hasattr(class_obj, method_name):
|
||||
method = getattr(class_obj, method_name)
|
||||
if is_broadcast_event:
|
||||
self.__executor.submit(method, event_to_process)
|
||||
else:
|
||||
method(event_to_process)
|
||||
# 插件同步函数在异步环境中运行,避免阻塞
|
||||
await run_in_threadpool(method, event)
|
||||
except Exception as e:
|
||||
self.__handle_event_error(event, handler, e)
|
||||
self.__handle_event_error(event=event, handler=handler, e=e, module_name=plugin.name)
|
||||
|
||||
async def __invoke_module_method_async(self, handler: Any, class_name: str, method_name: str, event: Event):
|
||||
"""
|
||||
异步调用模块方法
|
||||
"""
|
||||
module = handler.get_running_module(class_name)
|
||||
if not module:
|
||||
return
|
||||
method = getattr(module, method_name, None)
|
||||
if not method:
|
||||
return
|
||||
try:
|
||||
if inspect.iscoroutinefunction(method):
|
||||
await method(event)
|
||||
else:
|
||||
method(event)
|
||||
except Exception as e:
|
||||
self.__handle_event_error(event=event, module_name=module.get_name(),
|
||||
class_name=class_name, method_name=method_name, e=e)
|
||||
|
||||
async def __invoke_global_method_async(self, class_name: str, method_name: str, event: Event):
|
||||
"""
|
||||
异步调用全局对象方法
|
||||
"""
|
||||
class_obj = self.__get_class_instance(class_name)
|
||||
if not class_obj:
|
||||
return
|
||||
method = getattr(class_obj, method_name, None)
|
||||
if not method:
|
||||
return
|
||||
try:
|
||||
if inspect.iscoroutinefunction(method):
|
||||
await method(event)
|
||||
else:
|
||||
method(event)
|
||||
except Exception as e:
|
||||
self.__handle_event_error(event=event, module_name=class_name,
|
||||
class_name=class_name, method_name=method_name, e=e)
|
||||
|
||||
@staticmethod
|
||||
def __get_class_instance(class_name: str):
|
||||
@@ -438,22 +624,29 @@ class EventManager(metaclass=Singleton):
|
||||
|
||||
# 如果类不在全局变量中,尝试动态导入模块并创建实例
|
||||
try:
|
||||
if class_name == "Command":
|
||||
module_name = "app.command"
|
||||
if class_name.endswith("Manager"):
|
||||
module_name = f"app.core.{class_name[:-7].lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
elif class_name.endswith("Chain"):
|
||||
module_name = f"app.chain.{class_name[:-5].lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
elif class_name.endswith("Helper"):
|
||||
# 特殊处理 Async 类
|
||||
if class_name.startswith("Async"):
|
||||
module_name = f"app.helper.{class_name[5:-6].lower()}"
|
||||
else:
|
||||
module_name = f"app.helper.{class_name[:-6].lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
else:
|
||||
logger.debug(f"事件处理出错:无效的 Chain 类名: {class_name},类名必须以 'Chain' 结尾")
|
||||
return None
|
||||
module_name = f"app.{class_name.lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
if hasattr(module, class_name):
|
||||
class_obj = getattr(module, class_name)()
|
||||
return class_obj
|
||||
else:
|
||||
logger.debug(f"事件处理出错:模块 {module_name} 中没有找到类 {class_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
logger.debug(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
def __broadcast_consumer_loop(self):
|
||||
@@ -482,18 +675,18 @@ class EventManager(metaclass=Singleton):
|
||||
"""
|
||||
logger.debug(f"{stage} - {event}")
|
||||
|
||||
def __handle_event_error(self, event: Event, handler: Callable, e: Exception):
|
||||
def __handle_event_error(self, event: Event, module_name: str,
|
||||
class_name: str, method_name: str, e: Exception):
|
||||
"""
|
||||
全局错误处理器,用于处理事件处理中的异常
|
||||
"""
|
||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
logger.error(f"{module_name} 事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
names = handler.__qualname__.split(".")
|
||||
class_name, method_name = names[0], names[1]
|
||||
|
||||
self.__messagehelper.put(title=f"{event.event_type} 事件处理出错",
|
||||
message=f"{class_name}.{method_name}:{str(e)}",
|
||||
role="system")
|
||||
# 发送系统错误通知
|
||||
from app.helper.message import MessageHelper
|
||||
MessageHelper().put(title=f"{module_name} 处理事件 {event.event_type} 时出错",
|
||||
message=f"{class_name}.{method_name}:{str(e)}",
|
||||
role="system")
|
||||
self.send_event(
|
||||
EventType.SystemError,
|
||||
{
|
||||
@@ -506,7 +699,7 @@ class EventManager(metaclass=Singleton):
|
||||
)
|
||||
|
||||
def register(self, etype: Union[EventType, ChainEventType, List[Union[EventType, ChainEventType]], type],
|
||||
priority: int = DEFAULT_EVENT_PRIORITY):
|
||||
priority: Optional[int] = DEFAULT_EVENT_PRIORITY):
|
||||
"""
|
||||
事件注册装饰器,用于将函数注册为事件的处理器
|
||||
:param etype:
|
||||
|
||||
@@ -9,8 +9,6 @@ class CustomizationMatcher(metaclass=Singleton):
|
||||
"""
|
||||
识别自定义占位符
|
||||
"""
|
||||
customization = None
|
||||
custom_separator = None
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
@@ -55,6 +55,8 @@ class MetaBase(object):
|
||||
resource_team: Optional[str] = None
|
||||
# 识别的自定义占位符
|
||||
customization: Optional[str] = None
|
||||
# 识别的流媒体平台
|
||||
web_source: Optional[str] = None
|
||||
# 视频编码
|
||||
video_encode: Optional[str] = None
|
||||
# 音频编码
|
||||
@@ -69,7 +71,7 @@ class MetaBase(object):
|
||||
_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_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季|([0-9一二三四五六七八九十]+)\s*季\s*全"
|
||||
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季"
|
||||
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP]+)\s*[集话話期幕](?!\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*[集话話期幕]"
|
||||
@@ -247,7 +249,7 @@ class MetaBase(object):
|
||||
self.type = MediaType.TV
|
||||
self._subtitle_flag = True
|
||||
return
|
||||
# x集全
|
||||
# x集全/全x集
|
||||
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
|
||||
if episode_all_str:
|
||||
episode_all = episode_all_str.group(1)
|
||||
@@ -259,8 +261,6 @@ class MetaBase(object):
|
||||
except Exception as err:
|
||||
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
||||
return
|
||||
self.begin_episode = None
|
||||
self.end_episode = None
|
||||
self.type = MediaType.TV
|
||||
self._subtitle_flag = True
|
||||
return
|
||||
@@ -584,6 +584,12 @@ class MetaBase(object):
|
||||
# Part
|
||||
if not self.part:
|
||||
self.part = meta.part
|
||||
# tmdbid
|
||||
if not self.tmdbid and meta.tmdbid:
|
||||
self.tmdbid = meta.tmdbid
|
||||
# doubanid
|
||||
if not self.doubanid and meta.doubanid:
|
||||
self.doubanid = meta.doubanid
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.tokens import Tokens
|
||||
from app.core.meta.streamingplatform import StreamingPlatforms
|
||||
|
||||
|
||||
class MetaVideo(MetaBase):
|
||||
@@ -31,7 +32,7 @@ class MetaVideo(MetaBase):
|
||||
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
|
||||
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
|
||||
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$"
|
||||
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
|
||||
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$|^HDR10(\+|Plus)$|^EDR$|^HQ$"
|
||||
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
|
||||
_name_no_begin_re = r"^[\[【].+?[\]】]"
|
||||
_name_no_chinese_re = r".*版|.*字幕"
|
||||
@@ -50,8 +51,8 @@ class MetaVideo(MetaBase):
|
||||
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_re2 = r"(^[248]+K)"
|
||||
_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?$"
|
||||
_video_encode_re = r"^(H26[45])$|^(x26[45])$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^AV1$|^HDR\d*$|^AVS(\+|[23])$"
|
||||
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\+\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$|^HR\d?$|^Opus\d?$|^Vorbis\d?$|^AV[3S]A$"
|
||||
|
||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||
"""
|
||||
@@ -66,6 +67,7 @@ class MetaVideo(MetaBase):
|
||||
original_title = title
|
||||
self._source = ""
|
||||
self._effect = []
|
||||
self._index = 0
|
||||
# 判断是否纯数字命名
|
||||
if isfile \
|
||||
and title.isdigit() \
|
||||
@@ -93,9 +95,12 @@ class MetaVideo(MetaBase):
|
||||
# 拆分tokens
|
||||
tokens = Tokens(title)
|
||||
self.tokens = tokens
|
||||
# 实例化StreamingPlatforms对象
|
||||
streaming_platforms = StreamingPlatforms()
|
||||
# 解析名称、年份、季、集、资源类型、分辨率等
|
||||
token = tokens.get_next()
|
||||
while token:
|
||||
self._index += 1 # 更新当前处理的token索引
|
||||
# Part
|
||||
self.__init_part(token)
|
||||
# 标题
|
||||
@@ -116,6 +121,9 @@ class MetaVideo(MetaBase):
|
||||
# 资源类型
|
||||
if self._continue_flag:
|
||||
self.__init_resource_type(token)
|
||||
# 流媒体平台
|
||||
if self._continue_flag:
|
||||
self.__init_web_source(token, streaming_platforms)
|
||||
# 视频编码
|
||||
if self._continue_flag:
|
||||
self.__init_video_encode(token)
|
||||
@@ -172,7 +180,7 @@ class MetaVideo(MetaBase):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __is_pinyin(name_str: str) -> bool:
|
||||
def __is_pinyin(name_str: Optional[str]) -> bool:
|
||||
"""
|
||||
判断是否拼音
|
||||
"""
|
||||
@@ -183,7 +191,7 @@ class MetaVideo(MetaBase):
|
||||
return False
|
||||
return True
|
||||
|
||||
def __fix_name(self, name: str):
|
||||
def __fix_name(self, name: Optional[str]):
|
||||
"""
|
||||
去掉名字中不需要的干扰字符
|
||||
"""
|
||||
@@ -192,7 +200,7 @@ class MetaVideo(MetaBase):
|
||||
name = re.sub(r'%s' % self._name_nostring_re, '', name,
|
||||
flags=re.IGNORECASE).strip()
|
||||
name = re.sub(r'\s+', ' ', name)
|
||||
if name.isdigit() \
|
||||
if name.isdecimal() \
|
||||
and int(name) < 1800 \
|
||||
and not self.year \
|
||||
and not self.begin_season \
|
||||
@@ -207,7 +215,7 @@ class MetaVideo(MetaBase):
|
||||
name = None
|
||||
return name
|
||||
|
||||
def __init_name(self, token: str):
|
||||
def __init_name(self, token: Optional[str]):
|
||||
"""
|
||||
识别名称
|
||||
"""
|
||||
@@ -574,6 +582,57 @@ class MetaVideo(MetaBase):
|
||||
self._effect.append(effect)
|
||||
self._last_token = effect.upper()
|
||||
|
||||
def __init_web_source(self, token: str, streaming_platforms: StreamingPlatforms):
|
||||
"""
|
||||
识别流媒体平台
|
||||
"""
|
||||
if not self.name:
|
||||
return
|
||||
|
||||
platform_name = None
|
||||
query_range = 1
|
||||
|
||||
prev_token = None
|
||||
prev_idx = self._index - 2
|
||||
if 0 <= prev_idx < len(self.tokens.tokens):
|
||||
prev_token = self.tokens.tokens[prev_idx]
|
||||
|
||||
next_token = self.tokens.peek()
|
||||
|
||||
if streaming_platforms.is_streaming_platform(token):
|
||||
platform_name = streaming_platforms.get_streaming_platform_name(token)
|
||||
else:
|
||||
for adjacent_token, is_next in [(prev_token, False), (next_token, True)]:
|
||||
if not adjacent_token or platform_name:
|
||||
continue
|
||||
|
||||
for separator in [" ", "-"]:
|
||||
if is_next:
|
||||
combined_token = f"{token}{separator}{adjacent_token}"
|
||||
else:
|
||||
combined_token = f"{adjacent_token}{separator}{token}"
|
||||
|
||||
if streaming_platforms.is_streaming_platform(combined_token):
|
||||
platform_name = streaming_platforms.get_streaming_platform_name(combined_token)
|
||||
query_range = 2
|
||||
if is_next:
|
||||
self.tokens.get_next()
|
||||
break
|
||||
|
||||
if not platform_name:
|
||||
return
|
||||
|
||||
web_tokens = ["WEB", "DL", "WEBDL", "WEBRIP"]
|
||||
match_start_idx = self._index - query_range
|
||||
match_end_idx = self._index - 1
|
||||
start_index = max(0, match_start_idx - query_range)
|
||||
end_index = min(len(self.tokens.tokens), match_end_idx + 1 + query_range)
|
||||
tokens_to_check = self.tokens.tokens[start_index:end_index]
|
||||
|
||||
if any(tok and tok.upper() in web_tokens for tok in tokens_to_check):
|
||||
self.web_source = platform_name
|
||||
self._continue_flag = False
|
||||
|
||||
def __init_video_encode(self, token: str):
|
||||
"""
|
||||
识别视频编码
|
||||
@@ -592,7 +651,12 @@ class MetaVideo(MetaBase):
|
||||
self._stop_name_flag = True
|
||||
self._last_token_type = "videoencode"
|
||||
if not self.video_encode:
|
||||
self.video_encode = re_res.group(1).upper()
|
||||
if re_res.group(2):
|
||||
self.video_encode = re_res.group(2).upper()
|
||||
elif re_res.group(3):
|
||||
self.video_encode = re_res.group(3).lower()
|
||||
else:
|
||||
self.video_encode = re_res.group(1).upper()
|
||||
self._last_token = self.video_encode
|
||||
elif self.video_encode == "10bit":
|
||||
self.video_encode = f"{re_res.group(1).upper()} 10bit"
|
||||
|
||||
@@ -9,38 +9,37 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
|
||||
"""
|
||||
识别制作组、字幕组
|
||||
"""
|
||||
__release_groups: str = None
|
||||
# 内置组
|
||||
RELEASE_GROUPS: dict = {
|
||||
"0ff": ['FF(?:(?:A|WE)B|CD|E(?:DU|B)|TV)'],
|
||||
"1pt": [],
|
||||
"52pt": [],
|
||||
"audiences": ['Audies', 'AD(?:Audio|E(?:|book)|Music|Web)'],
|
||||
"audiences": ['Audies', 'AD(?:Audio|E(?:book|)|Music|Web)'],
|
||||
"azusa": [],
|
||||
"beitai": ['BeiTai'],
|
||||
"btschool": ['Bts(?:CHOOL|HD|PAD|TV)', 'Zone'],
|
||||
"carpt": ['CarPT'],
|
||||
"chdbits": ['CHD(?:|Bits|PAD|(?:|HK)TV|WEB)', 'StBOX', 'OneHD', 'Lee', 'xiaopie'],
|
||||
"chdbits": ['CHD(?:Bits|PAD|(?:|HK)TV|WEB|)', 'StBOX', 'OneHD', 'Lee', 'xiaopie'],
|
||||
"discfan": [],
|
||||
"dragonhd": [],
|
||||
"eastgame": ['(?:(?:iNT|(?:HALFC|Mini(?:S|H|FH)D))-|)TLF'],
|
||||
"filelist": [],
|
||||
"gainbound": ['(?:DG|GBWE)B'],
|
||||
"hares": ['Hares(?:|(?:M|T)V|Web)'],
|
||||
"hares": ['Hares(?:(?:M|T)V|Web|)'],
|
||||
"hd4fans": [],
|
||||
"hdarea": ['HDA(?:pad|rea|TV)', 'EPiC'],
|
||||
"hdatmos": [],
|
||||
"hdbd": [],
|
||||
"hdchina": ['HDC(?:|hina|TV)', 'k9611', 'tudou', 'iHD'],
|
||||
"hdchina": ['HDC(?:hina|TV|)', 'k9611', 'tudou', 'iHD'],
|
||||
"hddolby": ['D(?:ream|BTV)', '(?:HD|QHstudI)o'],
|
||||
"hdfans": ['beAst(?:|TV)'],
|
||||
"hdhome": ['HDH(?:|ome|Pad|TV|WEB)'],
|
||||
"hdpt": ['HDPT(?:|Web)'],
|
||||
"hdsky": ['HDS(?:|ky|TV|Pad|WEB)', 'AQLJ'],
|
||||
"hdfans": ['beAst(?:TV|)'],
|
||||
"hdhome": ['HDH(?:ome|Pad|TV|WEB|)'],
|
||||
"hdpt": ['HDPT(?:Web|)'],
|
||||
"hdsky": ['HDS(?:ky|TV|Pad|WEB|)', 'AQLJ'],
|
||||
"hdtime": [],
|
||||
"HDU": [],
|
||||
"hdvideo": [],
|
||||
"hdzone": ['HDZ(?:|one)'],
|
||||
"hdzone": ['HDZ(?:one|)'],
|
||||
"hhanclub": ['HHWEB'],
|
||||
"hitpt": [],
|
||||
"htpt": ['HTPT'],
|
||||
@@ -48,38 +47,39 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
|
||||
"joyhd": [],
|
||||
"keepfrds": ['FRDS', 'Yumi', 'cXcY'],
|
||||
"lemonhd": ['L(?:eague(?:(?:C|H)D|(?:M|T)V|NF|WEB)|HD)', 'i18n', 'CiNT'],
|
||||
"mteam": ['MTeam(?:|TV)', 'MPAD'],
|
||||
"mteam": ['MTeam(?:TV|)', 'MPAD', 'MWeb'],
|
||||
"nanyangpt": [],
|
||||
"nicept": [],
|
||||
"oshen": [],
|
||||
"ourbits": ['Our(?:Bits|TV)', 'FLTTH', 'Ao', 'PbK', 'MGs', 'iLove(?:HD|TV)'],
|
||||
"piggo": ['PiGo(?:NF|(?:H|WE)B)'],
|
||||
"ptchina": [],
|
||||
"pterclub": ['PTer(?:|DIY|Game|(?:M|T)V|WEB)'],
|
||||
"pthome": ['PTH(?:|Audio|eBook|music|ome|tv|WEB)'],
|
||||
"pterclub": ['PTer(?:DIY|Game|(?:M|T)V|WEB|)'],
|
||||
"pthome": ['PTH(?:Audio|eBook|music|ome|tv|WEB|)'],
|
||||
"ptmsg": [],
|
||||
"ptsbao": ['PTsbao', 'OPS', 'F(?:Fans(?:AIeNcE|BD|D(?:VD|IY)|TV|WEB)|HDMv)', 'SGXT'],
|
||||
"pttime": [],
|
||||
"putao": ['PuTao'],
|
||||
"soulvoice": [],
|
||||
"springsunday": ['CMCT(?:|V)'],
|
||||
"sharkpt": ['Shark(?:|WEB|DIY|TV|MV)'],
|
||||
"springsunday": ['CMCT(?:V|)'],
|
||||
"sharkpt": ['Shark(?:WEB|DIY|TV|MV|)'],
|
||||
"tccf": [],
|
||||
"tjupt": ['TJUPT'],
|
||||
"totheglory": ['TTG', 'WiKi', 'NGB', 'DoA', '(?:ARi|ExRE)N'],
|
||||
"U2": [],
|
||||
"ultrahd": [],
|
||||
"others": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:|yG)',
|
||||
"others": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:yG|)',
|
||||
'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )'],
|
||||
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', 'SweetSub', 'MingY',
|
||||
'(?:Lilith|NC)-Raws', '织梦字幕组', '枫叶字幕组', '猎户手抄部', '喵萌奶茶屋', '漫猫字幕社',
|
||||
'霜庭云花Sub', '北宇治字幕组', '氢气烤肉架', '云歌字幕组', '萌樱字幕组', '极影字幕社',
|
||||
'悠哈璃羽字幕社',
|
||||
'❀拨雪寻春❀', '沸羊羊(?:制作|字幕组)', '(?:桜|樱)都字幕组']
|
||||
'❀拨雪寻春❀', '沸羊羊(?:制作|字幕组)', '(?:桜|樱)都字幕组'],
|
||||
"forge": ['FROG(?:E|Web|)'],
|
||||
"ubits": ['UB(?:its|WEB|TV)'],
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
release_groups = []
|
||||
for site_groups in self.RELEASE_GROUPS.values():
|
||||
for release_group in site_groups:
|
||||
@@ -96,17 +96,20 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
|
||||
return ""
|
||||
if not groups:
|
||||
# 自定义组
|
||||
custom_release_groups = self.systemconfig.get(SystemConfigKey.CustomReleaseGroups)
|
||||
custom_release_groups = SystemConfigOper().get(SystemConfigKey.CustomReleaseGroups)
|
||||
if isinstance(custom_release_groups, list):
|
||||
custom_release_groups = list(filter(None, custom_release_groups))
|
||||
if custom_release_groups:
|
||||
custom_release_groups_str = '|'.join(custom_release_groups)
|
||||
groups = f"{self.__release_groups}|{custom_release_groups_str}"
|
||||
else:
|
||||
groups = self.__release_groups
|
||||
title = f"{title} "
|
||||
groups_re = re.compile(r"(?<=[-@\[£【&])(?:%s)(?=[@.\s\]\[】&])" % groups, re.I)
|
||||
# 处理一个制作组识别多次的情况,保留顺序
|
||||
groups_re = re.compile(r"(?<=[-@\[£【&])(?:(?:%s))(?=[@.\s\S\]\[】&])" % groups, re.I)
|
||||
unique_groups = []
|
||||
for item in re.findall(groups_re, title):
|
||||
if item not in unique_groups:
|
||||
unique_groups.append(item)
|
||||
item_str = item[0] if isinstance(item, tuple) else item
|
||||
if item_str not in unique_groups:
|
||||
unique_groups.append(item_str)
|
||||
|
||||
return "@".join(unique_groups)
|
||||
|
||||
314
app/core/meta/streamingplatform.py
Normal file
314
app/core/meta/streamingplatform.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class StreamingPlatforms(metaclass=Singleton):
|
||||
"""
|
||||
流媒体平台简称与全称。
|
||||
"""
|
||||
STREAMING_PLATFORMS: List[Tuple[str, str]] = [
|
||||
("AMZN", "Amazon"),
|
||||
("NF", "Netflix"),
|
||||
("ATVP", "Apple TV+"),
|
||||
("iT", "iTunes"),
|
||||
("DSNP", "Disney+"),
|
||||
("HS", "Hotstar"),
|
||||
("APPS", "Disney+ MENA"),
|
||||
("PMTP", "Paramount+"),
|
||||
("HMAX", "Max"),
|
||||
("", "Max"),
|
||||
("HULU", "Hulu Networks"),
|
||||
("MA", "Movies Anywhere"),
|
||||
("BCORE", "Bravia Core"),
|
||||
("MS", "Microsoft Store"),
|
||||
("SHO", "Showtime"),
|
||||
("STAN", "Stan"),
|
||||
("PCOK", "Peacock"),
|
||||
("SKST", "SkyShowtime"),
|
||||
("NOW", "Now"),
|
||||
("FXTL", "Foxtel Now"),
|
||||
("BNGE", "Binge"),
|
||||
("CRKL", "Crackle"),
|
||||
("RKTN", "Rakuten TV"),
|
||||
("ALL4", "Channel 4"),
|
||||
("AS", "Adult Swim"),
|
||||
("BRTB", "Brtb TV"),
|
||||
("CNLP", "Canal+"),
|
||||
("CRIT", "Criterion Channel"),
|
||||
("DSCP", "Discovery+"),
|
||||
("FOOD", "Food Network"),
|
||||
("MUBI", "Mubi"),
|
||||
("PLAY", "Google Play"),
|
||||
("YT", "YouTube"),
|
||||
("", "friDay"),
|
||||
("", "KKTV"),
|
||||
("", "ofiii"),
|
||||
("", "LiTV"),
|
||||
("", "MyVideo"),
|
||||
("Hami", "Hami Video"),
|
||||
("HamiVideo", "Hami Video"),
|
||||
("MW", "meWATCH"),
|
||||
("CATCHPLAY", "CATCHPLAY+"),
|
||||
("CPP", "CATCHPLAY+"),
|
||||
("LINETV", "LINE TV"),
|
||||
("VIU", "Viu"),
|
||||
("IQ", ""),
|
||||
("", "WeTV"),
|
||||
("ABMA", "Abema"),
|
||||
("ADN", ""),
|
||||
("AT-X", ""),
|
||||
("Baha", ""),
|
||||
("BG", "B-Global"),
|
||||
("CR", "Crunchyroll"),
|
||||
("", "DMM"),
|
||||
("FOD", ""),
|
||||
("FUNi", "Funimation"),
|
||||
("HIDI", "HIDIVE"),
|
||||
("UNXT", "U-NEXT"),
|
||||
("FAA", "Filmarchiv Austria"),
|
||||
("CC", "Comedy Central"),
|
||||
("iP", "BBC iPlayer"),
|
||||
("9NOW", "9Now"),
|
||||
("ABC", ""),
|
||||
("", "AMC"),
|
||||
("", "ZEE5"),
|
||||
("", "WAVO"),
|
||||
("SHAHID", "Shahid"),
|
||||
("Flixole", "FlixOlé"),
|
||||
("TOU", "Ici TOU.TV"),
|
||||
("ROKU", "Roku"),
|
||||
("KNPY", "Kanopy"),
|
||||
("SNXT", "Sun NXT"),
|
||||
("CUR", "Curiosity Stream"),
|
||||
("MY5", "Channel 5"),
|
||||
("AHA", "aha"),
|
||||
("WOWP", "WOW Presents Plus"),
|
||||
("JC", "JioCinema"),
|
||||
("", "Dekkoo"),
|
||||
("FILMZIE", "Filmzie"),
|
||||
("HoiChoi", "Hoichoi"),
|
||||
("VIKI", "Rakuten Viki"),
|
||||
("SF", "SF Anytime"),
|
||||
("PLEX", "Plex"),
|
||||
("SHDR", "Shudder"),
|
||||
("CRAV", "Crave"),
|
||||
("CPE", "Cineplex Entertainment"),
|
||||
("JF HC", ""),
|
||||
("JF", ""),
|
||||
("JFFP", ""),
|
||||
("VIAP", "Viaplay"),
|
||||
("TUBI", "TubiTV"),
|
||||
("", "PBS"),
|
||||
("PBSK", "PBS KIDS"),
|
||||
("LGP", "Lionsgate Play"),
|
||||
("", "CTV"),
|
||||
("", "Cineverse"),
|
||||
("LN", "Love Nature"),
|
||||
("MP", "Movistar Plus+"),
|
||||
("RUNTIME", "Runtime"),
|
||||
("STZ", "STARZ"),
|
||||
("FUBO", "fuboTV"),
|
||||
("TENK", "Tënk"),
|
||||
("KNOW", "Knowledge Network"),
|
||||
("TVO", "tvo"),
|
||||
("", "OVID"),
|
||||
("CBC", "CBC Gem"),
|
||||
("FANDOR", "fandor"),
|
||||
("CW", "The CW"),
|
||||
("KNPY", "Kanopy"),
|
||||
("FREE", "Freeform"),
|
||||
("AE", "A&E"),
|
||||
("LIFE", "Lifetime"),
|
||||
("WWEN", "WWE Network"),
|
||||
("CMAX", "Cinemax"),
|
||||
("HLMK", "Hallmark"),
|
||||
("BYU", "BYUtv"),
|
||||
("", "ViX"),
|
||||
("VICE", "Viceland"),
|
||||
("", "TVING"),
|
||||
("USAN", "USA Network"),
|
||||
("FOX", ""),
|
||||
("", "TCM"),
|
||||
("BRAV", "BravoTV"),
|
||||
("", "TNT"),
|
||||
("", "ZDF"),
|
||||
("", "IndieFlix"),
|
||||
("", "TLC"),
|
||||
("", "HGTV"),
|
||||
("ANPL", "Animal Planet"),
|
||||
("TRVL", "Travel Channel"),
|
||||
("", "VH1"),
|
||||
("SAINA", "Saina Play"),
|
||||
("SP", "Saina Play"),
|
||||
("OXGN", "Oxygen"),
|
||||
("PSN", "PlayStation Network"),
|
||||
("PMNT", "Paramount Network"),
|
||||
("FAWESOME", "Fawesome"),
|
||||
("KLASSIKI", "Klassiki"),
|
||||
("STRP", "Star+"),
|
||||
("NATG", "National Geographic"),
|
||||
("REVEEL", "Reveel"),
|
||||
("FYI", "FYI Network"),
|
||||
("WatchiT", "WATCH IT"),
|
||||
("ITVX", "ITV"),
|
||||
("GAIA", "Gaia"),
|
||||
("", "FlixLatino"),
|
||||
("CNNP", "CNN+"),
|
||||
("TROMA", "Troma"),
|
||||
("IVI", "Ivi"),
|
||||
("9NOW", "9Now"),
|
||||
("A3P", "Atresplayer"),
|
||||
("7PLUS", "7plus"),
|
||||
("", "SBS"),
|
||||
("TEN", "10Play"),
|
||||
("AUBC", ""),
|
||||
("DSNY", "Disney Networks"),
|
||||
("OSN", "OSN+"),
|
||||
("SVT", "Sveriges Television"),
|
||||
("LACINETEK", "LaCinetek"),
|
||||
("", "Maxdome"),
|
||||
("RTL", "RTL+"),
|
||||
("ARTE", "Arte"),
|
||||
("JOYN", "Joyn"),
|
||||
("TV2", "TV 2"),
|
||||
("3SAT", "3sat"),
|
||||
("FILMINGO", "filmingo"),
|
||||
("", "WOW"),
|
||||
("OKKO", "Okko"),
|
||||
("", "Go3"),
|
||||
("ARGP", "Argo"),
|
||||
("VOYO", "Voyo"),
|
||||
("VMAX", "vivamax"),
|
||||
("FILMIN", "Filmin"),
|
||||
("", "Mitele"),
|
||||
("MY5", "Channel 5"),
|
||||
("", "ARD"),
|
||||
("BK", "Bentkey"),
|
||||
("BOOM", "Boomerang"),
|
||||
("", "CBS"),
|
||||
("CLBI", "Club illico"),
|
||||
("CMOR", "C More"),
|
||||
("CMT", ""),
|
||||
("", "CNBC"),
|
||||
("COOK", "Cooking Channel"),
|
||||
("CWS", "CW Seed"),
|
||||
("DCU", "DC Universe"),
|
||||
("DDY", "Digiturk Dilediğin Yerde"),
|
||||
("DEST", "Destination America"),
|
||||
("DISC", "Discovery Channel"),
|
||||
("DW", "DailyWire+"),
|
||||
("DLWP", "DailyWire+"),
|
||||
("DPLY", "dplay"),
|
||||
("DRPO", "Dropout"),
|
||||
("EPIX", "EPIX MGM+"),
|
||||
("ESQ", "Esquire"),
|
||||
("ETV", "E!"),
|
||||
("FBWatch", "Facebook Watch"),
|
||||
("FPT", "FPT Play"),
|
||||
("FTV", "France.tv"),
|
||||
("GLOB", "GloboSat Play"),
|
||||
("GLBO", "Globoplay"),
|
||||
("GO90", "go90"),
|
||||
("HIST", "History Channel"),
|
||||
("HPLAY", "Hungama Play"),
|
||||
("KS", "Kaleidescape"),
|
||||
("", "MBC"),
|
||||
("MMAX", "ManoramaMAX"),
|
||||
("MNBC", "MSNBC"),
|
||||
("MTOD", "Motor Trend OnDemand"),
|
||||
("NBC", ""),
|
||||
("NBLA", "Nebula"),
|
||||
("NICK", "Nickelodeon"),
|
||||
("ODK", "OnDemandKorea"),
|
||||
("POGO", "PokerGO"),
|
||||
("PUHU", "puhutv"),
|
||||
("QIBI", "Quibi"),
|
||||
("RTE", "RTÉ"),
|
||||
("SESO", "Seeso"),
|
||||
("SPIK", "Spike"),
|
||||
("SS", "Simply South"),
|
||||
("SYFY", "SyFy"),
|
||||
("TIMV", "TIMvision"),
|
||||
("TK", "Tentkotta"),
|
||||
("", "TV4"),
|
||||
("TVL", "TV Land"),
|
||||
("", "TVNZ"),
|
||||
("", "UKTV"),
|
||||
("VLCT", "Discovery Velocity"),
|
||||
("VMEO", "Vimeo"),
|
||||
("VRV", "VRV Defunct"),
|
||||
("WTCH", "Watcha"),
|
||||
("", "NowPlayer"),
|
||||
("HuluJP", "Hulu Networks"),
|
||||
("Gaga", "GagaOOLala"),
|
||||
("MyTVS", "MyTVSuper"),
|
||||
("", "BBC"),
|
||||
("CC", "Comedy Central"),
|
||||
("NowE", "Now E"),
|
||||
("WAVVE", "Wavve"),
|
||||
("SE", ""),
|
||||
("", "BritBox"),
|
||||
("AOD", "Anime on Demand"),
|
||||
("AF", ""),
|
||||
("BCH", "Bandai Channel"),
|
||||
("VMJ", "VideoMarket"),
|
||||
("LFTL", "Laftel"),
|
||||
("WAKA", "Wakanim"),
|
||||
("WAKANIM", "Wakanim"),
|
||||
("AO", "AnimeOnegai"),
|
||||
("", "Lemino"),
|
||||
("VIDIO", "Vidio"),
|
||||
("TVER", "TVer"),
|
||||
("", "MBS"),
|
||||
("LFTLNET", "Laftel"),
|
||||
("JONU", "Jonu Play"),
|
||||
("PlutoTV", "Pluto TV"),
|
||||
("AbemaTV", "Abema"),
|
||||
("", "dTV"),
|
||||
("NYMEY", "Nymey"),
|
||||
("SMNS", "SAMANSA"),
|
||||
("CTHP", "CATCHPLAY+"),
|
||||
("HBOGO", "HBO GO"),
|
||||
("HBO", "HBO"),
|
||||
("FPTP", "FPT Play"),
|
||||
("", "LOCIPO"),
|
||||
("DANT", "DANET"),
|
||||
("OV", "OceanVeil"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""初始化流媒体平台匹配器"""
|
||||
self._lookup_cache = {}
|
||||
self._build_cache()
|
||||
|
||||
def _build_cache(self) -> None:
|
||||
"""
|
||||
构建查询缓存。
|
||||
"""
|
||||
self._lookup_cache.clear()
|
||||
for short_name, full_name in self.STREAMING_PLATFORMS:
|
||||
canonical_name = full_name or short_name
|
||||
if not canonical_name:
|
||||
continue
|
||||
|
||||
aliases = {short_name, full_name}
|
||||
for alias in aliases:
|
||||
if alias:
|
||||
self._lookup_cache[alias.upper()] = canonical_name
|
||||
|
||||
def get_streaming_platform_name(self, platform_code: str) -> Optional[str]:
|
||||
"""
|
||||
根据流媒体平台简称或全称获取标准名称。
|
||||
"""
|
||||
if platform_code is None:
|
||||
return None
|
||||
return self._lookup_cache.get(platform_code.upper())
|
||||
|
||||
def is_streaming_platform(self, name: str) -> bool:
|
||||
"""
|
||||
判断给定的字符串是否为已知的流媒体平台代码或名称。
|
||||
"""
|
||||
if name is None:
|
||||
return False
|
||||
return name.upper() in self._lookup_cache
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Tuple, List
|
||||
from typing import Tuple, List, Optional
|
||||
|
||||
import regex as re
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def MetaInfo(title: str, subtitle: str = None, custom_words: List[str] = None) -> MetaBase:
|
||||
def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None) -> MetaBase:
|
||||
"""
|
||||
根据标题和副标题识别元数据
|
||||
:param title: 标题、种子名、文件名
|
||||
@@ -92,7 +92,8 @@ def is_anime(name: str) -> bool:
|
||||
return True
|
||||
if re.search(r'\s+-\s+[\dv]{1,4}\s+', name, re.IGNORECASE):
|
||||
return True
|
||||
if re.search(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,
|
||||
if re.search(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,
|
||||
re.IGNORECASE):
|
||||
return False
|
||||
if re.search(r'\[[+0-9XVPI-]+]\s*\[', name, re.IGNORECASE):
|
||||
@@ -119,44 +120,69 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
|
||||
return title, metainfo
|
||||
# 从标题中提取媒体信息 格式为{[tmdbid=xxx;type=xxx;s=xxx;e=xxx]}
|
||||
results = re.findall(r'(?<={\[)[\W\w]+(?=]})', title)
|
||||
if not results:
|
||||
return title, metainfo
|
||||
for result in results:
|
||||
# 查找tmdbid信息
|
||||
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
|
||||
if tmdbid and tmdbid[0].isdigit():
|
||||
metainfo['tmdbid'] = tmdbid[0]
|
||||
# 查找豆瓣id信息
|
||||
doubanid = re.findall(r'(?<=doubanid=)\d+', result)
|
||||
if doubanid and doubanid[0].isdigit():
|
||||
metainfo['doubanid'] = doubanid[0]
|
||||
# 查找媒体类型
|
||||
mtype = re.findall(r'(?<=type=)\w+', result)
|
||||
if mtype:
|
||||
match mtype[0]:
|
||||
case "movie":
|
||||
if results:
|
||||
for result in results:
|
||||
# 查找tmdbid信息
|
||||
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
|
||||
if tmdbid and tmdbid[0].isdigit():
|
||||
metainfo['tmdbid'] = tmdbid[0]
|
||||
# 查找豆瓣id信息
|
||||
doubanid = re.findall(r'(?<=doubanid=)\d+', result)
|
||||
if doubanid and doubanid[0].isdigit():
|
||||
metainfo['doubanid'] = doubanid[0]
|
||||
# 查找媒体类型
|
||||
mtype = re.findall(r'(?<=type=)\w+', result)
|
||||
if mtype:
|
||||
if mtype[0] == "movies":
|
||||
metainfo['type'] = MediaType.MOVIE
|
||||
case "tv":
|
||||
elif mtype[0] == "tv":
|
||||
metainfo['type'] = MediaType.TV
|
||||
case _:
|
||||
pass
|
||||
# 查找季信息
|
||||
begin_season = re.findall(r'(?<=s=)\d+', result)
|
||||
if begin_season and begin_season[0].isdigit():
|
||||
metainfo['begin_season'] = int(begin_season[0])
|
||||
end_season = re.findall(r'(?<=s=\d+-)\d+', result)
|
||||
if end_season and end_season[0].isdigit():
|
||||
metainfo['end_season'] = int(end_season[0])
|
||||
# 查找集信息
|
||||
begin_episode = re.findall(r'(?<=e=)\d+', result)
|
||||
if begin_episode and begin_episode[0].isdigit():
|
||||
metainfo['begin_episode'] = int(begin_episode[0])
|
||||
end_episode = re.findall(r'(?<=e=\d+-)\d+', result)
|
||||
if end_episode and end_episode[0].isdigit():
|
||||
metainfo['end_episode'] = int(end_episode[0])
|
||||
# 去除title中该部分
|
||||
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
|
||||
title = title.replace(f"{{[{result}]}}", '')
|
||||
# 查找季信息
|
||||
begin_season = re.findall(r'(?<=s=)\d+', result)
|
||||
if begin_season and begin_season[0].isdigit():
|
||||
metainfo['begin_season'] = int(begin_season[0])
|
||||
end_season = re.findall(r'(?<=s=\d+-)\d+', result)
|
||||
if end_season and end_season[0].isdigit():
|
||||
metainfo['end_season'] = int(end_season[0])
|
||||
# 查找集信息
|
||||
begin_episode = re.findall(r'(?<=e=)\d+', result)
|
||||
if begin_episode and begin_episode[0].isdigit():
|
||||
metainfo['begin_episode'] = int(begin_episode[0])
|
||||
end_episode = re.findall(r'(?<=e=\d+-)\d+', result)
|
||||
if end_episode and end_episode[0].isdigit():
|
||||
metainfo['end_episode'] = int(end_episode[0])
|
||||
# 去除title中该部分
|
||||
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
|
||||
title = title.replace(f"{{[{result}]}}", '')
|
||||
|
||||
# 支持Emby格式的ID标签
|
||||
# 1. [tmdbid=xxxx] 或 [tmdbid-xxxx] 格式
|
||||
tmdb_match = re.search(r'\[tmdbid[=\-](\d+)\]', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\[tmdbid[=\-](\d+)\]', '', title).strip()
|
||||
|
||||
# 2. [tmdb=xxxx] 或 [tmdb-xxxx] 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\[tmdb[=\-](\d+)\]', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\[tmdb[=\-](\d+)\]', '', title).strip()
|
||||
|
||||
# 3. {tmdbid=xxxx} 或 {tmdbid-xxxx} 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\{tmdbid[=\-](\d+)\}', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\{tmdbid[=\-](\d+)\}', '', title).strip()
|
||||
|
||||
# 4. {tmdb=xxxx} 或 {tmdb-xxxx} 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\{tmdb[=\-](\d+)\}', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\{tmdb[=\-](\d+)\}', '', title).strip()
|
||||
|
||||
# 计算季集总数
|
||||
if metainfo.get('begin_season') and metainfo.get('end_season'):
|
||||
if metainfo['begin_season'] > metainfo['end_season']:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import traceback
|
||||
from typing import Generator, Optional, Tuple, Any, Union
|
||||
from typing import Generator, Optional, Tuple, Any, Union, List
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
@@ -16,14 +16,14 @@ class ModuleManager(metaclass=Singleton):
|
||||
模块管理器
|
||||
"""
|
||||
|
||||
# 模块列表
|
||||
_modules: dict = {}
|
||||
# 运行态模块列表
|
||||
_running_modules: dict = {}
|
||||
# 子模块类型集合
|
||||
SubType = Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]
|
||||
|
||||
def __init__(self):
|
||||
# 模块列表
|
||||
self._modules: dict = {}
|
||||
# 运行态模块列表
|
||||
self._running_modules: dict = {}
|
||||
self.load_modules()
|
||||
|
||||
def load_modules(self):
|
||||
@@ -121,7 +121,7 @@ class ModuleManager(metaclass=Singleton):
|
||||
获取实现了同一方法的模块列表
|
||||
"""
|
||||
if not self._running_modules:
|
||||
return []
|
||||
return
|
||||
for _, module in self._running_modules.items():
|
||||
if hasattr(module, method) \
|
||||
and ObjectUtils.check_method(getattr(module, method)):
|
||||
@@ -132,7 +132,7 @@ class ModuleManager(metaclass=Singleton):
|
||||
获取指定类型的模块列表
|
||||
"""
|
||||
if not self._running_modules:
|
||||
return []
|
||||
return
|
||||
for _, module in self._running_modules.items():
|
||||
if hasattr(module, 'get_type') \
|
||||
and module.get_type() == module_type:
|
||||
@@ -143,7 +143,7 @@ class ModuleManager(metaclass=Singleton):
|
||||
获取指定子类型的模块
|
||||
"""
|
||||
if not self._running_modules:
|
||||
return []
|
||||
return
|
||||
for _, module in self._running_modules.items():
|
||||
if hasattr(module, 'get_subtype') \
|
||||
and module.get_subtype() == module_subtype:
|
||||
@@ -164,3 +164,9 @@ class ModuleManager(metaclass=Singleton):
|
||||
获取模块列表
|
||||
"""
|
||||
return self._modules
|
||||
|
||||
def get_module_ids(self) -> List[str]:
|
||||
"""
|
||||
获取模块id列表
|
||||
"""
|
||||
return list(self._modules.keys())
|
||||
|
||||
1216
app/core/plugin.py
1216
app/core/plugin.py
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from typing import Any, Union, Annotated, Optional
|
||||
|
||||
import jwt
|
||||
@@ -43,9 +44,9 @@ api_key_query = APIKeyQuery(name="apikey", auto_error=False, scheme_name="api_ke
|
||||
def create_access_token(
|
||||
userid: Union[str, Any],
|
||||
username: str,
|
||||
super_user: bool = False,
|
||||
super_user: Optional[bool] = False,
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
level: int = 1,
|
||||
level: Optional[int] = 1,
|
||||
purpose: Optional[str] = "authentication"
|
||||
) -> str:
|
||||
"""
|
||||
@@ -69,13 +70,13 @@ def create_access_token(
|
||||
if expires_delta is not None:
|
||||
if expires_delta.total_seconds() <= 0:
|
||||
raise ValueError("过期时间必须为正数")
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
expire = datetime.datetime.now(datetime.UTC) + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + default_expire
|
||||
expire = datetime.datetime.now(datetime.UTC) + default_expire
|
||||
|
||||
to_encode = {
|
||||
"exp": expire,
|
||||
"iat": datetime.utcnow(),
|
||||
"iat": datetime.datetime.now(datetime.UTC),
|
||||
"sub": str(userid),
|
||||
"username": username,
|
||||
"super_user": super_user,
|
||||
@@ -102,7 +103,7 @@ def __set_or_refresh_resource_token_cookie(request: Request, response: Response,
|
||||
decoded_token = jwt.decode(resource_token, settings.RESOURCE_SECRET_KEY, algorithms=[ALGORITHM])
|
||||
exp = decoded_token.get("exp")
|
||||
if exp:
|
||||
remaining_time = datetime.utcfromtimestamp(exp) - datetime.utcnow()
|
||||
remaining_time = datetime.datetime.fromtimestamp(exp, tz=datetime.UTC) - datetime.datetime.now(datetime.UTC)
|
||||
# 根据剩余时长提前刷新令牌
|
||||
if remaining_time < timedelta(seconds=(settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3)):
|
||||
raise jwt.ExpiredSignatureError
|
||||
@@ -135,7 +136,7 @@ def __set_or_refresh_resource_token_cookie(request: Request, response: Response,
|
||||
)
|
||||
|
||||
|
||||
def __verify_token(token: str, purpose: str = "authentication") -> schemas.TokenPayload:
|
||||
def __verify_token(token: str, purpose: Optional[str] = "authentication") -> schemas.TokenPayload:
|
||||
"""
|
||||
使用 JWT Token 进行身份认证并解析 Token 的内容
|
||||
:param token: JWT 令牌
|
||||
@@ -175,7 +176,7 @@ def __verify_token(token: str, purpose: str = "authentication") -> schemas.Token
|
||||
def verify_token(
|
||||
request: Request,
|
||||
response: Response,
|
||||
token: str = Security(oauth2_scheme)
|
||||
token: Annotated[str, Security(oauth2_scheme)]
|
||||
) -> schemas.TokenPayload:
|
||||
"""
|
||||
验证 JWT 令牌并自动处理 resource_token 写入
|
||||
@@ -195,7 +196,7 @@ def verify_token(
|
||||
|
||||
|
||||
def verify_resource_token(
|
||||
resource_token: str = Security(resource_token_cookie)
|
||||
resource_token: Annotated[str, Security(resource_token_cookie)]
|
||||
) -> schemas.TokenPayload:
|
||||
"""
|
||||
验证资源访问令牌(从 Cookie 中获取)
|
||||
@@ -248,22 +249,22 @@ def __verify_key(key: str, expected_key: str, key_type: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
def verify_apitoken(token: str = Security(__get_api_token)) -> str:
|
||||
def verify_apitoken(token: Annotated[str, Security(__get_api_token)]) -> str:
|
||||
"""
|
||||
使用 API Token 进行身份认证
|
||||
:param token: API Token,从 URL 查询参数中获取
|
||||
:param token: API Token,从 URL 查询参数中获取 token=xxx
|
||||
:return: 返回校验通过的 API Token
|
||||
"""
|
||||
return __verify_key(token, settings.API_TOKEN, "API_TOKEN")
|
||||
return __verify_key(token, settings.API_TOKEN, "token")
|
||||
|
||||
|
||||
def verify_apikey(apikey: str = Security(__get_api_key)) -> str:
|
||||
def verify_apikey(apikey: Annotated[str, Security(__get_api_key)]) -> str:
|
||||
"""
|
||||
使用 API Key 进行身份认证
|
||||
:param apikey: API Key,从 URL 查询参数或请求头中获取
|
||||
:param apikey: API Key,从 URL 查询参数中获取 apikey=xxx
|
||||
:return: 返回校验通过的 API Key
|
||||
"""
|
||||
return __verify_key(apikey, settings.API_TOKEN, "API_KEY")
|
||||
return __verify_key(apikey, settings.API_TOKEN, "apikey")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
|
||||
300
app/core/workflow.py
Normal file
300
app/core/workflow.py
Normal file
@@ -0,0 +1,300 @@
|
||||
import threading
|
||||
from time import sleep
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import List, Tuple
|
||||
|
||||
from app.core.config import global_vars
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.db.models import Workflow
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
from app.helper.module import ModuleHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ActionContext, Action
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class WorkFlowManager(metaclass=Singleton):
|
||||
"""
|
||||
工作流管理器
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 所有动作定义
|
||||
self._lock = threading.Lock()
|
||||
self._actions: Dict[str, Any] = {}
|
||||
self._event_workflows: Dict[str, List[int]] = {}
|
||||
self.init()
|
||||
|
||||
def init(self):
|
||||
"""
|
||||
初始化
|
||||
"""
|
||||
|
||||
def filter_func(obj: Any):
|
||||
"""
|
||||
过滤函数,确保只加载新定义的类
|
||||
"""
|
||||
if not isinstance(obj, type):
|
||||
return False
|
||||
if not hasattr(obj, 'execute') or not hasattr(obj, "name"):
|
||||
return False
|
||||
if obj.__name__ == "BaseAction":
|
||||
return False
|
||||
return obj.__module__.startswith("app.actions")
|
||||
|
||||
# 加载所有动作
|
||||
self._actions = {}
|
||||
actions = ModuleHelper.load(
|
||||
"app.actions",
|
||||
filter_func=lambda _, obj: filter_func(obj)
|
||||
)
|
||||
for action in actions:
|
||||
logger.debug(f"加载动作: {action.__name__}")
|
||||
try:
|
||||
self._actions[action.__name__] = action
|
||||
except Exception as err:
|
||||
logger.error(f"加载动作失败: {action.__name__} - {err}")
|
||||
|
||||
# 加载工作流事件触发器
|
||||
self.load_workflow_events()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
停止
|
||||
"""
|
||||
self._actions = {}
|
||||
self._event_workflows = {}
|
||||
|
||||
def excute(self, workflow_id: int, action: Action,
|
||||
context: ActionContext = None) -> Tuple[bool, str, ActionContext]:
|
||||
"""
|
||||
执行工作流动作
|
||||
"""
|
||||
if not context:
|
||||
context = ActionContext()
|
||||
if action.type in self._actions:
|
||||
# 实例化之前,清理掉类对象的数据
|
||||
|
||||
# 实例化
|
||||
action_obj = self._actions[action.type](action.id)
|
||||
# 执行
|
||||
logger.info(f"执行动作: {action.id} - {action.name}")
|
||||
try:
|
||||
result_context = action_obj.execute(workflow_id, action.data, context)
|
||||
except Exception as err:
|
||||
logger.error(f"{action.name} 执行失败: {err}")
|
||||
return False, f"{err}", context
|
||||
loop = action.data.get("loop")
|
||||
loop_interval = action.data.get("loop_interval")
|
||||
if loop and loop_interval:
|
||||
while not action_obj.done:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
# 等待
|
||||
logger.info(f"{action.name} 等待 {loop_interval} 秒后继续执行 ...")
|
||||
sleep(loop_interval)
|
||||
# 执行
|
||||
logger.info(f"继续执行动作: {action.id} - {action.name}")
|
||||
result_context = action_obj.execute(workflow_id, action.data, result_context)
|
||||
if action_obj.success:
|
||||
logger.info(f"{action.name} 执行成功")
|
||||
else:
|
||||
logger.error(f"{action.name} 执行失败!")
|
||||
return action_obj.success, action_obj.message, result_context
|
||||
else:
|
||||
logger.error(f"未找到动作: {action.type} - {action.name}")
|
||||
return False, " ", context
|
||||
|
||||
def list_actions(self) -> List[dict]:
|
||||
"""
|
||||
获取所有动作
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"type": key,
|
||||
"name": action.name,
|
||||
"description": action.description,
|
||||
"data": {
|
||||
"label": action.name,
|
||||
**action.data
|
||||
}
|
||||
} for key, action in self._actions.items()
|
||||
]
|
||||
|
||||
def update_workflow_event(self, workflow: Workflow):
|
||||
"""
|
||||
更新工作流事件触发器
|
||||
"""
|
||||
# 确保先移除旧的事件监听器
|
||||
self.remove_workflow_event(workflow_id=workflow.id, event_type_str=workflow.event_type)
|
||||
# 如果工作流是事件触发类型且未被禁用
|
||||
if workflow.trigger_type == "event" and workflow.state != 'P':
|
||||
# 注册事件触发器
|
||||
self.register_workflow_event(workflow.id, workflow.event_type)
|
||||
|
||||
def load_workflow_events(self, workflow_id: Optional[int] = None):
|
||||
"""
|
||||
加载工作流触发事件
|
||||
"""
|
||||
workflows = []
|
||||
if workflow_id:
|
||||
workflow = WorkflowOper().get(workflow_id)
|
||||
if workflow:
|
||||
workflows = [workflow]
|
||||
else:
|
||||
workflows = WorkflowOper().get_event_triggered_workflows()
|
||||
try:
|
||||
for workflow in workflows:
|
||||
self.update_workflow_event(workflow)
|
||||
except Exception as e:
|
||||
logger.error(f"加载事件触发工作流失败: {e}")
|
||||
|
||||
def register_workflow_event(self, workflow_id: int, event_type_str: str):
|
||||
"""
|
||||
注册工作流事件触发器
|
||||
"""
|
||||
try:
|
||||
event_type = EventType(event_type_str)
|
||||
except ValueError:
|
||||
logger.error(f"无效的事件类型: {event_type_str}")
|
||||
return
|
||||
if event_type in EventType:
|
||||
# 确保先移除旧的事件监听器
|
||||
self.remove_workflow_event(workflow_id, event_type.value)
|
||||
with self._lock:
|
||||
# 添加新的事件监听器
|
||||
eventmanager.add_event_listener(event_type, self._handle_event)
|
||||
# 记录工作流事件触发器
|
||||
if event_type.value not in self._event_workflows:
|
||||
self._event_workflows[event_type.value] = []
|
||||
self._event_workflows[event_type.value].append(workflow_id)
|
||||
logger.info(f"已注册工作流 {workflow_id} 事件触发器: {event_type.value}")
|
||||
|
||||
def remove_workflow_event(self, workflow_id: int, event_type_str: str):
|
||||
"""
|
||||
移除工作流事件触发器
|
||||
"""
|
||||
try:
|
||||
event_type = EventType(event_type_str)
|
||||
except ValueError:
|
||||
logger.error(f"无效的事件类型: {event_type_str}")
|
||||
return
|
||||
if event_type in EventType:
|
||||
with self._lock:
|
||||
eventmanager.remove_event_listener(event_type, self._handle_event)
|
||||
if event_type.value in self._event_workflows:
|
||||
if workflow_id in self._event_workflows[event_type.value]:
|
||||
self._event_workflows[event_type.value].remove(workflow_id)
|
||||
if not self._event_workflows[event_type.value]:
|
||||
del self._event_workflows[event_type.value]
|
||||
logger.info(f"已移除工作流 {workflow_id} 事件触发器")
|
||||
|
||||
def _handle_event(self, event: Event):
|
||||
"""
|
||||
处理事件,触发相应的工作流
|
||||
"""
|
||||
try:
|
||||
event_type_str = str(event.event_type.value)
|
||||
with self._lock:
|
||||
if event_type_str not in self._event_workflows:
|
||||
return
|
||||
workflow_ids = self._event_workflows[event_type_str].copy()
|
||||
for workflow_id in workflow_ids:
|
||||
self._trigger_workflow(workflow_id, event)
|
||||
except Exception as e:
|
||||
logger.error(f"处理工作流事件失败: {e}")
|
||||
|
||||
def _trigger_workflow(self, workflow_id: int, event: Event):
|
||||
"""
|
||||
触发工作流执行
|
||||
"""
|
||||
try:
|
||||
# 检查工作流是否存在且启用
|
||||
workflow = WorkflowOper().get(workflow_id)
|
||||
if not workflow or workflow.state == 'P':
|
||||
return
|
||||
|
||||
# 检查事件条件
|
||||
if not self._check_event_conditions(workflow, event):
|
||||
logger.debug(f"工作流 {workflow.name} 事件条件不匹配,跳过执行")
|
||||
return
|
||||
|
||||
# 检查工作流是否正在运行
|
||||
if workflow.state == 'R':
|
||||
logger.warning(f"工作流 {workflow.name} 正在运行中,跳过重复触发")
|
||||
return
|
||||
|
||||
logger.info(f"事件 {event.event_type.value} 触发工作流: {workflow.name}")
|
||||
|
||||
# 发送工作流执行事件以启动工作流
|
||||
eventmanager.send_event(EventType.WorkflowExecute, {
|
||||
"workflow_id": workflow_id,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"触发工作流 {workflow_id} 失败: {e}")
|
||||
|
||||
def _check_event_conditions(self, workflow, event: Event) -> bool:
|
||||
"""
|
||||
检查事件是否满足工作流的触发条件
|
||||
"""
|
||||
if not workflow.event_conditions:
|
||||
return True
|
||||
|
||||
conditions = workflow.event_conditions
|
||||
event_data = event.event_data or {}
|
||||
|
||||
# 检查字段匹配条件
|
||||
for field, expected_value in conditions.items():
|
||||
if field not in event_data:
|
||||
return False
|
||||
actual_value = event_data[field]
|
||||
# 支持多种条件匹配方式
|
||||
if isinstance(expected_value, dict):
|
||||
# 复杂条件匹配
|
||||
if not self._check_complex_condition(actual_value, expected_value):
|
||||
return False
|
||||
else:
|
||||
# 简单值匹配
|
||||
if actual_value != expected_value:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _check_complex_condition(actual_value: any, condition: dict) -> bool:
|
||||
"""
|
||||
检查复杂条件匹配
|
||||
支持的操作符:equals, not_equals, contains, not_contains, in, not_in, regex
|
||||
"""
|
||||
for operator, expected_value in condition.items():
|
||||
if operator == "equals":
|
||||
if actual_value != expected_value:
|
||||
return False
|
||||
elif operator == "not_equals":
|
||||
if actual_value == expected_value:
|
||||
return False
|
||||
elif operator == "contains":
|
||||
if expected_value not in str(actual_value):
|
||||
return False
|
||||
elif operator == "not_contains":
|
||||
if expected_value in str(actual_value):
|
||||
return False
|
||||
elif operator == "in":
|
||||
if actual_value not in expected_value:
|
||||
return False
|
||||
elif operator == "not_in":
|
||||
if actual_value in expected_value:
|
||||
return False
|
||||
elif operator == "regex":
|
||||
import re
|
||||
if not re.search(expected_value, str(actual_value)):
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_event_workflows(self) -> dict:
|
||||
"""
|
||||
获取所有事件触发的工作流
|
||||
"""
|
||||
with self._lock:
|
||||
return self._event_workflows.copy()
|
||||
@@ -1,45 +1,191 @@
|
||||
from typing import Any, Generator, List, Optional, Self, Tuple
|
||||
import asyncio
|
||||
from typing import Any, Generator, List, Optional, Self, Tuple, AsyncGenerator, Union
|
||||
|
||||
from sqlalchemy import NullPool, QueuePool, and_, create_engine, inspect, text
|
||||
from sqlalchemy import NullPool, QueuePool, and_, create_engine, inspect, text, select, delete, Column, Integer, \
|
||||
Sequence, Identity
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import Session, as_declarative, declared_attr, scoped_session, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# 根据池类型设置 poolclass 和相关参数
|
||||
pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool
|
||||
connect_args = {
|
||||
"timeout": settings.DB_TIMEOUT
|
||||
}
|
||||
# 启用 WAL 模式时的额外配置
|
||||
if settings.DB_WAL_ENABLE:
|
||||
connect_args["check_same_thread"] = False
|
||||
db_kwargs = {
|
||||
"url": f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
||||
"pool_pre_ping": settings.DB_POOL_PRE_PING,
|
||||
"echo": settings.DB_ECHO,
|
||||
"poolclass": pool_class,
|
||||
"pool_recycle": settings.DB_POOL_RECYCLE,
|
||||
"connect_args": connect_args
|
||||
}
|
||||
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
|
||||
if pool_class == QueuePool:
|
||||
db_kwargs.update({
|
||||
"pool_size": settings.DB_POOL_SIZE,
|
||||
"pool_timeout": settings.DB_POOL_TIMEOUT,
|
||||
"max_overflow": settings.DB_MAX_OVERFLOW
|
||||
})
|
||||
# 创建数据库引擎
|
||||
Engine = create_engine(**db_kwargs)
|
||||
# 根据配置设置日志模式
|
||||
journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
|
||||
with Engine.connect() as connection:
|
||||
current_mode = connection.execute(text(f"PRAGMA journal_mode={journal_mode};")).scalar()
|
||||
print(f"Database journal mode set to: {current_mode}")
|
||||
|
||||
# 会话工厂
|
||||
def get_id_column():
|
||||
"""
|
||||
根据数据库类型返回合适的ID列定义
|
||||
"""
|
||||
if settings.DB_TYPE.lower() == "postgresql":
|
||||
# PostgreSQL使用SERIAL类型,让数据库自动处理序列
|
||||
return Column(Integer, Identity(start=1, cycle=True), primary_key=True, index=True)
|
||||
else:
|
||||
# SQLite使用Sequence
|
||||
return Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
|
||||
|
||||
def _get_database_engine(is_async: bool = False):
|
||||
"""
|
||||
获取数据库连接参数并设置WAL模式
|
||||
:param is_async: 是否创建异步引擎,True - 异步引擎, False - 同步引擎
|
||||
:return: 返回对应的数据库引擎
|
||||
"""
|
||||
# 根据数据库类型选择连接方式
|
||||
if settings.DB_TYPE.lower() == "postgresql":
|
||||
return _get_postgresql_engine(is_async)
|
||||
else:
|
||||
return _get_sqlite_engine(is_async)
|
||||
|
||||
|
||||
def _get_sqlite_engine(is_async: bool = False):
|
||||
"""
|
||||
获取SQLite数据库引擎
|
||||
"""
|
||||
# 连接参数
|
||||
_connect_args = {
|
||||
"timeout": settings.DB_TIMEOUT,
|
||||
}
|
||||
# 启用 WAL 模式时的额外配置
|
||||
if settings.DB_WAL_ENABLE:
|
||||
_connect_args["check_same_thread"] = False
|
||||
|
||||
# 创建同步引擎
|
||||
if not is_async:
|
||||
# 根据池类型设置 poolclass 和相关参数
|
||||
_pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool
|
||||
|
||||
# 数据库参数
|
||||
_db_kwargs = {
|
||||
"url": f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
||||
"pool_pre_ping": settings.DB_POOL_PRE_PING,
|
||||
"echo": settings.DB_ECHO,
|
||||
"poolclass": _pool_class,
|
||||
"pool_recycle": settings.DB_POOL_RECYCLE,
|
||||
"connect_args": _connect_args
|
||||
}
|
||||
|
||||
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
|
||||
if _pool_class == QueuePool:
|
||||
_db_kwargs.update({
|
||||
"pool_size": settings.DB_SQLITE_POOL_SIZE,
|
||||
"pool_timeout": settings.DB_POOL_TIMEOUT,
|
||||
"max_overflow": settings.DB_SQLITE_MAX_OVERFLOW
|
||||
})
|
||||
|
||||
# 创建数据库引擎
|
||||
engine = create_engine(**_db_kwargs)
|
||||
|
||||
# 设置WAL模式
|
||||
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
|
||||
with engine.connect() as connection:
|
||||
current_mode = connection.execute(text(f"PRAGMA journal_mode={_journal_mode};")).scalar()
|
||||
print(f"SQLite database journal mode set to: {current_mode}")
|
||||
|
||||
return engine
|
||||
else:
|
||||
# 数据库参数,只能使用 NullPool
|
||||
_db_kwargs = {
|
||||
"url": f"sqlite+aiosqlite:///{settings.CONFIG_PATH}/user.db",
|
||||
"pool_pre_ping": settings.DB_POOL_PRE_PING,
|
||||
"echo": settings.DB_ECHO,
|
||||
"poolclass": NullPool,
|
||||
"pool_recycle": settings.DB_POOL_RECYCLE,
|
||||
"connect_args": _connect_args
|
||||
}
|
||||
# 创建异步数据库引擎
|
||||
async_engine = create_async_engine(**_db_kwargs)
|
||||
|
||||
# 设置WAL模式
|
||||
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
|
||||
|
||||
async def set_async_wal_mode():
|
||||
"""
|
||||
设置异步引擎的WAL模式
|
||||
"""
|
||||
async with async_engine.connect() as _connection:
|
||||
result = await _connection.execute(text(f"PRAGMA journal_mode={_journal_mode};"))
|
||||
_current_mode = result.scalar()
|
||||
print(f"Async SQLite database journal mode set to: {_current_mode}")
|
||||
|
||||
try:
|
||||
asyncio.run(set_async_wal_mode())
|
||||
except Exception as e:
|
||||
print(f"Failed to set async SQLite WAL mode: {e}")
|
||||
|
||||
return async_engine
|
||||
|
||||
|
||||
def _get_postgresql_engine(is_async: bool = False):
|
||||
"""
|
||||
获取PostgreSQL数据库引擎
|
||||
"""
|
||||
# 构建PostgreSQL连接URL
|
||||
if settings.DB_POSTGRESQL_PASSWORD:
|
||||
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
|
||||
else:
|
||||
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
|
||||
|
||||
# PostgreSQL连接参数
|
||||
_connect_args = {}
|
||||
|
||||
# 创建同步引擎
|
||||
if not is_async:
|
||||
# 根据池类型设置 poolclass 和相关参数
|
||||
_pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool
|
||||
|
||||
# 数据库参数
|
||||
_db_kwargs = {
|
||||
"url": db_url,
|
||||
"pool_pre_ping": settings.DB_POOL_PRE_PING,
|
||||
"echo": settings.DB_ECHO,
|
||||
"poolclass": _pool_class,
|
||||
"pool_recycle": settings.DB_POOL_RECYCLE,
|
||||
"connect_args": _connect_args
|
||||
}
|
||||
|
||||
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
|
||||
if _pool_class == QueuePool:
|
||||
_db_kwargs.update({
|
||||
"pool_size": settings.DB_POSTGRESQL_POOL_SIZE,
|
||||
"pool_timeout": settings.DB_POOL_TIMEOUT,
|
||||
"max_overflow": settings.DB_POSTGRESQL_MAX_OVERFLOW
|
||||
})
|
||||
|
||||
# 创建数据库引擎
|
||||
engine = create_engine(**_db_kwargs)
|
||||
print(f"PostgreSQL database connected to {settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}")
|
||||
|
||||
return engine
|
||||
else:
|
||||
# 构建异步PostgreSQL连接URL
|
||||
async_db_url = f"postgresql+asyncpg://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
|
||||
|
||||
# 数据库参数,只能使用 NullPool
|
||||
_db_kwargs = {
|
||||
"url": async_db_url,
|
||||
"pool_pre_ping": settings.DB_POOL_PRE_PING,
|
||||
"echo": settings.DB_ECHO,
|
||||
"poolclass": NullPool,
|
||||
"pool_recycle": settings.DB_POOL_RECYCLE,
|
||||
"connect_args": _connect_args
|
||||
}
|
||||
# 创建异步数据库引擎
|
||||
async_engine = create_async_engine(**_db_kwargs)
|
||||
print(f"Async PostgreSQL database connected to {settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}")
|
||||
|
||||
return async_engine
|
||||
|
||||
|
||||
# 同步数据库引擎
|
||||
Engine = _get_database_engine(is_async=False)
|
||||
|
||||
# 异步数据库引擎
|
||||
AsyncEngine = _get_database_engine(is_async=True)
|
||||
|
||||
# 同步会话工厂
|
||||
SessionFactory = sessionmaker(bind=Engine)
|
||||
|
||||
# 多线程全局使用的数据库会话
|
||||
# 异步会话工厂
|
||||
AsyncSessionFactory = async_sessionmaker(bind=AsyncEngine, class_=AsyncSession)
|
||||
|
||||
# 同步多线程全局使用的数据库会话
|
||||
ScopedSession = scoped_session(SessionFactory)
|
||||
|
||||
|
||||
@@ -57,37 +203,32 @@ def get_db() -> Generator:
|
||||
db.close()
|
||||
|
||||
|
||||
def perform_checkpoint(mode: str = "PASSIVE"):
|
||||
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
执行 SQLite 的 checkpoint 操作,将 WAL 文件内容写回主数据库
|
||||
:param mode: checkpoint 模式,可选值包括 "PASSIVE"、"FULL"、"RESTART"、"TRUNCATE"
|
||||
默认为 "PASSIVE",即不锁定 WAL 文件的轻量级同步
|
||||
获取异步数据库会话,用于WEB请求
|
||||
:return: AsyncSession
|
||||
"""
|
||||
if not settings.DB_WAL_ENABLE:
|
||||
return
|
||||
valid_modes = {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}
|
||||
if mode.upper() not in valid_modes:
|
||||
raise ValueError(f"Invalid checkpoint mode '{mode}'. Must be one of {valid_modes}")
|
||||
try:
|
||||
# 使用指定的 checkpoint 模式,确保 WAL 文件数据被正确写回主数据库
|
||||
with Engine.connect() as conn:
|
||||
conn.execute(text(f"PRAGMA wal_checkpoint({mode.upper()});"))
|
||||
except Exception as e:
|
||||
print(f"Error during WAL checkpoint: {e}")
|
||||
async with AsyncSessionFactory() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
def close_database():
|
||||
async def close_database():
|
||||
"""
|
||||
关闭所有数据库连接并清理资源
|
||||
"""
|
||||
try:
|
||||
# 释放连接池,SQLite 会自动清空 WAL 文件,这里不单独再调用 checkpoint
|
||||
Engine.dispose()
|
||||
except Exception as e:
|
||||
print(f"Error while disposing database connections: {e}")
|
||||
# 释放同步连接池
|
||||
Engine.dispose() # noqa
|
||||
# 释放异步连接池
|
||||
await AsyncEngine.dispose()
|
||||
except Exception as err:
|
||||
print(f"Error while disposing database connections: {err}")
|
||||
|
||||
|
||||
def get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
|
||||
def _get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
|
||||
"""
|
||||
从参数中获取数据库Session对象
|
||||
"""
|
||||
@@ -105,7 +246,25 @@ def get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
|
||||
return db
|
||||
|
||||
|
||||
def update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]:
|
||||
def _get_args_async_db(args: tuple, kwargs: dict) -> Optional[AsyncSession]:
|
||||
"""
|
||||
从参数中获取异步数据库AsyncSession对象
|
||||
"""
|
||||
db = None
|
||||
if args:
|
||||
for arg in args:
|
||||
if isinstance(arg, AsyncSession):
|
||||
db = arg
|
||||
break
|
||||
if kwargs:
|
||||
for key, value in kwargs.items():
|
||||
if isinstance(value, AsyncSession):
|
||||
db = value
|
||||
break
|
||||
return db
|
||||
|
||||
|
||||
def _update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]:
|
||||
"""
|
||||
更新参数中的数据库Session对象,关键字传参时更新db的值,否则更新第1或第2个参数
|
||||
"""
|
||||
@@ -119,6 +278,20 @@ def update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]
|
||||
return args, kwargs
|
||||
|
||||
|
||||
def _update_args_async_db(args: tuple, kwargs: dict, db: AsyncSession) -> Tuple[tuple, dict]:
|
||||
"""
|
||||
更新参数中的异步数据库AsyncSession对象,关键字传参时更新db的值,否则更新第1或第2个参数
|
||||
"""
|
||||
if kwargs and 'db' in kwargs:
|
||||
kwargs['db'] = db
|
||||
elif args:
|
||||
if args[0] is None:
|
||||
args = (db, *args[1:])
|
||||
else:
|
||||
args = (args[0], db, *args[2:])
|
||||
return args, kwargs
|
||||
|
||||
|
||||
def db_update(func):
|
||||
"""
|
||||
数据库更新类操作装饰器,第一个参数必须是数据库会话或存在db参数
|
||||
@@ -128,14 +301,14 @@ def db_update(func):
|
||||
# 是否关闭数据库会话
|
||||
_close_db = False
|
||||
# 从参数中获取数据库会话
|
||||
db = get_args_db(args, kwargs)
|
||||
db = _get_args_db(args, kwargs)
|
||||
if not db:
|
||||
# 如果没有获取到数据库会话,创建一个
|
||||
db = ScopedSession()
|
||||
# 标记需要关闭数据库会话
|
||||
_close_db = True
|
||||
# 更新参数中的数据库会话
|
||||
args, kwargs = update_args_db(args, kwargs, db)
|
||||
args, kwargs = _update_args_db(args, kwargs, db)
|
||||
try:
|
||||
# 执行函数
|
||||
result = func(*args, **kwargs)
|
||||
@@ -154,6 +327,41 @@ def db_update(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
def async_db_update(func):
|
||||
"""
|
||||
异步数据库更新类操作装饰器,第一个参数必须是异步数据库会话或存在db参数
|
||||
"""
|
||||
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 是否关闭数据库会话
|
||||
_close_db = False
|
||||
# 从参数中获取异步数据库会话
|
||||
db = _get_args_async_db(args, kwargs)
|
||||
if not db:
|
||||
# 如果没有获取到异步数据库会话,创建一个
|
||||
db = AsyncSessionFactory()
|
||||
# 标记需要关闭数据库会话
|
||||
_close_db = True
|
||||
# 更新参数中的异步数据库会话
|
||||
args, kwargs = _update_args_async_db(args, kwargs, db)
|
||||
try:
|
||||
# 执行函数
|
||||
result = await func(*args, **kwargs)
|
||||
# 提交事务
|
||||
await db.commit()
|
||||
except Exception as err:
|
||||
# 回滚事务
|
||||
await db.rollback()
|
||||
raise err
|
||||
finally:
|
||||
# 关闭数据库会话
|
||||
if _close_db:
|
||||
await db.close()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def db_query(func):
|
||||
"""
|
||||
数据库查询操作装饰器,第一个参数必须是数据库会话或存在db参数
|
||||
@@ -164,14 +372,14 @@ def db_query(func):
|
||||
# 是否关闭数据库会话
|
||||
_close_db = False
|
||||
# 从参数中获取数据库会话
|
||||
db = get_args_db(args, kwargs)
|
||||
db = _get_args_db(args, kwargs)
|
||||
if not db:
|
||||
# 如果没有获取到数据库会话,创建一个
|
||||
db = ScopedSession()
|
||||
# 标记需要关闭数据库会话
|
||||
_close_db = True
|
||||
# 更新参数中的数据库会话
|
||||
args, kwargs = update_args_db(args, kwargs, db)
|
||||
args, kwargs = _update_args_db(args, kwargs, db)
|
||||
try:
|
||||
# 执行函数
|
||||
result = func(*args, **kwargs)
|
||||
@@ -186,6 +394,38 @@ def db_query(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
def async_db_query(func):
|
||||
"""
|
||||
异步数据库查询操作装饰器,第一个参数必须是异步数据库会话或存在db参数
|
||||
注意:db.query列表数据时,需要转换为list返回
|
||||
"""
|
||||
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 是否关闭数据库会话
|
||||
_close_db = False
|
||||
# 从参数中获取异步数据库会话
|
||||
db = _get_args_async_db(args, kwargs)
|
||||
if not db:
|
||||
# 如果没有获取到异步数据库会话,创建一个
|
||||
db = AsyncSessionFactory()
|
||||
# 标记需要关闭数据库会话
|
||||
_close_db = True
|
||||
# 更新参数中的异步数据库会话
|
||||
args, kwargs = _update_args_async_db(args, kwargs, db)
|
||||
try:
|
||||
# 执行函数
|
||||
result = await func(*args, **kwargs)
|
||||
except Exception as err:
|
||||
raise err
|
||||
finally:
|
||||
# 关闭数据库会话
|
||||
if _close_db:
|
||||
await db.close()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@as_declarative()
|
||||
class Base:
|
||||
id: Any
|
||||
@@ -195,11 +435,23 @@ class Base:
|
||||
def create(self, db: Session):
|
||||
db.add(self)
|
||||
|
||||
@async_db_update
|
||||
async def async_create(self, db: AsyncSession):
|
||||
db.add(self)
|
||||
await db.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get(cls, db: Session, rid: int) -> Self:
|
||||
return db.query(cls).filter(and_(cls.id == rid)).first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get(cls, db: AsyncSession, rid: int) -> Self:
|
||||
result = await db.execute(select(cls).where(and_(cls.id == rid)))
|
||||
return result.scalars().first()
|
||||
|
||||
@db_update
|
||||
def update(self, db: Session, payload: dict):
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
@@ -208,24 +460,50 @@ class Base:
|
||||
if inspect(self).detached:
|
||||
db.add(self)
|
||||
|
||||
@async_db_update
|
||||
async def async_update(self, db: AsyncSession, 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(and_(cls.id == rid)).delete()
|
||||
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_delete(cls, db: AsyncSession, rid):
|
||||
result = await db.execute(select(cls).where(and_(cls.id == rid)))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
await db.delete(user)
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def truncate(cls, db: Session):
|
||||
db.query(cls).delete()
|
||||
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_truncate(cls, db: AsyncSession):
|
||||
await db.execute(delete(cls))
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list(cls, db: Session) -> List[Self]:
|
||||
result = db.query(cls).all()
|
||||
return list(result)
|
||||
return db.query(cls).all()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list(cls, db: AsyncSession) -> Sequence[Self]:
|
||||
result = await db.execute(select(cls))
|
||||
return result.scalars().all()
|
||||
|
||||
def to_dict(self):
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa
|
||||
|
||||
@declared_attr
|
||||
def __tablename__(self) -> str:
|
||||
@@ -236,7 +514,6 @@ class DbOper:
|
||||
"""
|
||||
数据库操作基类
|
||||
"""
|
||||
_db: Session = None
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
def __init__(self, db: Union[Session, AsyncSession] = None):
|
||||
self._db = db
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
|
||||
@@ -51,7 +51,7 @@ class DownloadHistoryOper(DbOper):
|
||||
"""
|
||||
DownloadFiles.truncate(self._db)
|
||||
|
||||
def get_files_by_hash(self, download_hash: str, state: int = None) -> List[DownloadFiles]:
|
||||
def get_files_by_hash(self, download_hash: str, state: Optional[int] = None) -> List[DownloadFiles]:
|
||||
"""
|
||||
按Hash查询下载文件记录
|
||||
:param download_hash: 数据key
|
||||
@@ -97,7 +97,7 @@ class DownloadHistoryOper(DbOper):
|
||||
return fileinfo.download_hash
|
||||
return ""
|
||||
|
||||
def list_by_page(self, page: int = 1, count: int = 30) -> List[DownloadHistory]:
|
||||
def list_by_page(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[DownloadHistory]:
|
||||
"""
|
||||
分页查询下载历史
|
||||
"""
|
||||
@@ -109,10 +109,11 @@ class DownloadHistoryOper(DbOper):
|
||||
"""
|
||||
DownloadHistory.truncate(self._db)
|
||||
|
||||
def get_last_by(self, mtype=None, title: str = None, year: str = None,
|
||||
season: str = None, episode: str = None, tmdbid=None) -> List[DownloadHistory]:
|
||||
def get_last_by(self, mtype=None, title: Optional[str] = None, year: Optional[str] = None,
|
||||
season: Optional[str] = None, episode: Optional[str] = None, tmdbid=None) -> List[DownloadHistory]:
|
||||
"""
|
||||
按类型、标题、年份、季集查询下载记录
|
||||
tmdbid + mtype 或 title + year
|
||||
"""
|
||||
return DownloadHistory.get_last_by(db=self._db,
|
||||
mtype=mtype,
|
||||
@@ -122,7 +123,7 @@ class DownloadHistoryOper(DbOper):
|
||||
episode=episode,
|
||||
tmdbid=tmdbid)
|
||||
|
||||
def list_by_user_date(self, date: str, username: str = None) -> List[DownloadHistory]:
|
||||
def list_by_user_date(self, date: str, username: Optional[str] = None) -> List[DownloadHistory]:
|
||||
"""
|
||||
查询某用户某时间之前的下载历史
|
||||
"""
|
||||
@@ -130,7 +131,7 @@ class DownloadHistoryOper(DbOper):
|
||||
date=date,
|
||||
username=username)
|
||||
|
||||
def list_by_date(self, date: str, type: str, tmdbid: str, seasons: str = None) -> List[DownloadHistory]:
|
||||
def list_by_date(self, date: str, type: str, tmdbid: str, seasons: Optional[str] = None) -> List[DownloadHistory]:
|
||||
"""
|
||||
查询某时间之后的下载历史
|
||||
"""
|
||||
@@ -140,7 +141,7 @@ class DownloadHistoryOper(DbOper):
|
||||
tmdbid=tmdbid,
|
||||
seasons=seasons)
|
||||
|
||||
def list_by_type(self, mtype: str, days: int = 7) -> List[DownloadHistory]:
|
||||
def list_by_type(self, mtype: str, days: Optional[int] = 7) -> List[DownloadHistory]:
|
||||
"""
|
||||
获取指定类型的下载历史
|
||||
"""
|
||||
|
||||
@@ -18,12 +18,22 @@ def update_db():
|
||||
"""
|
||||
更新数据库
|
||||
"""
|
||||
db_location = settings.CONFIG_PATH / 'user.db'
|
||||
script_location = settings.ROOT_PATH / 'database'
|
||||
try:
|
||||
alembic_cfg = Config()
|
||||
alembic_cfg.set_main_option('script_location', str(script_location))
|
||||
alembic_cfg.set_main_option('sqlalchemy.url', f"sqlite:///{db_location}")
|
||||
|
||||
# 根据数据库类型设置不同的URL
|
||||
if settings.DB_TYPE.lower() == "postgresql":
|
||||
if settings.DB_POSTGRESQL_PASSWORD:
|
||||
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
|
||||
else:
|
||||
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
|
||||
else:
|
||||
db_location = settings.CONFIG_PATH / 'user.db'
|
||||
db_url = f"sqlite:///{db_location}"
|
||||
|
||||
alembic_cfg.set_main_option('sqlalchemy.url', db_url)
|
||||
upgrade(alembic_cfg, 'head')
|
||||
except Exception as e:
|
||||
logger.error(f'数据库更新失败:{str(e)}')
|
||||
|
||||
@@ -58,6 +58,32 @@ class MediaServerOper(DbOper):
|
||||
return None
|
||||
return item
|
||||
|
||||
async def async_exists(self, **kwargs) -> Optional[MediaServerItem]:
|
||||
"""
|
||||
异步判断媒体服务器数据是否存在
|
||||
"""
|
||||
if kwargs.get("tmdbid"):
|
||||
# 优先按TMDBID查
|
||||
item = await MediaServerItem.async_exist_by_tmdbid(self._db, tmdbid=kwargs.get("tmdbid"),
|
||||
mtype=kwargs.get("mtype"))
|
||||
elif kwargs.get("title"):
|
||||
# 按标题、类型、年份查
|
||||
item = await MediaServerItem.async_exists_by_title(self._db, title=kwargs.get("title"),
|
||||
mtype=kwargs.get("mtype"), year=kwargs.get("year"))
|
||||
else:
|
||||
return None
|
||||
if not item:
|
||||
return None
|
||||
|
||||
if kwargs.get("season"):
|
||||
# 判断季是否存在
|
||||
if not item.seasoninfo:
|
||||
return None
|
||||
seasoninfo = item.seasoninfo or {}
|
||||
if kwargs.get("season") not in seasoninfo.keys():
|
||||
return None
|
||||
return item
|
||||
|
||||
def get_item_id(self, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
获取媒体服务器数据ID
|
||||
@@ -66,3 +92,12 @@ class MediaServerOper(DbOper):
|
||||
if not item:
|
||||
return None
|
||||
return str(item.item_id)
|
||||
|
||||
async def async_get_item_id(self, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
异步获取媒体服务器数据ID
|
||||
"""
|
||||
item = await self.async_exists(**kwargs)
|
||||
if not item:
|
||||
return None
|
||||
return str(item.item_id)
|
||||
|
||||
@@ -18,18 +18,18 @@ class MessageOper(DbOper):
|
||||
|
||||
def add(self,
|
||||
channel: MessageChannel = None,
|
||||
source: str = None,
|
||||
source: Optional[str] = None,
|
||||
mtype: NotificationType = None,
|
||||
title: str = None,
|
||||
text: str = None,
|
||||
image: str = None,
|
||||
link: str = None,
|
||||
userid: str = None,
|
||||
action: int = 1,
|
||||
title: Optional[str] = None,
|
||||
text: Optional[str] = None,
|
||||
image: Optional[str] = None,
|
||||
link: Optional[str] = None,
|
||||
userid: Optional[str] = None,
|
||||
action: Optional[int] = 1,
|
||||
note: Union[list, dict] = None,
|
||||
**kwargs):
|
||||
"""
|
||||
新增媒体服务器数据
|
||||
新增消息
|
||||
:param channel: 消息渠道
|
||||
:param source: 来源
|
||||
:param mtype: 消息类型
|
||||
@@ -57,12 +57,48 @@ class MessageOper(DbOper):
|
||||
|
||||
# 从kwargs中去掉Message中没有的字段
|
||||
for k in list(kwargs.keys()):
|
||||
if k not in Message.__table__.columns.keys(): # noqa
|
||||
if k not in Message.__table__.columns.keys(): # noqa
|
||||
kwargs.pop(k)
|
||||
|
||||
Message(**kwargs).create(self._db)
|
||||
|
||||
def list_by_page(self, page: int = 1, count: int = 30) -> Optional[str]:
|
||||
async def async_add(self,
|
||||
channel: MessageChannel = None,
|
||||
source: Optional[str] = None,
|
||||
mtype: NotificationType = None,
|
||||
title: Optional[str] = None,
|
||||
text: Optional[str] = None,
|
||||
image: Optional[str] = None,
|
||||
link: Optional[str] = None,
|
||||
userid: Optional[str] = None,
|
||||
action: Optional[int] = 1,
|
||||
note: Union[list, dict] = None,
|
||||
**kwargs):
|
||||
"""
|
||||
异步新增消息
|
||||
"""
|
||||
kwargs.update({
|
||||
"channel": channel.value if channel else '',
|
||||
"source": source,
|
||||
"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": note or {}
|
||||
})
|
||||
|
||||
# 从kwargs中去掉Message中没有的字段
|
||||
for k in list(kwargs.keys()):
|
||||
if k not in Message.__table__.columns.keys(): # noqa
|
||||
kwargs.pop(k)
|
||||
|
||||
await Message(**kwargs).async_create(self._db)
|
||||
|
||||
def list_by_page(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[str]:
|
||||
"""
|
||||
获取媒体服务器数据ID
|
||||
"""
|
||||
|
||||
@@ -8,3 +8,4 @@ from .systemconfig import SystemConfig
|
||||
from .transferhistory import TransferHistory
|
||||
from .user import User
|
||||
from .userconfig import UserConfig
|
||||
from .workflow import Workflow
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import Column, Integer, String, JSON, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
from app.db import db_query, db_update, get_id_column, Base, async_db_query
|
||||
|
||||
|
||||
class DownloadHistory(Base):
|
||||
"""
|
||||
下载历史记录
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
id = get_id_column()
|
||||
# 保存路径
|
||||
path = Column(String, nullable=False, index=True)
|
||||
# 类型 电影/电视剧
|
||||
@@ -51,94 +53,112 @@ class DownloadHistory(Base):
|
||||
note = Column(JSON)
|
||||
# 自定义媒体类别
|
||||
media_category = Column(String)
|
||||
# 剧集组
|
||||
episode_group = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_hash(db: Session, download_hash: str):
|
||||
def get_by_hash(cls, db: Session, download_hash: str):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).order_by(
|
||||
DownloadHistory.date.desc()
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_mediaid(db: Session, tmdbid: int, doubanid: str):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.doubanid == doubanid).all()
|
||||
def get_by_mediaid(cls, db: Session, tmdbid: int, doubanid: str):
|
||||
if tmdbid:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all()
|
||||
elif doubanid:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.doubanid == doubanid).all()
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_page(db: Session, page: int = 1, count: int = 30):
|
||||
result = db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()
|
||||
return list(result)
|
||||
def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
return db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
result = await db.execute(
|
||||
select(cls).offset((page - 1) * count).limit(count)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_path(db: Session, path: str):
|
||||
def get_by_path(cls, db: Session, path: str):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.path == path).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_last_by(db: Session, mtype: str = None, title: str = None, year: int = None, season: str = None,
|
||||
episode: str = None, tmdbid: int = None):
|
||||
def get_last_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None,
|
||||
year: Optional[str] = None, season: Optional[str] = None,
|
||||
episode: Optional[str] = None, tmdbid: Optional[int] = None):
|
||||
"""
|
||||
据tmdbid、season、season_episode查询转移记录
|
||||
据tmdbid、season、season_episode查询下载记录
|
||||
tmdbid + mtype 或 title + year
|
||||
"""
|
||||
result = None
|
||||
if tmdbid and not season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
if tmdbid and season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
if tmdbid and season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧所有季集|电影
|
||||
if not season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季
|
||||
if season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# TMDBID + 类型
|
||||
if tmdbid and mtype:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
# 电视剧所有季集/电影
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 标题 + 年份
|
||||
elif title and year:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
# 电视剧所有季集/电影
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
if result:
|
||||
return list(result)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_user_date(db: Session, date: str, username: str = None):
|
||||
def list_by_user_date(cls, db: Session, date: str, username: Optional[str] = None):
|
||||
"""
|
||||
查询某用户某时间之后的下载历史
|
||||
"""
|
||||
if username:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.date < date,
|
||||
DownloadHistory.username == username).order_by(
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date < date,
|
||||
DownloadHistory.username == username).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_date(db: Session, date: str, type: str, tmdbid: str, seasons: str = None):
|
||||
def list_by_date(cls, db: Session, date: str, type: str, tmdbid: str, seasons: Optional[str] = None):
|
||||
"""
|
||||
查询某时间之后的下载历史
|
||||
"""
|
||||
@@ -154,22 +174,21 @@ class DownloadHistory(Base):
|
||||
DownloadHistory.tmdbid == tmdbid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, days: int):
|
||||
result = db.query(DownloadHistory) \
|
||||
def list_by_type(cls, db: Session, mtype: str, days: int):
|
||||
return 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):
|
||||
"""
|
||||
下载文件记录
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
id = get_id_column()
|
||||
# 下载器
|
||||
downloader = Column(String)
|
||||
# 下载任务Hash
|
||||
@@ -185,38 +204,35 @@ class DownloadFiles(Base):
|
||||
# 状态 0-已删除 1-正常
|
||||
state = Column(Integer, nullable=False, default=1)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_hash(db: Session, download_hash: str, state: int = None):
|
||||
def get_by_hash(cls, db: Session, download_hash: str, state: Optional[int] = None):
|
||||
if state:
|
||||
result = db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash,
|
||||
DownloadFiles.state == state).all()
|
||||
return db.query(cls).filter(cls.download_hash == download_hash,
|
||||
cls.state == state).all()
|
||||
else:
|
||||
result = db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash).all()
|
||||
return db.query(cls).filter(cls.download_hash == download_hash).all()
|
||||
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_fullpath(db: Session, fullpath: str, all_files: bool = False):
|
||||
def get_by_fullpath(cls, db: Session, fullpath: str, all_files: bool = False):
|
||||
if not all_files:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
||||
DownloadFiles.id.desc()).first()
|
||||
return db.query(cls).filter(cls.fullpath == fullpath).order_by(
|
||||
cls.id.desc()).first()
|
||||
else:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
||||
DownloadFiles.id.desc()).all()
|
||||
return db.query(cls).filter(cls.fullpath == fullpath).order_by(
|
||||
cls.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_savepath(db: Session, savepath: str):
|
||||
result = db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
|
||||
return list(result)
|
||||
def get_by_savepath(cls, db: Session, savepath: str):
|
||||
return db.query(cls).filter(cls.savepath == savepath).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_update
|
||||
def delete_by_fullpath(db: Session, fullpath: str):
|
||||
db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath,
|
||||
DownloadFiles.state == 1).update(
|
||||
def delete_by_fullpath(cls, db: Session, fullpath: str):
|
||||
db.query(cls).filter(cls.fullpath == fullpath,
|
||||
cls.state == 1).update(
|
||||
{
|
||||
"state": 0
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import Column, Integer, String, JSON
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
from app.db import db_query, db_update, get_id_column, async_db_query, Base
|
||||
|
||||
|
||||
class MediaServerItem(Base):
|
||||
"""
|
||||
媒体服务器媒体条目表
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
id = get_id_column()
|
||||
# 服务器类型
|
||||
server = Column(String)
|
||||
# 媒体库ID
|
||||
@@ -41,28 +43,49 @@ class MediaServerItem(Base):
|
||||
# 同步时间
|
||||
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_itemid(db: Session, item_id: str):
|
||||
return db.query(MediaServerItem).filter(MediaServerItem.item_id == item_id).first()
|
||||
def get_by_itemid(cls, db: Session, item_id: str):
|
||||
return db.query(cls).filter(cls.item_id == item_id).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_update
|
||||
def empty(db: Session, server: Optional[str] = None):
|
||||
def empty(cls, db: Session, server: Optional[str] = None):
|
||||
if server is None:
|
||||
db.query(MediaServerItem).delete()
|
||||
db.query(cls).delete()
|
||||
else:
|
||||
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
|
||||
db.query(cls).filter(cls.server == server).delete()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def exist_by_tmdbid(db: Session, tmdbid: int, mtype: str):
|
||||
return db.query(MediaServerItem).filter(MediaServerItem.tmdbid == tmdbid,
|
||||
MediaServerItem.item_type == mtype).first()
|
||||
def exist_by_tmdbid(cls, db: Session, tmdbid: int, mtype: str):
|
||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.item_type == mtype).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def exists_by_title(db: Session, title: str, mtype: str, year: str):
|
||||
return db.query(MediaServerItem).filter(MediaServerItem.title == title,
|
||||
MediaServerItem.item_type == mtype,
|
||||
MediaServerItem.year == str(year)).first()
|
||||
def exists_by_title(cls, db: Session, title: str, mtype: str, year: str):
|
||||
return db.query(cls).filter(cls.title == title,
|
||||
cls.item_type == mtype,
|
||||
cls.year == str(year)).first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_itemid(cls, db: AsyncSession, item_id: str):
|
||||
result = await db.execute(select(cls).filter(cls.item_id == item_id))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_exist_by_tmdbid(cls, db: AsyncSession, tmdbid: int, mtype: str):
|
||||
result = await db.execute(select(cls).filter(cls.tmdbid == tmdbid,
|
||||
cls.item_type == mtype))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_exists_by_title(cls, db: AsyncSession, title: str, mtype: str, year: str):
|
||||
result = await db.execute(select(cls).filter(cls.title == title,
|
||||
cls.item_type == mtype,
|
||||
cls.year == str(year)))
|
||||
return result.scalars().first()
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, JSON, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, Base
|
||||
from app.db import db_query, Base, get_id_column, async_db_query
|
||||
|
||||
|
||||
class Message(Base):
|
||||
"""
|
||||
消息表
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
id = get_id_column()
|
||||
# 消息渠道
|
||||
channel = Column(String)
|
||||
# 消息来源
|
||||
@@ -32,10 +35,15 @@ class Message(Base):
|
||||
# 附件json
|
||||
note = Column(JSON)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@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)
|
||||
def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
return db.query(cls).order_by(cls.reg_time.desc()).offset((page - 1) * count).limit(count).all()
|
||||
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
result = await db.execute(
|
||||
select(cls).order_by(cls.reg_time.desc()).offset((page - 1) * count).limit(count)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -1,41 +1,39 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import Column, String, JSON
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
from app.db import db_query, db_update, get_id_column, Base
|
||||
|
||||
|
||||
class PluginData(Base):
|
||||
"""
|
||||
插件数据表
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
id = get_id_column()
|
||||
plugin_id = Column(String, nullable=False, index=True)
|
||||
key = Column(String, index=True, nullable=False)
|
||||
value = Column(JSON)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_plugin_data(db: Session, plugin_id: str):
|
||||
result = db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
return list(result)
|
||||
def get_plugin_data(cls, db: Session, plugin_id: str):
|
||||
return db.query(cls).filter(cls.plugin_id == plugin_id).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_plugin_data_by_key(db: Session, plugin_id: str, key: str):
|
||||
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).first()
|
||||
def get_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):
|
||||
return db.query(cls).filter(cls.plugin_id == plugin_id, cls.key == key).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_update
|
||||
def del_plugin_data_by_key(db: Session, plugin_id: str, key: str):
|
||||
db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).delete()
|
||||
def del_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):
|
||||
db.query(cls).filter(cls.plugin_id == plugin_id, cls.key == key).delete()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_update
|
||||
def del_plugin_data(db: Session, plugin_id: str):
|
||||
db.query(PluginData).filter(PluginData.plugin_id == plugin_id).delete()
|
||||
def del_plugin_data(cls, db: Session, plugin_id: str):
|
||||
db.query(cls).filter(cls.plugin_id == plugin_id).delete()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_plugin_data_by_plugin_id(db: Session, plugin_id: str):
|
||||
result = db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
return list(result)
|
||||
def get_plugin_data_by_plugin_id(cls, db: Session, plugin_id: str):
|
||||
return db.query(cls).filter(cls.plugin_id == plugin_id).all()
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import Boolean, Column, Integer, String, JSON, select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
from app.db import db_query, db_update, Base, async_db_query, async_db_update, get_id_column
|
||||
|
||||
|
||||
class Site(Base):
|
||||
"""
|
||||
站点表
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
id = get_id_column()
|
||||
# 站点名
|
||||
name = Column(String, nullable=False)
|
||||
# 域名Key
|
||||
@@ -54,30 +55,50 @@ class Site(Base):
|
||||
# 下载器
|
||||
downloader = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_by_domain(db: Session, domain: str):
|
||||
return db.query(Site).filter(Site.domain == domain).first()
|
||||
def get_by_domain(cls, db: Session, domain: str):
|
||||
return db.query(cls).filter(cls.domain == domain).first()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_by_domain(cls, db: AsyncSession, domain: str):
|
||||
result = await db.execute(select(cls).where(cls.domain == domain))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_actives(db: Session):
|
||||
result = db.query(Site).filter(Site.is_active == 1).all()
|
||||
return list(result)
|
||||
def get_actives(cls, db: Session):
|
||||
return db.query(cls).filter(cls.is_active).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_get_actives(cls, db: AsyncSession):
|
||||
result = await db.execute(select(cls).where(cls.is_active))
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list_order_by_pri(db: Session):
|
||||
result = db.query(Site).order_by(Site.pri).all()
|
||||
return list(result)
|
||||
def list_order_by_pri(cls, db: Session):
|
||||
return db.query(cls).order_by(cls.pri).all()
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@async_db_query
|
||||
async def async_list_order_by_pri(cls, db: AsyncSession):
|
||||
result = await db.execute(select(cls).order_by(cls.pri))
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get_domains_by_ids(db: Session, ids: list):
|
||||
result = db.query(Site.domain).filter(Site.id.in_(ids)).all()
|
||||
return [r[0] for r in result]
|
||||
def get_domains_by_ids(cls, db: Session, ids: list):
|
||||
return [r[0] for r in db.query(cls.domain).filter(cls.id.in_(ids)).all()]
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
@db_update
|
||||
def reset(db: Session):
|
||||
db.query(Site).delete()
|
||||
def reset(cls, db: Session):
|
||||
db.query(cls).delete()
|
||||
|
||||
@classmethod
|
||||
@async_db_update
|
||||
async def async_reset(cls, db: AsyncSession):
|
||||
await db.execute(delete(cls))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user