mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-12 04:59:39 +08:00
Compare commits
664 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2096e8e0f | ||
|
|
75e80158e5 | ||
|
|
d42bd14288 | ||
|
|
28f6e7f9bb | ||
|
|
2aadbeaed7 | ||
|
|
3f6b4bf3f2 | ||
|
|
f73750fcf7 | ||
|
|
59df673eb5 | ||
|
|
e29ab92cd1 | ||
|
|
3777045a17 | ||
|
|
16165c0fcc | ||
|
|
4d377d5e04 | ||
|
|
76c84f9bac | ||
|
|
88f91152d6 | ||
|
|
dfdb88c5ac | ||
|
|
ec183b6d0d | ||
|
|
9d047dddb4 | ||
|
|
2d83880830 | ||
|
|
7e6ef04554 | ||
|
|
08aa5fe50a | ||
|
|
656cc1fe01 | ||
|
|
8afaa683cc | ||
|
|
4d3aa0faf3 | ||
|
|
9e08b9129a | ||
|
|
0584bda470 | ||
|
|
df8531e4d8 | ||
|
|
cfc51c305b | ||
|
|
28759f6c81 | ||
|
|
15b701803f | ||
|
|
72774f80a5 | ||
|
|
341526b4d9 | ||
|
|
b6bfd215bc | ||
|
|
6801032f7a | ||
|
|
af2075578c | ||
|
|
b46ede86fc | ||
|
|
a104001087 | ||
|
|
88e8790678 | ||
|
|
a59d73a68a | ||
|
|
522d970731 | ||
|
|
51a0f97580 | ||
|
|
0ef6d7bbf2 | ||
|
|
d818ceb8e6 | ||
|
|
a69d56d9fd | ||
|
|
957df2cf66 | ||
|
|
d863a7cb7f | ||
|
|
021fcb17bb | ||
|
|
b4e233678d | ||
|
|
5e53825684 | ||
|
|
236d860133 | ||
|
|
76d939b665 | ||
|
|
63d35dfeef | ||
|
|
3dd7d36760 | ||
|
|
e4b0e4bf33 | ||
|
|
3504c0cdd6 | ||
|
|
980feb3cd2 | ||
|
|
a1daf884e6 | ||
|
|
f0e4d9bf63 | ||
|
|
15397a522e | ||
|
|
1c00c47a9b | ||
|
|
e9a6f08cc8 | ||
|
|
7ba2d60925 | ||
|
|
9686a20c2f | ||
|
|
6029cf283b | ||
|
|
4d6ed7d552 | ||
|
|
8add8ed631 | ||
|
|
ab78b10287 | ||
|
|
94ed377843 | ||
|
|
4cb85a2b4c | ||
|
|
b2a88b2791 | ||
|
|
88f451147e | ||
|
|
51099ace65 | ||
|
|
0564bdf020 | ||
|
|
bbac709970 | ||
|
|
bb9690c873 | ||
|
|
00be46b74f | ||
|
|
2af21765e0 | ||
|
|
646349ac35 | ||
|
|
915388c109 | ||
|
|
3c24ae5351 | ||
|
|
e876ba38a7 | ||
|
|
01546baddc | ||
|
|
133195cc0a | ||
|
|
e58911397a | ||
|
|
10553ad6fc | ||
|
|
672d430322 | ||
|
|
be785f358d | ||
|
|
eff8a6c497 | ||
|
|
5d89ad965f | ||
|
|
1651f4677b | ||
|
|
dc3240e90a | ||
|
|
e2ee930ff4 | ||
|
|
90901d7297 | ||
|
|
1b76f1c851 | ||
|
|
3d9853adcf | ||
|
|
81384c358e | ||
|
|
a46463683d | ||
|
|
4cf3b49324 | ||
|
|
1f6fa22aa1 | ||
|
|
d108b0da78 | ||
|
|
0ee21b38de | ||
|
|
b1858f4849 | ||
|
|
ac086a7640 | ||
|
|
1d252f4eb2 | ||
|
|
ab354ef0e8 | ||
|
|
167cba2dbb | ||
|
|
9cf7547a8c | ||
|
|
823b81784e | ||
|
|
d9effb54ee | ||
|
|
1a8d9044d7 | ||
|
|
0a2ce11eb0 | ||
|
|
42b5dd4178 | ||
|
|
2bae866f70 | ||
|
|
2470a98491 | ||
|
|
9d70b117d7 | ||
|
|
1fad9d9904 | ||
|
|
dc1533d5e8 | ||
|
|
e0cfb4fd6d | ||
|
|
119919da51 | ||
|
|
684e518b87 | ||
|
|
50febd6b2c | ||
|
|
86dec5aec2 | ||
|
|
fa021de2ae | ||
|
|
874572253c | ||
|
|
059f7f8146 | ||
|
|
d6f8c364bf | ||
|
|
a6f0792014 | ||
|
|
a4419796ac | ||
|
|
dad980fa14 | ||
|
|
a3cb805c64 | ||
|
|
c128dd9507 | ||
|
|
dbf1b691d6 | ||
|
|
4199438d5e | ||
|
|
a0ad8faaf7 | ||
|
|
c4619edcde | ||
|
|
51f8fc07eb | ||
|
|
f7357b8a71 | ||
|
|
c5e7050898 | ||
|
|
5871c60a9d | ||
|
|
078bca1259 | ||
|
|
6ca78c0cb9 | ||
|
|
f03a977a99 | ||
|
|
ab32d3347d | ||
|
|
f8631c68a3 | ||
|
|
a052850990 | ||
|
|
ea9db33323 | ||
|
|
72b955ebae | ||
|
|
b1545fc351 | ||
|
|
be09d5e65d | ||
|
|
48f4505161 | ||
|
|
5c5182941f | ||
|
|
7a0b0d114e | ||
|
|
5eaffd9797 | ||
|
|
7cfa315529 | ||
|
|
6869708e8e | ||
|
|
5311b5f66a | ||
|
|
f3144807bd | ||
|
|
7437c1ca51 | ||
|
|
60632aa9d3 | ||
|
|
c66793c0c8 | ||
|
|
d5c8dffffe | ||
|
|
d4ac585549 | ||
|
|
b9c368e087 | ||
|
|
bda0d7a9fb | ||
|
|
d29ab9b5bd | ||
|
|
b2d66b8973 | ||
|
|
bb858f4bc1 | ||
|
|
6b875ef2de | ||
|
|
0145421885 | ||
|
|
0ac6d9f25e | ||
|
|
80328bdf2d | ||
|
|
87166b3cd7 | ||
|
|
f91daf2106 | ||
|
|
3c8cf65902 | ||
|
|
3c784e946a | ||
|
|
4034d69fbc | ||
|
|
eeed9849ef | ||
|
|
b07297c7e1 | ||
|
|
87813c853b | ||
|
|
571997fa8e | ||
|
|
9255c85a85 | ||
|
|
dba5603359 | ||
|
|
e76cb97092 | ||
|
|
6dde33d8fc | ||
|
|
d1d98a9081 | ||
|
|
08e07625cd | ||
|
|
c650f1b5e3 | ||
|
|
2c8ecdfcb9 | ||
|
|
c6febe4755 | ||
|
|
08830c7edd | ||
|
|
1a40860a5d | ||
|
|
afd0edf7d1 | ||
|
|
c2d3a00615 | ||
|
|
5b6083a1ec | ||
|
|
363f12ed5a | ||
|
|
de17bc5645 | ||
|
|
1e4f3e97cd | ||
|
|
69c02291a3 | ||
|
|
c7b27784c9 | ||
|
|
616b15e18a | ||
|
|
1e781ba3d1 | ||
|
|
d48c4d15e2 | ||
|
|
1c2a194a7d | ||
|
|
5d1ccef5a2 | ||
|
|
6f299b3255 | ||
|
|
974fe7c965 | ||
|
|
d8e7c7e6d7 | ||
|
|
386ff672a7 | ||
|
|
a802de2589 | ||
|
|
b6eac122b8 | ||
|
|
1a8e1844b4 | ||
|
|
2b982ce7a8 | ||
|
|
e93b3f5602 | ||
|
|
5ef4fc04d5 | ||
|
|
1190d8dda4 | ||
|
|
0805f02f1f | ||
|
|
4accd5d784 | ||
|
|
4c2bb99b59 | ||
|
|
348923aaa6 | ||
|
|
62ac03fb29 | ||
|
|
a4bf59ad58 | ||
|
|
c02c19d719 | ||
|
|
b83279b05a | ||
|
|
cf94c70f8c | ||
|
|
52a15086cb | ||
|
|
8234c29006 | ||
|
|
aeed9fb48e | ||
|
|
e233bc678c | ||
|
|
346c6dd11c | ||
|
|
bcc48e885a | ||
|
|
4469a1b3b8 | ||
|
|
54666cb757 | ||
|
|
4455ac13e9 | ||
|
|
981e5ea927 | ||
|
|
541a3d68e6 | ||
|
|
ccc11c4892 | ||
|
|
9548409bd5 | ||
|
|
11c10ea783 | ||
|
|
e99913f900 | ||
|
|
8af37a0adc | ||
|
|
810e3c98f9 | ||
|
|
4877ec68b1 | ||
|
|
12c669aa17 | ||
|
|
7fd65c572b | ||
|
|
89819f8730 | ||
|
|
954110f166 | ||
|
|
bd1427474d | ||
|
|
3909bb6393 | ||
|
|
9a8e0a256a | ||
|
|
675655bfc7 | ||
|
|
422474b4b7 | ||
|
|
efb624259a | ||
|
|
f9e06e4381 | ||
|
|
f67ee27618 | ||
|
|
5224e6751d | ||
|
|
b263489635 | ||
|
|
1a10f6d6e3 | ||
|
|
4e3a76ffa3 | ||
|
|
0d139851af | ||
|
|
603ab97665 | ||
|
|
fcfeeb09d3 | ||
|
|
ea32cd83af | ||
|
|
1b8380d0c2 | ||
|
|
e3901c7621 | ||
|
|
f633d09a1d | ||
|
|
e4cc834fa7 | ||
|
|
828e9ab886 | ||
|
|
d1bf1411b6 | ||
|
|
7532929669 | ||
|
|
d2a613a441 | ||
|
|
eb66f6c05a | ||
|
|
9f79a30960 | ||
|
|
51391db262 | ||
|
|
3541d47baf | ||
|
|
b0c11bbe5f | ||
|
|
82253af5a5 | ||
|
|
5ba555eead | ||
|
|
0c73bbbfe0 | ||
|
|
55403cd8a8 | ||
|
|
871f8d3529 | ||
|
|
cadc0b0511 | ||
|
|
084b5c8d68 | ||
|
|
16f6303609 | ||
|
|
7ea01c1109 | ||
|
|
e31df15b5e | ||
|
|
78cbe1aaed | ||
|
|
2bfd32f716 | ||
|
|
092ac8a124 | ||
|
|
0d3d6e9bf9 | ||
|
|
e2ee3ec4cd | ||
|
|
9e161fb36c | ||
|
|
1b00bbc890 | ||
|
|
812d6029d0 | ||
|
|
52cf154e65 | ||
|
|
5b6b1231fe | ||
|
|
1a9ba58023 | ||
|
|
4dd146d1c8 | ||
|
|
4af57d9857 | ||
|
|
4f01b82b81 | ||
|
|
9547847037 | ||
|
|
284082741e | ||
|
|
d7da2e133a | ||
|
|
b704dcfe07 | ||
|
|
5c05845500 | ||
|
|
75530a22c3 | ||
|
|
cd4a6476c9 | ||
|
|
0afdd9056a | ||
|
|
5de882d788 | ||
|
|
c35f1f0a07 | ||
|
|
4f27897e08 | ||
|
|
ea76a27d26 | ||
|
|
9d71c9b61e | ||
|
|
1484ce86a9 | ||
|
|
3b0154f8e3 | ||
|
|
cb761275ab | ||
|
|
210c5e3151 | ||
|
|
bbe8f7f080 | ||
|
|
8317b6b7a2 | ||
|
|
9dcb28fe3d | ||
|
|
fb61eda831 | ||
|
|
f8149afb6e | ||
|
|
9dc603bd73 | ||
|
|
0da914b891 | ||
|
|
5701bbb146 | ||
|
|
4b6d269230 | ||
|
|
a25ff4302d | ||
|
|
80ada2232e | ||
|
|
557c1cd1e6 | ||
|
|
7473f0ba27 | ||
|
|
ee455ac61e | ||
|
|
0ca42236d6 | ||
|
|
835e0b4d5d | ||
|
|
d3186cd742 | ||
|
|
d69041f049 | ||
|
|
666f9a536d | ||
|
|
637e92304f | ||
|
|
80a1ded602 | ||
|
|
e731767dfa | ||
|
|
06ea9e2d09 | ||
|
|
886b31b35d | ||
|
|
da872cca41 | ||
|
|
daadfcffd8 | ||
|
|
838e17bf6e | ||
|
|
61ecc175f3 | ||
|
|
709f8ef3ed | ||
|
|
fdab59a84e | ||
|
|
0593275a62 | ||
|
|
7c643432ee | ||
|
|
5993bfcefb | ||
|
|
1add203c0e | ||
|
|
8b00e9cb72 | ||
|
|
14dd7c4e31 | ||
|
|
48122d8d9a | ||
|
|
8f5cf33fa9 | ||
|
|
3fe79d589a | ||
|
|
f3956a0504 | ||
|
|
efb3bd93d0 | ||
|
|
640a67fc3a | ||
|
|
2ce3ddb75a | ||
|
|
1a36d9fe7a | ||
|
|
255c05daf9 | ||
|
|
d1abc23cbd | ||
|
|
35c68fe30d | ||
|
|
5efcd6e6be | ||
|
|
46fb52fff9 | ||
|
|
c6abb1f9f1 | ||
|
|
b4b919db86 | ||
|
|
1cef5e43e3 | ||
|
|
f6baf62189 | ||
|
|
e1aa4b7519 | ||
|
|
ddfcdf9ce2 | ||
|
|
eff3fadfbf | ||
|
|
3512e7df4a | ||
|
|
5b1d111a97 | ||
|
|
e1b557f681 | ||
|
|
93e053d06a | ||
|
|
f79364bc58 | ||
|
|
2da95fa4e6 | ||
|
|
90603fa2a9 | ||
|
|
41d41685fe | ||
|
|
91efe2e94c | ||
|
|
d7f9ed5198 | ||
|
|
f0464c4be7 | ||
|
|
9863c85fe2 | ||
|
|
222991d07f | ||
|
|
cf4c6b2d40 | ||
|
|
6d55db466c | ||
|
|
88394005e5 | ||
|
|
959dc0f14b | ||
|
|
c07d02e572 | ||
|
|
8612127161 | ||
|
|
4bf7e05a3d | ||
|
|
9cfc27392d | ||
|
|
1a3d88f306 | ||
|
|
d7c277a277 | ||
|
|
8e8a10f04e | ||
|
|
5fc5838abd | ||
|
|
748836df23 | ||
|
|
f0100e6dbc | ||
|
|
17aa6c674f | ||
|
|
796dc6d800 | ||
|
|
7444b3e84b | ||
|
|
fada22e892 | ||
|
|
c51826ba4c | ||
|
|
d7e56eeb36 | ||
|
|
f4b4e6e0dc | ||
|
|
a555c9b654 | ||
|
|
997a9487a1 | ||
|
|
dea8fc5486 | ||
|
|
857383c8d0 | ||
|
|
6a9fccaacb | ||
|
|
688693b31f | ||
|
|
7c5b4b6202 | ||
|
|
ef0768ec44 | ||
|
|
be63e9ed15 | ||
|
|
6431524e61 | ||
|
|
3bee5a8a86 | ||
|
|
e2bf0cd457 | ||
|
|
8ac3fd46d2 | ||
|
|
117bd80528 | ||
|
|
91fc41261f | ||
|
|
ee5976a03e | ||
|
|
8a75159662 | ||
|
|
63b0f5b70f | ||
|
|
623580a7ae | ||
|
|
85cb9f7cd7 | ||
|
|
e786120e98 | ||
|
|
49b6052ab0 | ||
|
|
2486b9274c | ||
|
|
4016295696 | ||
|
|
f3b2bbfb6f | ||
|
|
786b317cea | ||
|
|
152546d89a | ||
|
|
bf21eda1bb | ||
|
|
6e8d1219f8 | ||
|
|
69c3f9eb5d | ||
|
|
bb086d7c83 | ||
|
|
28f7a409f9 | ||
|
|
3141d02e44 | ||
|
|
5136698617 | ||
|
|
8cc72f402b | ||
|
|
4d2e77fc51 | ||
|
|
d5aa52ed91 | ||
|
|
148e4a95ee | ||
|
|
3c43055f10 | ||
|
|
1920dc0a82 | ||
|
|
8306aa92db | ||
|
|
947a19eb95 | ||
|
|
36142b97bf | ||
|
|
4efc80e35a | ||
|
|
31aadabe86 | ||
|
|
593bcbf455 | ||
|
|
220fef5c9b | ||
|
|
343f51ce79 | ||
|
|
e86bf61579 | ||
|
|
8bb25afcdc | ||
|
|
57bad6353c | ||
|
|
f6c84a744c | ||
|
|
5229a0173a | ||
|
|
5a6733fa32 | ||
|
|
e0e4b31933 | ||
|
|
a29cf83aba | ||
|
|
ede37b80fc | ||
|
|
2f2ecc8c43 | ||
|
|
5ec7357c56 | ||
|
|
a547ea954d | ||
|
|
777c7c78d0 | ||
|
|
c1bf32318b | ||
|
|
6a65b5b234 | ||
|
|
d0a868123d | ||
|
|
b9f1ebff89 | ||
|
|
0ef8efd5a5 | ||
|
|
9129de1720 | ||
|
|
1ebec13afb | ||
|
|
73407825f5 | ||
|
|
53195457c7 | ||
|
|
9a62feb9a9 | ||
|
|
26abccabf3 | ||
|
|
596b2e11b8 | ||
|
|
e2436ba94f | ||
|
|
f9895b2edd | ||
|
|
3446aec6a2 | ||
|
|
23b9774c5d | ||
|
|
540f5eb77f | ||
|
|
8b336cf3eb | ||
|
|
186476ad31 | ||
|
|
171f15e410 | ||
|
|
05bbfde943 | ||
|
|
150e2366da | ||
|
|
9c47da8c98 | ||
|
|
0f5290be18 | ||
|
|
104348ba0e | ||
|
|
1a1318b5e4 | ||
|
|
8a6ad03880 | ||
|
|
d0ac5646f5 | ||
|
|
89f2bf5f30 | ||
|
|
c3ef3dd7d1 | ||
|
|
aa6fa8d336 | ||
|
|
f18b9793b4 | ||
|
|
15946f8d0a | ||
|
|
c2824a1bc8 | ||
|
|
2d8dd6cc17 | ||
|
|
adf78a9e3e | ||
|
|
40ee902457 | ||
|
|
48ac6e727b | ||
|
|
d8a2b0497e | ||
|
|
1d31785def | ||
|
|
b1d2125e22 | ||
|
|
81ce44ee4d | ||
|
|
d806931296 | ||
|
|
28a8bb4baa | ||
|
|
773399347d | ||
|
|
a5cecdd631 | ||
|
|
34ae663d5a | ||
|
|
01505ceaa7 | ||
|
|
f0b1cdbe52 | ||
|
|
a13d32c17f | ||
|
|
c438cd5713 | ||
|
|
31c9fa932a | ||
|
|
4493d4c62f | ||
|
|
862f3cb623 | ||
|
|
ffbcc988b3 | ||
|
|
ab294ac35e | ||
|
|
d9f6db18d4 | ||
|
|
7a7225ba45 | ||
|
|
b42a69f361 | ||
|
|
eea6bd1ea3 | ||
|
|
73fca81641 | ||
|
|
f4b010f106 | ||
|
|
93801e857e | ||
|
|
8f73e45a30 | ||
|
|
9ab852c1ad | ||
|
|
88a0de7fa6 | ||
|
|
78657cb948 | ||
|
|
264cd2658b | ||
|
|
f4dfaa0519 | ||
|
|
707921e15d | ||
|
|
eea8b9a8a6 | ||
|
|
bd7fc2d4ff | ||
|
|
bc1da0a7c7 | ||
|
|
3ae34216d0 | ||
|
|
c15d326636 | ||
|
|
f93bcd852c | ||
|
|
0bf30bb75f | ||
|
|
93b899b7e9 | ||
|
|
fef270f73b | ||
|
|
c7dcbf697e | ||
|
|
d5241a2eb8 | ||
|
|
cf3d6bca91 | ||
|
|
1f87bc643a | ||
|
|
566928926b | ||
|
|
0a74437253 | ||
|
|
65ff01b713 | ||
|
|
5d3809b8f5 | ||
|
|
6e334ef333 | ||
|
|
c030f52418 | ||
|
|
af88618fbd | ||
|
|
8485d4ec30 | ||
|
|
61e4e63a6a | ||
|
|
47481d2482 | ||
|
|
65c8f35f6d | ||
|
|
6358e49a96 | ||
|
|
fc1076586a | ||
|
|
5bfd08cce8 | ||
|
|
63b0d0a86b | ||
|
|
a41be81f35 | ||
|
|
cec671e8a1 | ||
|
|
0097a6f33b | ||
|
|
0055f4c7af | ||
|
|
e19abeb149 | ||
|
|
236f59d56f | ||
|
|
8fba3cf170 | ||
|
|
0cb120a9e5 | ||
|
|
67f991d217 | ||
|
|
466b42bea7 | ||
|
|
f7d583856f | ||
|
|
d5d32e2335 | ||
|
|
e8ff878aac | ||
|
|
fea7b7d02d | ||
|
|
c69d317054 | ||
|
|
40663b6ce7 | ||
|
|
4f4c7a5748 | ||
|
|
7adae64955 | ||
|
|
4afd043f85 | ||
|
|
dc5250a74e | ||
|
|
0bd91ee484 | ||
|
|
6bbdc574b6 | ||
|
|
c275d4db22 | ||
|
|
843d93f0a8 | ||
|
|
5d26e70cae | ||
|
|
766640a0a0 | ||
|
|
7459938e92 | ||
|
|
bc476cb0c9 | ||
|
|
dc92a554f6 | ||
|
|
d8c8d43ed9 | ||
|
|
0f8c2d3fc9 | ||
|
|
b949969b10 | ||
|
|
66e13c5a31 | ||
|
|
294ff93e2b | ||
|
|
547812162d | ||
|
|
0028e2f830 | ||
|
|
97fdfe789e | ||
|
|
b0874f56c9 | ||
|
|
3d2b645bfc | ||
|
|
47b276795f | ||
|
|
9331f82b81 | ||
|
|
bb4355fbe0 | ||
|
|
a567a8644b | ||
|
|
9b7896ab96 | ||
|
|
1a0c4acf1c | ||
|
|
059e4f08a3 | ||
|
|
30ae583704 | ||
|
|
28d420af51 | ||
|
|
c87b982ebf | ||
|
|
290cafa03d | ||
|
|
604c418bd4 | ||
|
|
28345817d9 | ||
|
|
965e40e630 | ||
|
|
5f01dd5625 | ||
|
|
dde2d22d93 | ||
|
|
9f34be049d | ||
|
|
db26f2e108 | ||
|
|
35eda7d116 | ||
|
|
b6800c7fda | ||
|
|
03068778bc | ||
|
|
0da2bd6468 | ||
|
|
b37e50480a | ||
|
|
8530d54fcc | ||
|
|
1822d01d17 | ||
|
|
f23be671c0 | ||
|
|
15a7297099 | ||
|
|
9484093d22 | ||
|
|
c8fe6e4284 | ||
|
|
dfc5872087 | ||
|
|
9a07d88d41 | ||
|
|
b4e1e911fc | ||
|
|
60827fd5b1 | ||
|
|
cf409eb28f | ||
|
|
f16eb271da | ||
|
|
778b562cab | ||
|
|
964e212831 | ||
|
|
302514a469 | ||
|
|
3d79b5bb2a | ||
|
|
3dd5c91ce7 | ||
|
|
02ad98c024 | ||
|
|
a7b906ada6 | ||
|
|
a62ca9a226 | ||
|
|
02030a8e2d | ||
|
|
63ca5ee313 | ||
|
|
77632880d1 | ||
|
|
20fa8feab0 | ||
|
|
be55c7bdd9 | ||
|
|
a4288aa871 | ||
|
|
c0f15ac7ff | ||
|
|
4047d433f5 | ||
|
|
0699f0003c | ||
|
|
91d6769d0f | ||
|
|
ad378956bf | ||
|
|
9dcfb6dc1e | ||
|
|
2d0b21d3f2 | ||
|
|
3287c85300 | ||
|
|
fd2682bc6a | ||
|
|
9a2ef5fe48 | ||
|
|
7bd55caed7 | ||
|
|
ae36f5100a |
185
.github/workflows/build.yml
vendored
185
.github/workflows/build.yml
vendored
@@ -1,11 +1,14 @@
|
|||||||
name: MoviePilot Builder
|
name: MoviePilot Builder v2
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- v2
|
||||||
paths:
|
paths:
|
||||||
- version.py
|
- '.github/workflows/build.yml'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'version.py'
|
||||||
|
- 'requirements.in'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
Docker-build:
|
Docker-build:
|
||||||
@@ -25,7 +28,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ secrets.DOCKER_USERNAME }}/moviepilot
|
images: ${{ secrets.DOCKER_USERNAME }}/moviepilot-v2
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=${{ env.app_version }}
|
type=raw,value=${{ env.app_version }}
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
@@ -51,181 +54,25 @@ jobs:
|
|||||||
linux/amd64
|
linux/amd64
|
||||||
linux/arm64/v8
|
linux/arm64/v8
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
|
||||||
MOVIEPILOT_VERSION=${{ env.app_version }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
||||||
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
||||||
|
|
||||||
Windows-build:
|
- name: Delete Release
|
||||||
runs-on: windows-latest
|
uses: dev-drprasad/delete-tag-and-release@v1.1
|
||||||
name: Build Windows Binary
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Init Python 3.11.4
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
with:
|
||||||
python-version: '3.11.4'
|
tag_name: ${{ env.app_version }}
|
||||||
cache: 'pip'
|
delete_release: true
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Install Dependent Packages
|
- name: Generate Release
|
||||||
run: |
|
uses: softprops/action-gh-release@v2
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install wheel pyinstaller
|
|
||||||
pip install -r requirements.txt
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Prepare Frontend
|
|
||||||
run: |
|
|
||||||
# 下载nginx
|
|
||||||
Invoke-WebRequest -Uri "http://nginx.org/download/nginx-1.25.2.zip" -OutFile "nginx.zip"
|
|
||||||
Expand-Archive -Path "nginx.zip" -DestinationPath "nginx-1.25.2"
|
|
||||||
Move-Item -Path "nginx-1.25.2/nginx-1.25.2" -Destination "nginx"
|
|
||||||
Remove-Item -Path "nginx.zip"
|
|
||||||
Remove-Item -Path "nginx-1.25.2" -Recurse -Force
|
|
||||||
# 下载前端
|
|
||||||
$FRONTEND_VERSION = (Invoke-WebRequest -Uri "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | ConvertFrom-Json).tag_name
|
|
||||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/$FRONTEND_VERSION/dist.zip" -OutFile "dist.zip"
|
|
||||||
Expand-Archive -Path "dist.zip" -DestinationPath "dist"
|
|
||||||
Move-Item -Path "dist/dist/*" -Destination "nginx/html" -Force
|
|
||||||
Remove-Item -Path "dist.zip"
|
|
||||||
Remove-Item -Path "dist" -Recurse -Force
|
|
||||||
Move-Item -Path "nginx/html/nginx.conf" -Destination "nginx/conf/nginx.conf" -Force
|
|
||||||
New-Item -Path "nginx/temp" -ItemType Directory -Force
|
|
||||||
New-Item -Path "nginx/temp/__keep__.txt" -ItemType File -Force
|
|
||||||
New-Item -Path "nginx/logs" -ItemType Directory -Force
|
|
||||||
New-Item -Path "nginx/logs/__keep__.txt" -ItemType File -Force
|
|
||||||
# 下载插件 jxxghp
|
|
||||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
|
|
||||||
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
|
|
||||||
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item -Path "MoviePilot-Plugins-main.zip"
|
|
||||||
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
|
|
||||||
# 下载插件 thsrite
|
|
||||||
Invoke-WebRequest -Uri "https://github.com/thsrite/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
|
|
||||||
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
|
|
||||||
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item -Path "MoviePilot-Plugins-main.zip"
|
|
||||||
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
|
|
||||||
# 下载插件 honue
|
|
||||||
Invoke-WebRequest -Uri "https://github.com/honue/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
|
|
||||||
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
|
|
||||||
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item -Path "MoviePilot-Plugins-main.zip"
|
|
||||||
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
|
|
||||||
# 下载插件 InfinityPacer
|
|
||||||
Invoke-WebRequest -Uri "https://github.com/InfinityPacer/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
|
|
||||||
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
|
|
||||||
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item -Path "MoviePilot-Plugins-main.zip"
|
|
||||||
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
|
|
||||||
# 下载资源
|
|
||||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" -OutFile "MoviePilot-Resources-main.zip"
|
|
||||||
Expand-Archive -Path "MoviePilot-Resources-main.zip" -DestinationPath "MoviePilot-Resources-main"
|
|
||||||
Move-Item -Path "MoviePilot-Resources-main/MoviePilot-Resources-main/resources/*" -Destination "app/helper/" -Force
|
|
||||||
Remove-Item -Path "MoviePilot-Resources-main.zip"
|
|
||||||
Remove-Item -Path "MoviePilot-Resources-main" -Recurse -Force
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Pyinstaller
|
|
||||||
run: |
|
|
||||||
pyinstaller frozen.spec
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Upload Windows File
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: windows
|
|
||||||
path: dist/MoviePilot.exe
|
|
||||||
|
|
||||||
Linux-build-amd64:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Build Linux Amd64
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Init Python 3.11.4
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: '3.11.4'
|
|
||||||
cache: 'pip'
|
|
||||||
|
|
||||||
- name: Install Dependent Packages
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install wheel pyinstaller
|
|
||||||
pip install -r requirements.txt
|
|
||||||
find app/plugins -name requirements.txt -exec pip install -r {} \;
|
|
||||||
|
|
||||||
- name: Prepare Frontend
|
|
||||||
run: |
|
|
||||||
wget https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip
|
|
||||||
unzip main.zip
|
|
||||||
mv MoviePilot-Plugins-main/plugins/* app/plugins/
|
|
||||||
rm main.zip
|
|
||||||
rm -rf MoviePilot-Plugins-main
|
|
||||||
|
|
||||||
wget https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip
|
|
||||||
unzip main.zip
|
|
||||||
mv MoviePilot-Resources-main/resources/* app/helper/
|
|
||||||
rm main.zip
|
|
||||||
rm -rf MoviePilot-Resources-main
|
|
||||||
|
|
||||||
- name: Pyinstaller
|
|
||||||
run: |
|
|
||||||
pyinstaller frozen.spec
|
|
||||||
mv dist/MoviePilot dist/MoviePilot_Amd64
|
|
||||||
|
|
||||||
- name: Upload Linux File
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: linux-amd64
|
|
||||||
path: dist/MoviePilot_Amd64
|
|
||||||
|
|
||||||
Create-release:
|
|
||||||
permissions: write-all
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [ Windows-build, Docker-build, Linux-build-amd64]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Release Version
|
|
||||||
id: release_version
|
|
||||||
run: |
|
|
||||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
|
||||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Download Artifact
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
|
|
||||||
- name: get release_informations
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
mkdir releases
|
|
||||||
mv ./windows/MoviePilot.exe ./releases/MoviePilot_Win_v${{ env.app_version }}.exe
|
|
||||||
mv ./linux-amd64/MoviePilot_Amd64 ./releases/MoviePilot_Amd64_v${{ env.app_version }}
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
id: create_release
|
|
||||||
uses: actions/create-release@latest
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
with:
|
||||||
tag_name: v${{ env.app_version }}
|
tag_name: v${{ env.app_version }}
|
||||||
release_name: v${{ env.app_version }}
|
name: v${{ env.app_version }}
|
||||||
body: ${{ github.event.commits[0].message }}
|
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
make_latest: false
|
||||||
- name: Upload Release Asset
|
|
||||||
uses: dwenegar/upload-release-assets@v1
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
|
||||||
release_id: ${{ steps.create_release.outputs.id }}
|
|
||||||
assets_path: |
|
|
||||||
./releases/
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,6 +4,7 @@ build/
|
|||||||
dist/
|
dist/
|
||||||
nginx/
|
nginx/
|
||||||
test.py
|
test.py
|
||||||
|
safety_report.txt
|
||||||
app/helper/sites.py
|
app/helper/sites.py
|
||||||
app/helper/*.so
|
app/helper/*.so
|
||||||
app/helper/*.pyd
|
app/helper/*.pyd
|
||||||
@@ -13,6 +14,9 @@ app/plugins/**
|
|||||||
config/cookies/**
|
config/cookies/**
|
||||||
config/user.db
|
config/user.db
|
||||||
config/sites/**
|
config/sites/**
|
||||||
|
config/logs/
|
||||||
|
config/temp/
|
||||||
|
config/cache/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.log
|
*.log
|
||||||
.vscode
|
.vscode
|
||||||
|
|||||||
26
Dockerfile
26
Dockerfile
@@ -1,17 +1,17 @@
|
|||||||
FROM python:3.11.4-slim-bookworm
|
FROM python:3.11.4-slim-bookworm
|
||||||
ARG MOVIEPILOT_VERSION
|
|
||||||
ENV LANG="C.UTF-8" \
|
ENV LANG="C.UTF-8" \
|
||||||
TZ="Asia/Shanghai" \
|
TZ="Asia/Shanghai" \
|
||||||
HOME="/moviepilot" \
|
HOME="/moviepilot" \
|
||||||
CONFIG_DIR="/config" \
|
CONFIG_DIR="/config" \
|
||||||
TERM="xterm" \
|
TERM="xterm" \
|
||||||
|
DISPLAY=:987 \
|
||||||
PUID=0 \
|
PUID=0 \
|
||||||
PGID=0 \
|
PGID=0 \
|
||||||
UMASK=000 \
|
UMASK=000 \
|
||||||
PORT=3001 \
|
PORT=3001 \
|
||||||
NGINX_PORT=3000 \
|
NGINX_PORT=3000 \
|
||||||
PROXY_HOST="" \
|
PROXY_HOST="" \
|
||||||
MOVIEPILOT_AUTO_UPDATE=release \
|
MOVIEPILOT_AUTO_UPDATE=false \
|
||||||
AUTH_SITE="iyuu" \
|
AUTH_SITE="iyuu" \
|
||||||
IYUU_SIGN=""
|
IYUU_SIGN=""
|
||||||
WORKDIR "/app"
|
WORKDIR "/app"
|
||||||
@@ -30,7 +30,6 @@ RUN apt-get update -y \
|
|||||||
busybox \
|
busybox \
|
||||||
dumb-init \
|
dumb-init \
|
||||||
jq \
|
jq \
|
||||||
haproxy \
|
|
||||||
fuse3 \
|
fuse3 \
|
||||||
rsync \
|
rsync \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
@@ -42,6 +41,7 @@ RUN apt-get update -y \
|
|||||||
then ln -s /usr/lib/aarch64-linux-musl/libc.so /lib/libc.musl-aarch64.so.1; \
|
then ln -s /usr/lib/aarch64-linux-musl/libc.so /lib/libc.musl-aarch64.so.1; \
|
||||||
fi \
|
fi \
|
||||||
&& curl https://rclone.org/install.sh | bash \
|
&& 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 autoremove -y \
|
||||||
&& apt-get clean -y \
|
&& apt-get clean -y \
|
||||||
&& rm -rf \
|
&& rm -rf \
|
||||||
@@ -49,11 +49,12 @@ RUN apt-get update -y \
|
|||||||
/moviepilot/.cache \
|
/moviepilot/.cache \
|
||||||
/var/lib/apt/lists/* \
|
/var/lib/apt/lists/* \
|
||||||
/var/tmp/*
|
/var/tmp/*
|
||||||
COPY requirements.txt requirements.txt
|
COPY requirements.in requirements.in
|
||||||
RUN apt-get update -y \
|
RUN apt-get update -y \
|
||||||
&& apt-get install -y build-essential \
|
&& apt-get install -y build-essential \
|
||||||
&& pip install --upgrade pip \
|
&& pip install --upgrade pip \
|
||||||
&& pip install Cython \
|
&& pip install Cython pip-tools \
|
||||||
|
&& pip-compile requirements.in \
|
||||||
&& pip install -r requirements.txt \
|
&& pip install -r requirements.txt \
|
||||||
&& playwright install-deps chromium \
|
&& playwright install-deps chromium \
|
||||||
&& apt-get remove -y build-essential \
|
&& apt-get remove -y build-essential \
|
||||||
@@ -68,23 +69,26 @@ COPY . .
|
|||||||
RUN cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
RUN cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
||||||
&& cp -f /app/update /usr/local/bin/mp_update \
|
&& cp -f /app/update /usr/local/bin/mp_update \
|
||||||
&& cp -f /app/entrypoint /entrypoint \
|
&& 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 \
|
&& chmod +x /entrypoint /usr/local/bin/mp_update \
|
||||||
&& mkdir -p ${HOME} /var/lib/haproxy/server-state \
|
&& mkdir -p ${HOME} \
|
||||||
&& groupadd -r moviepilot -g 911 \
|
&& groupadd -r moviepilot -g 918 \
|
||||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 911 \
|
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \
|
||||||
&& python_ver=$(python3 -V | awk '{print $2}') \
|
&& python_ver=$(python3 -V | awk '{print $2}') \
|
||||||
&& echo "/app/" > /usr/local/lib/python${python_ver%.*}/site-packages/app.pth \
|
&& 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_watches=5242880' >> /etc/sysctl.conf \
|
||||||
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
|
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
|
||||||
&& locale-gen zh_CN.UTF-8 \
|
&& locale-gen zh_CN.UTF-8 \
|
||||||
&& FRONTEND_VERSION=$(curl -sL "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | jq -r .tag_name) \
|
&& 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 / - \
|
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
|
||||||
&& mv /dist /public \
|
&& mv /dist /public \
|
||||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
&& curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
||||||
&& mv -f /tmp/MoviePilot-Plugins-main/plugins/* /app/app/plugins/ \
|
&& 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 - \
|
&& 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/ \
|
&& mv -f /tmp/MoviePilot-Resources-main/resources/* /app/app/helper/ \
|
||||||
&& rm -rf /tmp/*
|
&& rm -rf /tmp/*
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
VOLUME [ "/config" ]
|
VOLUME [ "/config" ]
|
||||||
ENTRYPOINT [ "/entrypoint" ]
|
ENTRYPOINT [ "/entrypoint" ]
|
||||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
|
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
|
||||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
|
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
|
||||||
local, transfer, mediaserver, bangumi, aliyun, u115
|
transfer, mediaserver, bangumi, storage
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
||||||
@@ -20,9 +20,7 @@ api_router.include_router(system.router, prefix="/system", tags=["system"])
|
|||||||
api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"])
|
api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"])
|
||||||
api_router.include_router(download.router, prefix="/download", tags=["download"])
|
api_router.include_router(download.router, prefix="/download", tags=["download"])
|
||||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||||
api_router.include_router(local.router, prefix="/local", tags=["local"])
|
api_router.include_router(storage.router, prefix="/storage", tags=["storage"])
|
||||||
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
|
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
|
||||||
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])
|
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])
|
||||||
api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"])
|
api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"])
|
||||||
api_router.include_router(aliyun.router, prefix="/aliyun", tags=["aliyun"])
|
|
||||||
api_router.include_router(u115.router, prefix="/u115", tags=["115"])
|
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import Any, List
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from starlette.responses import Response
|
|
||||||
|
|
||||||
from app import schemas
|
|
||||||
from app.chain.transfer import TransferChain
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.metainfo import MetaInfoPath
|
|
||||||
from app.core.security import verify_token, verify_uri_token
|
|
||||||
from app.helper.aliyun import AliyunHelper
|
|
||||||
from app.helper.progress import ProgressHelper
|
|
||||||
from app.schemas.types import ProgressKey
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/qrcode", summary="生成二维码内容", response_model=schemas.Response)
|
|
||||||
def qrcode(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
生成二维码
|
|
||||||
"""
|
|
||||||
qrcode_data, errmsg = AliyunHelper().generate_qrcode()
|
|
||||||
if qrcode_data:
|
|
||||||
return schemas.Response(success=True, data=qrcode_data)
|
|
||||||
return schemas.Response(success=False, message=errmsg)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/check", summary="二维码登录确认", response_model=schemas.Response)
|
|
||||||
def check(ck: str, t: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
二维码登录确认
|
|
||||||
"""
|
|
||||||
if not ck or not t:
|
|
||||||
return schemas.Response(success=False, message="参数错误")
|
|
||||||
data, errmsg = AliyunHelper().check_login(ck, t)
|
|
||||||
if data:
|
|
||||||
return schemas.Response(success=True, data=data)
|
|
||||||
return schemas.Response(success=False, message=errmsg)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/userinfo", summary="查询用户信息", response_model=schemas.Response)
|
|
||||||
def userinfo(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
查询用户信息
|
|
||||||
"""
|
|
||||||
aliyunhelper = AliyunHelper()
|
|
||||||
# 查询用户信息返回
|
|
||||||
info = aliyunhelper.user_info()
|
|
||||||
if info:
|
|
||||||
return schemas.Response(success=True, data=info)
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/list", summary="所有目录和文件(阿里云盘)", response_model=List[schemas.FileItem])
|
|
||||||
def list_aliyun(fileitem: schemas.FileItem,
|
|
||||||
sort: str = 'updated_at',
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
查询当前目录下所有目录和文件
|
|
||||||
:param fileitem: 文件夹信息
|
|
||||||
:param sort: 排序方式,name:按名称排序,time:按修改时间排序
|
|
||||||
:param _: token
|
|
||||||
:return: 所有目录和文件
|
|
||||||
"""
|
|
||||||
if not fileitem.fileid:
|
|
||||||
return []
|
|
||||||
if not fileitem.path:
|
|
||||||
path = "/"
|
|
||||||
else:
|
|
||||||
path = fileitem.path
|
|
||||||
if sort == "time":
|
|
||||||
sort = "updated_at"
|
|
||||||
if fileitem.type == "file":
|
|
||||||
fileitem = AliyunHelper().detail(drive_id=fileitem.drive_id, file_id=fileitem.fileid, path=path)
|
|
||||||
if fileitem:
|
|
||||||
return [fileitem]
|
|
||||||
return []
|
|
||||||
return AliyunHelper().list(drive_id=fileitem.drive_id,
|
|
||||||
parent_file_id=fileitem.fileid,
|
|
||||||
path=path,
|
|
||||||
order_by=sort)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mkdir", summary="创建目录(阿里云盘)", response_model=schemas.Response)
|
|
||||||
def mkdir_aliyun(fileitem: schemas.FileItem,
|
|
||||||
name: str,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
创建目录
|
|
||||||
"""
|
|
||||||
if not fileitem.fileid or not name:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
result = AliyunHelper().create_folder(drive_id=fileitem.drive_id, parent_file_id=fileitem.fileid,
|
|
||||||
name=name, path=fileitem.path)
|
|
||||||
if result:
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/delete", summary="删除文件或目录(阿里云盘)", response_model=schemas.Response)
|
|
||||||
def delete_aliyun(fileitem: schemas.FileItem,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
删除文件或目录
|
|
||||||
"""
|
|
||||||
if not fileitem.fileid:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
result = AliyunHelper().delete(drive_id=fileitem.drive_id, file_id=fileitem.fileid)
|
|
||||||
if result:
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/download", summary="下载文件(阿里云盘)")
|
|
||||||
def download_aliyun(fileid: str,
|
|
||||||
drive_id: str = None,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
|
|
||||||
"""
|
|
||||||
下载文件或目录
|
|
||||||
"""
|
|
||||||
if not fileid:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
url = AliyunHelper().download(drive_id=drive_id, file_id=fileid)
|
|
||||||
if url:
|
|
||||||
# 重定向
|
|
||||||
return Response(status_code=302, headers={"Location": url})
|
|
||||||
raise HTTPException(status_code=500, detail="下载文件出错")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rename", summary="重命名文件或目录(阿里云盘)", response_model=schemas.Response)
|
|
||||||
def rename_aliyun(fileitem: schemas.FileItem,
|
|
||||||
new_name: str,
|
|
||||||
recursive: bool = False,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
重命名文件或目录
|
|
||||||
"""
|
|
||||||
if not fileitem.fileid or not new_name:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
result = AliyunHelper().rename(drive_id=fileitem.drive_id, file_id=fileitem.fileid, name=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] = list_aliyun(fileitem=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_aliyun(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)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/image", summary="读取图片(阿里云盘)", response_model=schemas.Response)
|
|
||||||
def image_aliyun(fileid: str, drive_id: str = None, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
|
|
||||||
"""
|
|
||||||
读取图片
|
|
||||||
"""
|
|
||||||
if not fileid:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
url = AliyunHelper().download(drive_id=drive_id, file_id=fileid)
|
|
||||||
if url:
|
|
||||||
# 重定向
|
|
||||||
return Response(status_code=302, headers={"Location": url})
|
|
||||||
raise HTTPException(status_code=500, detail="下载图片出错")
|
|
||||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.dashboard import DashboardChain
|
from app.chain.dashboard import DashboardChain
|
||||||
|
from app.chain.storage import StorageChain
|
||||||
from app.core.security import verify_token, verify_apitoken
|
from app.core.security import verify_token, verify_apitoken
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.models.transferhistory import TransferHistory
|
from app.db.models.transferhistory import TransferHistory
|
||||||
@@ -17,11 +18,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
|
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
|
||||||
def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def statistic(name: str = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询媒体数量统计信息
|
查询媒体数量统计信息
|
||||||
"""
|
"""
|
||||||
media_statistics: Optional[List[schemas.Statistic]] = DashboardChain().media_statistic()
|
media_statistics: Optional[List[schemas.Statistic]] = DashboardChain().media_statistic(name)
|
||||||
if media_statistics:
|
if media_statistics:
|
||||||
# 汇总各媒体库统计信息
|
# 汇总各媒体库统计信息
|
||||||
ret_statistic = schemas.Statistic()
|
ret_statistic = schemas.Statistic()
|
||||||
@@ -43,23 +44,31 @@ def statistic2(_: str = Depends(verify_apitoken)) -> Any:
|
|||||||
return statistic()
|
return statistic()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/storage", summary="存储空间", response_model=schemas.Storage)
|
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
|
||||||
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询存储空间信息
|
查询本地存储空间信息
|
||||||
"""
|
"""
|
||||||
library_dirs = DirectoryHelper().get_library_dirs()
|
total, available = 0, 0
|
||||||
total_storage, free_storage = SystemUtils.space_usage([Path(d.path) for d in library_dirs if d.path])
|
dirs = DirectoryHelper().get_dirs()
|
||||||
|
if not dirs:
|
||||||
|
return schemas.Storage(total_storage=total, used_storage=total - available)
|
||||||
|
storages = set([d.library_storage for d in dirs if d.library_storage])
|
||||||
|
for _storage in storages:
|
||||||
|
_usage = StorageChain().storage_usage(_storage)
|
||||||
|
if _usage:
|
||||||
|
total += _usage.total
|
||||||
|
available += _usage.available
|
||||||
return schemas.Storage(
|
return schemas.Storage(
|
||||||
total_storage=total_storage,
|
total_storage=total,
|
||||||
used_storage=total_storage - free_storage
|
used_storage=total - available
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/storage2", summary="存储空间(API_TOKEN)", response_model=schemas.Storage)
|
@router.get("/storage2", summary="本地存储空间(API_TOKEN)", response_model=schemas.Storage)
|
||||||
def storage2(_: str = Depends(verify_apitoken)) -> Any:
|
def storage2(_: str = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询存储空间信息 API_TOKEN认证(?token=xxx)
|
查询本地存储空间信息 API_TOKEN认证(?token=xxx)
|
||||||
"""
|
"""
|
||||||
return storage()
|
return storage()
|
||||||
|
|
||||||
@@ -73,16 +82,16 @@ def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
|
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
|
||||||
def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def downloader(name: str = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询下载器信息
|
查询下载器信息
|
||||||
"""
|
"""
|
||||||
# 下载目录空间
|
# 下载目录空间
|
||||||
download_dirs = DirectoryHelper().get_download_dirs()
|
download_dirs = DirectoryHelper().get_local_download_dirs()
|
||||||
_, free_space = SystemUtils.space_usage([Path(d.path) for d in download_dirs if d.path])
|
_, free_space = SystemUtils.space_usage([Path(d.download_path) for d in download_dirs])
|
||||||
# 下载器信息
|
# 下载器信息
|
||||||
downloader_info = schemas.DownloaderInfo()
|
downloader_info = schemas.DownloaderInfo()
|
||||||
transfer_infos = DashboardChain().downloader_info()
|
transfer_infos = DashboardChain().downloader_info(name)
|
||||||
if transfer_infos:
|
if transfer_infos:
|
||||||
for transfer_info in transfer_infos:
|
for transfer_info in transfer_infos:
|
||||||
downloader_info.download_speed += transfer_info.download_speed
|
downloader_info.download_speed += transfer_info.download_speed
|
||||||
|
|||||||
@@ -1,33 +1,16 @@
|
|||||||
from typing import List, Any
|
from typing import Any, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Response
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.douban import DoubanChain
|
from app.chain.douban import DoubanChain
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
from app.utils.http import RequestUtils
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/img", summary="豆瓣图片代理")
|
|
||||||
def douban_img(imgurl: str) -> Any:
|
|
||||||
"""
|
|
||||||
豆瓣图片代理
|
|
||||||
"""
|
|
||||||
if not imgurl:
|
|
||||||
return None
|
|
||||||
response = RequestUtils(headers={
|
|
||||||
'Referer': "https://movie.douban.com/"
|
|
||||||
}, ua=settings.USER_AGENT).get_res(url=imgurl)
|
|
||||||
if response:
|
|
||||||
return Response(content=response.content, media_type="image/jpeg")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||||
def douban_person(person_id: int,
|
def douban_person(person_id: int,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
@@ -176,7 +159,6 @@ def tv_hot(page: int = 1,
|
|||||||
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson])
|
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson])
|
||||||
def douban_credits(doubanid: str,
|
def douban_credits(doubanid: str,
|
||||||
type_name: str,
|
type_name: str,
|
||||||
page: int = 1,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据豆瓣ID查询演员阵容,type_name: 电影/电视剧
|
根据豆瓣ID查询演员阵容,type_name: 电影/电视剧
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Body
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
@@ -9,24 +9,27 @@ from app.core.context import MediaInfo, Context, TorrentInfo
|
|||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
from app.db.models.user import User
|
from app.db.models.user import User
|
||||||
from app.db.userauth import get_current_active_user
|
from app.db.user_oper import get_current_active_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
||||||
def read(
|
def list(
|
||||||
|
name: str = None,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询正在下载的任务
|
查询正在下载的任务
|
||||||
"""
|
"""
|
||||||
return DownloadChain().downloading()
|
return DownloadChain().downloading(name)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response)
|
@router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response)
|
||||||
def download(
|
def download(
|
||||||
media_in: schemas.MediaInfo,
|
media_in: schemas.MediaInfo,
|
||||||
torrent_in: schemas.TorrentInfo,
|
torrent_in: schemas.TorrentInfo,
|
||||||
|
downloader: str = Body(None),
|
||||||
|
save_path: str = Body(None),
|
||||||
current_user: User = Depends(get_current_active_user)) -> Any:
|
current_user: User = Depends(get_current_active_user)) -> Any:
|
||||||
"""
|
"""
|
||||||
添加下载任务(含媒体信息)
|
添加下载任务(含媒体信息)
|
||||||
@@ -45,7 +48,8 @@ def download(
|
|||||||
media_info=mediainfo,
|
media_info=mediainfo,
|
||||||
torrent_info=torrentinfo
|
torrent_info=torrentinfo
|
||||||
)
|
)
|
||||||
did = DownloadChain().download_single(context=context, username=current_user.name)
|
did = DownloadChain().download_single(context=context, username=current_user.name,
|
||||||
|
downloader=downloader, save_path=save_path)
|
||||||
if not did:
|
if not did:
|
||||||
return schemas.Response(success=False, message="任务添加失败")
|
return schemas.Response(success=False, message="任务添加失败")
|
||||||
return schemas.Response(success=True, data={
|
return schemas.Response(success=True, data={
|
||||||
@@ -56,6 +60,8 @@ def download(
|
|||||||
@router.post("/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response)
|
@router.post("/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response)
|
||||||
def add(
|
def add(
|
||||||
torrent_in: schemas.TorrentInfo,
|
torrent_in: schemas.TorrentInfo,
|
||||||
|
downloader: str = Body(None),
|
||||||
|
save_path: str = Body(None),
|
||||||
current_user: User = Depends(get_current_active_user)) -> Any:
|
current_user: User = Depends(get_current_active_user)) -> Any:
|
||||||
"""
|
"""
|
||||||
添加下载任务(不含媒体信息)
|
添加下载任务(不含媒体信息)
|
||||||
@@ -75,7 +81,8 @@ def add(
|
|||||||
media_info=mediainfo,
|
media_info=mediainfo,
|
||||||
torrent_info=torrentinfo
|
torrent_info=torrentinfo
|
||||||
)
|
)
|
||||||
did = DownloadChain().download_single(context=context, username=current_user.name)
|
did = DownloadChain().download_single(context=context, username=current_user.name,
|
||||||
|
downloader=downloader, save_path=save_path)
|
||||||
if not did:
|
if not did:
|
||||||
return schemas.Response(success=False, message="任务添加失败")
|
return schemas.Response(success=False, message="任务添加失败")
|
||||||
return schemas.Response(success=True, data={
|
return schemas.Response(success=True, data={
|
||||||
@@ -95,9 +102,8 @@ def start(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
|
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
|
||||||
def stop(
|
def stop(hashString: str,
|
||||||
hashString: str,
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
"""
|
||||||
暂停下载任务
|
暂停下载任务
|
||||||
"""
|
"""
|
||||||
@@ -106,9 +112,8 @@ def stop(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
||||||
def info(
|
def delete(hashString: str,
|
||||||
hashString: str,
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
"""
|
||||||
删除下载任务
|
删除下载任务
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import List, Any
|
from typing import List, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.transfer import TransferChain
|
from app.chain.storage import StorageChain
|
||||||
from app.core.event import eventmanager
|
from app.core.event import eventmanager
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.db.models.downloadhistory import DownloadHistory
|
from app.db.models.downloadhistory import DownloadHistory
|
||||||
from app.db.models.transferhistory import TransferHistory
|
from app.db.models.transferhistory import TransferHistory
|
||||||
from app.db.userauth import get_current_active_superuser
|
from app.db.user_oper import get_current_active_superuser
|
||||||
from app.schemas.types import EventType
|
from app.schemas.types import EventType, MediaType
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -77,23 +76,26 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
|||||||
deletesrc: bool = False,
|
deletesrc: bool = False,
|
||||||
deletedest: bool = False,
|
deletedest: bool = False,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
删除转移历史记录
|
删除转移历史记录
|
||||||
"""
|
"""
|
||||||
history = TransferHistory.get(db, history_in.id)
|
history: TransferHistory = TransferHistory.get(db, history_in.id)
|
||||||
if not history:
|
if not history:
|
||||||
return schemas.Response(success=False, msg="记录不存在")
|
return schemas.Response(success=False, message="记录不存在")
|
||||||
# 册除媒体库文件
|
# 册除媒体库文件
|
||||||
if deletedest and history.dest:
|
if deletedest and history.dest_fileitem:
|
||||||
state, msg = TransferChain().delete_files(Path(history.dest))
|
dest_fileitem = schemas.FileItem(**history.dest_fileitem)
|
||||||
|
state = StorageChain().delete_media_file(fileitem=dest_fileitem, mtype=MediaType(history.type))
|
||||||
if not state:
|
if not state:
|
||||||
return schemas.Response(success=False, msg=msg)
|
return schemas.Response(success=False, message=f"{dest_fileitem.path} 删除失败")
|
||||||
|
|
||||||
# 删除源文件
|
# 删除源文件
|
||||||
if deletesrc and history.src:
|
if deletesrc and history.src_fileitem:
|
||||||
state, msg = TransferChain().delete_files(Path(history.src))
|
src_fileitem = schemas.FileItem(**history.src_fileitem)
|
||||||
|
state = StorageChain().delete_media_file(src_fileitem)
|
||||||
if not state:
|
if not state:
|
||||||
return schemas.Response(success=False, msg=msg)
|
return schemas.Response(success=False, message=f"{src_fileitem.path} 删除失败")
|
||||||
# 发送事件
|
# 发送事件
|
||||||
eventmanager.send_event(
|
eventmanager.send_event(
|
||||||
EventType.DownloadFileDeleted,
|
EventType.DownloadFileDeleted,
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, List
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from starlette.responses import FileResponse, Response
|
|
||||||
|
|
||||||
from app import schemas
|
|
||||||
from app.chain.transfer import TransferChain
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.metainfo import MetaInfoPath
|
|
||||||
from app.core.security import verify_token, verify_uri_token
|
|
||||||
from app.helper.progress import ProgressHelper
|
|
||||||
from app.log import logger
|
|
||||||
from app.schemas.types import ProgressKey
|
|
||||||
from app.utils.system import SystemUtils
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
IMAGE_TYPES = [".jpg", ".png", ".gif", ".bmp", ".jpeg", ".webp"]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/list", summary="所有目录和文件(本地)", response_model=List[schemas.FileItem])
|
|
||||||
def list_local(fileitem: schemas.FileItem,
|
|
||||||
sort: str = 'time',
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
查询当前目录下所有目录和文件
|
|
||||||
:param fileitem: 文件项
|
|
||||||
:param sort: 排序方式,name:按名称排序,time:按修改时间排序
|
|
||||||
:param _: token
|
|
||||||
:return: 所有目录和文件
|
|
||||||
"""
|
|
||||||
# 返回结果
|
|
||||||
ret_items = []
|
|
||||||
path = fileitem.path
|
|
||||||
if not fileitem.path or fileitem.path == "/":
|
|
||||||
if SystemUtils.is_windows():
|
|
||||||
partitions = SystemUtils.get_windows_drives() or ["C:/"]
|
|
||||||
for partition in partitions:
|
|
||||||
ret_items.append(schemas.FileItem(
|
|
||||||
type="dir",
|
|
||||||
path=partition + "/",
|
|
||||||
name=partition,
|
|
||||||
basename=partition
|
|
||||||
))
|
|
||||||
return ret_items
|
|
||||||
else:
|
|
||||||
path = "/"
|
|
||||||
else:
|
|
||||||
if SystemUtils.is_windows():
|
|
||||||
path = path.lstrip("/")
|
|
||||||
elif not path.startswith("/"):
|
|
||||||
path = "/" + path
|
|
||||||
|
|
||||||
# 遍历目录
|
|
||||||
path_obj = Path(path)
|
|
||||||
if not path_obj.exists():
|
|
||||||
logger.warn(f"目录不存在:{path}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 如果是文件
|
|
||||||
if path_obj.is_file():
|
|
||||||
ret_items.append(schemas.FileItem(
|
|
||||||
type="file",
|
|
||||||
path=str(path_obj).replace("\\", "/"),
|
|
||||||
name=path_obj.name,
|
|
||||||
basename=path_obj.stem,
|
|
||||||
extension=path_obj.suffix[1:],
|
|
||||||
size=path_obj.stat().st_size,
|
|
||||||
modify_time=path_obj.stat().st_mtime,
|
|
||||||
))
|
|
||||||
return ret_items
|
|
||||||
|
|
||||||
# 扁历所有目录
|
|
||||||
for item in SystemUtils.list_sub_directory(path_obj):
|
|
||||||
ret_items.append(schemas.FileItem(
|
|
||||||
type="dir",
|
|
||||||
path=str(item).replace("\\", "/") + "/",
|
|
||||||
name=item.name,
|
|
||||||
basename=item.stem,
|
|
||||||
modify_time=item.stat().st_mtime,
|
|
||||||
))
|
|
||||||
|
|
||||||
# 遍历所有文件,不含子目录
|
|
||||||
for item in SystemUtils.list_sub_files(path_obj,
|
|
||||||
settings.RMT_MEDIAEXT
|
|
||||||
+ settings.RMT_SUBEXT
|
|
||||||
+ IMAGE_TYPES
|
|
||||||
+ [".nfo"]):
|
|
||||||
ret_items.append(schemas.FileItem(
|
|
||||||
type="file",
|
|
||||||
path=str(item).replace("\\", "/"),
|
|
||||||
name=item.name,
|
|
||||||
basename=item.stem,
|
|
||||||
extension=item.suffix[1:],
|
|
||||||
size=item.stat().st_size,
|
|
||||||
modify_time=item.stat().st_mtime,
|
|
||||||
))
|
|
||||||
# 排序
|
|
||||||
if sort == 'time':
|
|
||||||
ret_items.sort(key=lambda x: x.modify_time, reverse=True)
|
|
||||||
else:
|
|
||||||
ret_items.sort(key=lambda x: x.name, reverse=False)
|
|
||||||
return ret_items
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/listdir", summary="所有目录(本地,不含文件)", response_model=List[schemas.FileItem])
|
|
||||||
def list_local_dir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
查询当前目录下所有目录
|
|
||||||
"""
|
|
||||||
# 返回结果
|
|
||||||
ret_items = []
|
|
||||||
if not path or path == "/":
|
|
||||||
if SystemUtils.is_windows():
|
|
||||||
partitions = SystemUtils.get_windows_drives() or ["C:/"]
|
|
||||||
for partition in partitions:
|
|
||||||
ret_items.append(schemas.FileItem(
|
|
||||||
type="dir",
|
|
||||||
path=partition + "/",
|
|
||||||
name=partition,
|
|
||||||
children=[]
|
|
||||||
))
|
|
||||||
return ret_items
|
|
||||||
else:
|
|
||||||
path = "/"
|
|
||||||
else:
|
|
||||||
if not SystemUtils.is_windows() and not path.startswith("/"):
|
|
||||||
path = "/" + path
|
|
||||||
|
|
||||||
# 遍历目录
|
|
||||||
path_obj = Path(path)
|
|
||||||
if not path_obj.exists():
|
|
||||||
logger.warn(f"目录不存在:{path}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 扁历所有目录
|
|
||||||
for item in SystemUtils.list_sub_directory(path_obj):
|
|
||||||
ret_items.append(schemas.FileItem(
|
|
||||||
type="dir",
|
|
||||||
path=str(item).replace("\\", "/") + "/",
|
|
||||||
name=item.name,
|
|
||||||
children=[]
|
|
||||||
))
|
|
||||||
return ret_items
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mkdir", summary="创建目录(本地)", response_model=schemas.Response)
|
|
||||||
def mkdir_local(fileitem: schemas.FileItem,
|
|
||||||
name: str,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
创建目录
|
|
||||||
"""
|
|
||||||
if not fileitem.path:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
path_obj = Path(fileitem.path) / name
|
|
||||||
if path_obj.exists():
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
path_obj.mkdir(parents=True, exist_ok=True)
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/delete", summary="删除文件或目录(本地)", response_model=schemas.Response)
|
|
||||||
def delete_local(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
删除文件或目录
|
|
||||||
"""
|
|
||||||
if not fileitem.path:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
path_obj = Path(fileitem.path)
|
|
||||||
if not path_obj.exists():
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
if path_obj.is_file():
|
|
||||||
path_obj.unlink()
|
|
||||||
else:
|
|
||||||
shutil.rmtree(path_obj, ignore_errors=True)
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/download", summary="下载文件(本地)")
|
|
||||||
def download_local(path: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
|
|
||||||
"""
|
|
||||||
下载文件或目录
|
|
||||||
"""
|
|
||||||
if not path:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
path_obj = Path(path)
|
|
||||||
if not path_obj.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="文件不存在")
|
|
||||||
if path_obj.is_file():
|
|
||||||
# 做为文件流式下载
|
|
||||||
return FileResponse(path_obj)
|
|
||||||
else:
|
|
||||||
# 做为压缩包下载
|
|
||||||
shutil.make_archive(base_name=path_obj.stem, format="zip", root_dir=path_obj)
|
|
||||||
reponse = Response(content=path_obj.read_bytes(), media_type="application/zip")
|
|
||||||
# 删除压缩包
|
|
||||||
Path(f"{path_obj.stem}.zip").unlink()
|
|
||||||
return reponse
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rename", summary="重命名文件或目录(本地)", response_model=schemas.Response)
|
|
||||||
def rename_local(fileitem: schemas.FileItem,
|
|
||||||
new_name: str,
|
|
||||||
recursive: bool = False,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
重命名文件或目录
|
|
||||||
"""
|
|
||||||
if not fileitem.path or not new_name:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
path_obj = Path(fileitem.path)
|
|
||||||
if not path_obj.exists():
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
path_obj.rename(path_obj.parent / new_name)
|
|
||||||
if recursive:
|
|
||||||
transferchain = TransferChain()
|
|
||||||
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
|
|
||||||
# 递归修改目录内文件(智能识别命名)
|
|
||||||
sub_files: List[schemas.FileItem] = list_local(fileitem=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(sub_file.path)
|
|
||||||
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_local(fileitem, 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)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/image", summary="读取图片(本地)")
|
|
||||||
def image_local(path: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
|
|
||||||
"""
|
|
||||||
读取图片
|
|
||||||
"""
|
|
||||||
if not path:
|
|
||||||
return None
|
|
||||||
path_obj = Path(path)
|
|
||||||
if not path_obj.exists():
|
|
||||||
return None
|
|
||||||
if not path_obj.is_file():
|
|
||||||
return None
|
|
||||||
# 判断是否图片文件
|
|
||||||
if path_obj.suffix.lower() not in IMAGE_TYPES:
|
|
||||||
raise HTTPException(status_code=500, detail="图片读取出错")
|
|
||||||
return Response(content=path_obj.read_bytes(), media_type="image/jpeg")
|
|
||||||
@@ -1,77 +1,50 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Form
|
from fastapi import APIRouter, Depends, Form, HTTPException
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.tmdb import TmdbChain
|
from app.chain.tmdb import TmdbChain
|
||||||
from app.chain.user import UserChain
|
from app.chain.user import UserChain
|
||||||
|
from app.chain.mediaserver import MediaServerChain
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import get_password_hash
|
|
||||||
from app.db import get_db
|
|
||||||
from app.db.models.user import User
|
|
||||||
from app.helper.sites import SitesHelper
|
from app.helper.sites import SitesHelper
|
||||||
from app.log import logger
|
|
||||||
from app.utils.web import WebUtils
|
from app.utils.web import WebUtils
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
|
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
|
||||||
async def login_access_token(
|
def login_access_token(
|
||||||
db: Session = Depends(get_db),
|
|
||||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
otp_password: str = Form(None)
|
otp_password: str = Form(None)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
获取认证Token
|
获取认证Token
|
||||||
"""
|
"""
|
||||||
# 检查数据库
|
success, user_or_message = UserChain().user_authenticate(username=form_data.username,
|
||||||
success, user = User.authenticate(
|
password=form_data.password,
|
||||||
db=db,
|
mfa_code=otp_password)
|
||||||
name=form_data.username,
|
|
||||||
password=form_data.password,
|
|
||||||
otp_password=otp_password
|
|
||||||
)
|
|
||||||
if not success:
|
if not success:
|
||||||
# 认证不成功
|
raise HTTPException(status_code=401, detail=user_or_message)
|
||||||
if not user:
|
|
||||||
# 未找到用户,请求协助认证
|
|
||||||
logger.warn(f"登录用户 {form_data.username} 本地不存在,尝试辅助认证 ...")
|
|
||||||
token = UserChain().user_authenticate(form_data.username, form_data.password)
|
|
||||||
if not token:
|
|
||||||
logger.warn(f"用户 {form_data.username} 登录失败!")
|
|
||||||
raise HTTPException(status_code=401, detail="用户名、密码、二次校验码不正确")
|
|
||||||
else:
|
|
||||||
logger.info(f"用户 {form_data.username} 辅助认证成功,用户信息: {token},以普通用户登录...")
|
|
||||||
# 加入用户信息表
|
|
||||||
logger.info(f"创建用户: {form_data.username}")
|
|
||||||
user = User(name=form_data.username, is_active=True,
|
|
||||||
is_superuser=False, hashed_password=get_password_hash(token))
|
|
||||||
user.create(db)
|
|
||||||
else:
|
|
||||||
# 用户存在,但认证失败
|
|
||||||
logger.warn(f"用户 {user.name} 登录失败!")
|
|
||||||
raise HTTPException(status_code=401, detail="用户名、密码或二次校验码不正确")
|
|
||||||
elif user and not user.is_active:
|
|
||||||
raise HTTPException(status_code=403, detail="用户未启用")
|
|
||||||
logger.info(f"用户 {user.name} 登录成功!")
|
|
||||||
level = SitesHelper().auth_level
|
level = SitesHelper().auth_level
|
||||||
return schemas.Token(
|
return schemas.Token(
|
||||||
access_token=security.create_access_token(
|
access_token=security.create_access_token(
|
||||||
userid=user.id,
|
userid=user_or_message.id,
|
||||||
username=user.name,
|
username=user_or_message.name,
|
||||||
super_user=user.is_superuser,
|
super_user=user_or_message.is_superuser,
|
||||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
level=level
|
level=level
|
||||||
),
|
),
|
||||||
token_type="bearer",
|
token_type="bearer",
|
||||||
super_user=user.is_superuser,
|
super_user=user_or_message.is_superuser,
|
||||||
user_name=user.name,
|
user_id=user_or_message.id,
|
||||||
avatar=user.avatar,
|
user_name=user_or_message.name,
|
||||||
|
avatar=user_or_message.avatar,
|
||||||
level=level
|
level=level
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,10 +54,12 @@ def wallpaper() -> Any:
|
|||||||
"""
|
"""
|
||||||
获取登录页面电影海报
|
获取登录页面电影海报
|
||||||
"""
|
"""
|
||||||
if settings.WALLPAPER == "tmdb":
|
if settings.WALLPAPER == "bing":
|
||||||
url = TmdbChain().get_random_wallpager()
|
|
||||||
else:
|
|
||||||
url = WebUtils.get_bing_wallpaper()
|
url = WebUtils.get_bing_wallpaper()
|
||||||
|
elif settings.WALLPAPER == "mediaserver":
|
||||||
|
url = MediaServerChain().get_latest_wallpaper()
|
||||||
|
else:
|
||||||
|
url = TmdbChain().get_random_wallpager()
|
||||||
if url:
|
if url:
|
||||||
return schemas.Response(
|
return schemas.Response(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -98,7 +73,9 @@ def wallpapers() -> Any:
|
|||||||
"""
|
"""
|
||||||
获取登录页面电影海报
|
获取登录页面电影海报
|
||||||
"""
|
"""
|
||||||
if settings.WALLPAPER == "tmdb":
|
if settings.WALLPAPER == "bing":
|
||||||
return TmdbChain().get_trending_wallpapers()
|
|
||||||
else:
|
|
||||||
return WebUtils.get_bing_wallpapers()
|
return WebUtils.get_bing_wallpapers()
|
||||||
|
elif settings.WALLPAPER == "mediaserver":
|
||||||
|
return MediaServerChain().get_latest_wallpapers()
|
||||||
|
else:
|
||||||
|
return TmdbChain().get_trending_wallpapers()
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ def scrape(fileitem: schemas.FileItem,
|
|||||||
if not fileitem.fileid:
|
if not fileitem.fileid:
|
||||||
return schemas.Response(success=False, message="刮削文件ID无效")
|
return schemas.Response(success=False, message="刮削文件ID无效")
|
||||||
# 手动刮削
|
# 手动刮削
|
||||||
chain.manual_scrape(storage=storage, fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||||
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,38 +6,38 @@ from sqlalchemy.orm import Session
|
|||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.chain.mediaserver import MediaServerChain
|
from app.chain.mediaserver import MediaServerChain
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.mediaserver_oper import MediaServerOper
|
from app.db.mediaserver_oper import MediaServerOper
|
||||||
from app.db.models import MediaServerItem
|
from app.db.models import MediaServerItem
|
||||||
|
from app.helper.mediaserver import MediaServerHelper
|
||||||
from app.schemas import MediaType, NotExistMediaInfo
|
from app.schemas import MediaType, NotExistMediaInfo
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/play/{itemid}", summary="在线播放")
|
@router.get("/play/{itemid:path}", summary="在线播放")
|
||||||
def play_item(itemid: str) -> schemas.Response:
|
def play_item(itemid: str, _: schemas.TokenPayload = Depends(verify_token)) -> schemas.Response:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器播放页面地址
|
获取媒体服务器播放页面地址
|
||||||
"""
|
"""
|
||||||
if not itemid:
|
if not itemid:
|
||||||
return schemas.Response(success=False, msg="参数错误")
|
return schemas.Response(success=False, message="参数错误")
|
||||||
if not settings.MEDIASERVER:
|
configs = MediaServerHelper().get_configs()
|
||||||
return schemas.Response(success=False, msg="未配置媒体服务器")
|
if not configs:
|
||||||
# 查找一个不为空的值
|
return schemas.Response(success=False, message="未配置媒体服务器")
|
||||||
mediaserver = next((server for server in settings.MEDIASERVER.split(",") if server), None)
|
media_chain = MediaServerChain()
|
||||||
if not mediaserver:
|
for name in configs.keys():
|
||||||
return schemas.Response(success=False, msg="未配置媒体服务器")
|
item = media_chain.iteminfo(server=name, item_id=itemid)
|
||||||
play_url = MediaServerChain().get_play_url(server=mediaserver, item_id=itemid)
|
if item:
|
||||||
# 重定向到play_url
|
play_url = media_chain.get_play_url(server=name, item_id=itemid)
|
||||||
if not play_url:
|
if play_url:
|
||||||
return schemas.Response(success=False, msg="未找到播放地址")
|
return schemas.Response(success=True, data={
|
||||||
return schemas.Response(success=True, data={
|
"url": play_url
|
||||||
"url": play_url
|
})
|
||||||
})
|
return schemas.Response(success=False, message="未找到播放地址")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/exists", summary="查询本地是否存在(数据库)", response_model=schemas.Response)
|
@router.get("/exists", summary="查询本地是否存在(数据库)", response_model=schemas.Response)
|
||||||
@@ -119,26 +119,27 @@ def not_exists(media_in: schemas.MediaInfo,
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem])
|
@router.get("/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem])
|
||||||
def latest(count: int = 18,
|
def latest(server: str, count: int = 18,
|
||||||
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器最新入库条目
|
获取媒体服务器最新入库条目
|
||||||
"""
|
"""
|
||||||
return MediaServerChain().latest(count=count, username=userinfo.username) or []
|
return MediaServerChain().latest(server=server, count=count, username=userinfo.username) or []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/playing", summary="正在播放条目", response_model=List[schemas.MediaServerPlayItem])
|
@router.get("/playing", summary="正在播放条目", response_model=List[schemas.MediaServerPlayItem])
|
||||||
def playing(count: int = 12,
|
def playing(server: str, count: int = 12,
|
||||||
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器正在播放条目
|
获取媒体服务器正在播放条目
|
||||||
"""
|
"""
|
||||||
return MediaServerChain().playing(count=count, username=userinfo.username) or []
|
return MediaServerChain().playing(server=server, count=count, username=userinfo.username) or []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/library", summary="媒体库列表", response_model=List[schemas.MediaServerLibrary])
|
@router.get("/library", summary="媒体库列表", response_model=List[schemas.MediaServerLibrary])
|
||||||
def library(userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def library(server: str, hidden: bool = False,
|
||||||
|
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器媒体库列表
|
获取媒体服务器媒体库列表
|
||||||
"""
|
"""
|
||||||
return MediaServerChain().librarys(username=userinfo.username) or []
|
return MediaServerChain().librarys(server=server, username=userinfo.username, hidden=hidden) or []
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Union, Any, List
|
from typing import Union, Any, List
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||||
from fastapi import Request
|
|
||||||
from pywebpush import WebPushException, webpush
|
from pywebpush import WebPushException, webpush
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.responses import PlainTextResponse
|
from starlette.responses import PlainTextResponse
|
||||||
@@ -10,16 +9,15 @@ from starlette.responses import PlainTextResponse
|
|||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.message import MessageChain
|
from app.chain.message import MessageChain
|
||||||
from app.core.config import settings, global_vars
|
from app.core.config import settings, global_vars
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token, verify_apitoken
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.db.models.message import Message
|
from app.db.models.message import Message
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.user_oper import get_current_active_superuser
|
||||||
from app.db.userauth import get_current_active_superuser
|
from app.helper.service import ServiceConfigHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
|
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||||
from app.schemas import NotificationSwitch
|
from app.schemas.types import MessageChannel
|
||||||
from app.schemas.types import SystemConfigKey, NotificationType, MessageChannel
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -32,9 +30,10 @@ def start_message_chain(body: Any, form: Any, args: Any):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/", summary="接收用户消息", response_model=schemas.Response)
|
@router.post("/", summary="接收用户消息", response_model=schemas.Response)
|
||||||
async def user_message(background_tasks: BackgroundTasks, request: Request):
|
async def user_message(background_tasks: BackgroundTasks, request: Request,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_apitoken)):
|
||||||
"""
|
"""
|
||||||
用户消息响应
|
用户消息响应,配置请求中需要添加参数:token=API_TOKEN&source=消息配置名
|
||||||
"""
|
"""
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -50,6 +49,7 @@ def web_message(text: str, current_user: User = Depends(get_current_active_super
|
|||||||
"""
|
"""
|
||||||
MessageChain().handle_message(
|
MessageChain().handle_message(
|
||||||
channel=MessageChannel.Web,
|
channel=MessageChannel.Web,
|
||||||
|
source=current_user.name,
|
||||||
userid=current_user.name,
|
userid=current_user.name,
|
||||||
username=current_user.name,
|
username=current_user.name,
|
||||||
text=text
|
text=text
|
||||||
@@ -76,87 +76,55 @@ def get_web_message(_: schemas.TokenPayload = Depends(verify_token),
|
|||||||
return ret_messages
|
return ret_messages
|
||||||
|
|
||||||
|
|
||||||
def wechat_verify(echostr: str, msg_signature: str,
|
def wechat_verify(echostr: str, msg_signature: str, timestamp: Union[str, int], nonce: str,
|
||||||
timestamp: Union[str, int], nonce: str) -> Any:
|
source: str = None) -> Any:
|
||||||
"""
|
"""
|
||||||
微信验证响应
|
微信验证响应
|
||||||
"""
|
"""
|
||||||
|
# 获取服务配置
|
||||||
|
client_configs = ServiceConfigHelper.get_notification_configs()
|
||||||
|
if not client_configs:
|
||||||
|
return "未找到对应的消息配置"
|
||||||
|
client_config = next((config for config in client_configs if
|
||||||
|
config.type == "wechat" and config.enabled and (not source or config.name == source)), None)
|
||||||
|
if not client_config:
|
||||||
|
return "未找到对应的消息配置"
|
||||||
try:
|
try:
|
||||||
wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN,
|
wxcpt = WXBizMsgCrypt(sToken=client_config.config.get('WECHAT_TOKEN'),
|
||||||
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
|
sEncodingAESKey=client_config.config.get('WECHAT_ENCODING_AESKEY'),
|
||||||
sReceiveId=settings.WECHAT_CORPID)
|
sReceiveId=client_config.config.get('WECHAT_CORPID'))
|
||||||
|
ret, sEchoStr = wxcpt.VerifyURL(sMsgSignature=msg_signature,
|
||||||
|
sTimeStamp=timestamp,
|
||||||
|
sNonce=nonce,
|
||||||
|
sEchoStr=echostr)
|
||||||
|
if ret == 0:
|
||||||
|
# 验证URL成功,将sEchoStr返回给企业号
|
||||||
|
return PlainTextResponse(sEchoStr)
|
||||||
|
return "微信验证失败"
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"微信请求验证失败: {str(err)}")
|
logger.error(f"微信请求验证失败: {str(err)}")
|
||||||
return str(err)
|
return str(err)
|
||||||
ret, sEchoStr = wxcpt.VerifyURL(sMsgSignature=msg_signature,
|
|
||||||
sTimeStamp=timestamp,
|
|
||||||
sNonce=nonce,
|
|
||||||
sEchoStr=echostr)
|
|
||||||
if ret != 0:
|
|
||||||
logger.error("微信请求验证失败 VerifyURL ret: %s" % str(ret))
|
|
||||||
# 验证URL成功,将sEchoStr返回给企业号
|
|
||||||
return PlainTextResponse(sEchoStr)
|
|
||||||
|
|
||||||
|
|
||||||
def vocechat_verify(token: str) -> Any:
|
def vocechat_verify() -> Any:
|
||||||
"""
|
"""
|
||||||
VoceChat验证响应
|
VoceChat验证响应
|
||||||
"""
|
"""
|
||||||
if token == settings.API_TOKEN:
|
return {"status": "OK"}
|
||||||
return {"status": "OK"}
|
|
||||||
return {"status": "ERROR"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="回调请求验证")
|
@router.get("/", summary="回调请求验证")
|
||||||
def incoming_verify(token: str = None, echostr: str = None, msg_signature: str = None,
|
def incoming_verify(token: str = None, echostr: str = None, msg_signature: str = None,
|
||||||
timestamp: Union[str, int] = None, nonce: str = None) -> Any:
|
timestamp: Union[str, int] = None, nonce: str = None, source: str = None,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
微信/VoceChat等验证响应
|
微信/VoceChat等验证响应
|
||||||
"""
|
"""
|
||||||
logger.info(f"收到验证请求: token={token}, echostr={echostr}, "
|
logger.info(f"收到验证请求: token={token}, echostr={echostr}, "
|
||||||
f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}")
|
f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}")
|
||||||
if echostr and msg_signature and timestamp and nonce:
|
if echostr and msg_signature and timestamp and nonce:
|
||||||
return wechat_verify(echostr, msg_signature, timestamp, nonce)
|
return wechat_verify(echostr, msg_signature, timestamp, nonce, source)
|
||||||
return vocechat_verify(token)
|
return vocechat_verify()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/switchs", summary="查询通知消息渠道开关", response_model=List[NotificationSwitch])
|
|
||||||
def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
查询通知消息渠道开关
|
|
||||||
"""
|
|
||||||
return_list = []
|
|
||||||
# 读取数据库
|
|
||||||
switchs = SystemConfigOper().get(SystemConfigKey.NotificationChannels)
|
|
||||||
if not switchs:
|
|
||||||
for noti in NotificationType:
|
|
||||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
|
||||||
telegram=True, slack=True,
|
|
||||||
synologychat=True, vocechat=True))
|
|
||||||
else:
|
|
||||||
for switch in switchs:
|
|
||||||
return_list.append(NotificationSwitch(**switch))
|
|
||||||
for noti in NotificationType:
|
|
||||||
if not any([x.mtype == noti.value for x in return_list]):
|
|
||||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
|
||||||
telegram=True, slack=True,
|
|
||||||
synologychat=True, vocechat=True))
|
|
||||||
return return_list
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/switchs", summary="设置通知消息渠道开关", response_model=schemas.Response)
|
|
||||||
def set_switchs(switchs: List[NotificationSwitch],
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
设置通知消息渠道开关
|
|
||||||
"""
|
|
||||||
switch_list = []
|
|
||||||
for switch in switchs:
|
|
||||||
switch_list.append(switch.dict())
|
|
||||||
# 存入数据库
|
|
||||||
SystemConfigOper().set(SystemConfigKey.NotificationChannels, switch_list)
|
|
||||||
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webpush/subscribe", summary="客户端webpush通知订阅", response_model=schemas.Response)
|
@router.post("/webpush/subscribe", summary="客户端webpush通知订阅", response_model=schemas.Response)
|
||||||
|
|||||||
@@ -1,43 +1,124 @@
|
|||||||
from typing import Any, List, Annotated
|
from typing import Annotated, Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.chain.command import CommandChain
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.plugin import PluginManager
|
from app.core.plugin import PluginManager
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_apikey, verify_token
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.db.user_oper import get_current_active_superuser
|
||||||
|
from app.factory import app
|
||||||
from app.helper.plugin import PluginHelper
|
from app.helper.plugin import PluginHelper
|
||||||
|
from app.log import logger
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.schemas.types import SystemConfigKey
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def register_plugin_api(plugin_id: str = None):
|
def register_plugin_api(plugin_id: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
注册插件API(先删除后新增)
|
动态注册插件 API
|
||||||
|
:param plugin_id: 插件 ID,如果为 None,则注册所有插件
|
||||||
"""
|
"""
|
||||||
for api in PluginManager().get_plugin_apis(plugin_id):
|
_update_plugin_api_routes(plugin_id, action="add")
|
||||||
for r in router.routes:
|
|
||||||
if r.path == api.get("path"):
|
|
||||||
router.routes.remove(r)
|
|
||||||
break
|
|
||||||
router.add_api_route(**api)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_plugin_api(plugin_id: str):
|
def remove_plugin_api(plugin_id: str):
|
||||||
"""
|
"""
|
||||||
移除插件API
|
动态移除单个插件的 API
|
||||||
|
:param plugin_id: 插件 ID
|
||||||
"""
|
"""
|
||||||
for api in PluginManager().get_plugin_apis(plugin_id):
|
_update_plugin_api_routes(plugin_id, action="remove")
|
||||||
for r in router.routes:
|
|
||||||
if r.path == api.get("path"):
|
|
||||||
router.routes.remove(r)
|
def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
|
||||||
break
|
"""
|
||||||
|
插件 API 路由注册和移除
|
||||||
|
:param plugin_id: 插件 ID,如果 action 为 "add" 且 plugin_id 为 None,则处理所有插件
|
||||||
|
如果 action 为 "remove",plugin_id 必须是有效的插件 ID
|
||||||
|
:param action: "add" 或 "remove",决定是添加还是移除路由
|
||||||
|
"""
|
||||||
|
if action not in {"add", "remove"}:
|
||||||
|
raise ValueError("Action must be 'add' or 'remove'")
|
||||||
|
|
||||||
|
is_modified = False
|
||||||
|
existing_paths = {route.path: route for route in app.routes}
|
||||||
|
|
||||||
|
plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids()
|
||||||
|
for plugin_id in plugin_ids:
|
||||||
|
routes_removed = _remove_routes(plugin_id)
|
||||||
|
if routes_removed:
|
||||||
|
is_modified = True
|
||||||
|
|
||||||
|
if action != "add":
|
||||||
|
continue
|
||||||
|
# 获取插件的 API 路由信息
|
||||||
|
plugin_apis = PluginManager().get_plugin_apis(plugin_id)
|
||||||
|
for api in plugin_apis:
|
||||||
|
api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}"
|
||||||
|
try:
|
||||||
|
api["path"] = api_path
|
||||||
|
allow_anonymous = api.pop("allow_anonymous", False)
|
||||||
|
dependencies = api.setdefault("dependencies", [])
|
||||||
|
if not allow_anonymous and 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}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
|
||||||
|
|
||||||
|
if is_modified:
|
||||||
|
_clean_protected_routes(existing_paths)
|
||||||
|
app.openapi_schema = None
|
||||||
|
app.setup()
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_routes(plugin_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
移除与单个插件相关的路由
|
||||||
|
:param plugin_id: 插件 ID
|
||||||
|
:return: 是否有路由被移除
|
||||||
|
"""
|
||||||
|
if not plugin_id:
|
||||||
|
return False
|
||||||
|
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
|
||||||
|
routes_to_remove = [route for route in app.routes if route.path.startswith(prefix)]
|
||||||
|
removed = False
|
||||||
|
for route in routes_to_remove:
|
||||||
|
try:
|
||||||
|
app.routes.remove(route)
|
||||||
|
removed = True
|
||||||
|
logger.debug(f"Removed plugin route: {route.path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing plugin route {route.path}: {str(e)}")
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_protected_routes(existing_paths: dict):
|
||||||
|
"""
|
||||||
|
清理受保护的路由,防止在插件操作中被删除或重复添加
|
||||||
|
:param existing_paths: 当前应用的路由路径映射
|
||||||
|
"""
|
||||||
|
for protected_route in PROTECTED_ROUTES:
|
||||||
|
try:
|
||||||
|
existing_route = existing_paths.get(protected_route)
|
||||||
|
if existing_route:
|
||||||
|
app.routes.remove(existing_route)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing protected route {protected_route}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
||||||
def all_plugins(_: schemas.TokenPayload = Depends(verify_token), state: str = "all") -> List[schemas.Plugin]:
|
def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||||
|
state: str = "all") -> List[schemas.Plugin]:
|
||||||
"""
|
"""
|
||||||
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
||||||
"""
|
"""
|
||||||
@@ -83,7 +164,7 @@ def all_plugins(_: schemas.TokenPayload = Depends(verify_token), state: str = "a
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/installed", summary="已安装插件", response_model=List[str])
|
@router.get("/installed", summary="已安装插件", response_model=List[str])
|
||||||
def installed(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def installed(_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询用户已安装插件清单
|
查询用户已安装插件清单
|
||||||
"""
|
"""
|
||||||
@@ -102,19 +183,25 @@ def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
def install(plugin_id: str,
|
def install(plugin_id: str,
|
||||||
repo_url: str = "",
|
repo_url: str = "",
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
安装插件
|
安装插件
|
||||||
"""
|
"""
|
||||||
# 已安装插件
|
# 已安装插件
|
||||||
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||||
# 如果是非本地括件,或者强制安装时,则需要下载安装
|
# 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计
|
||||||
if repo_url and (force or plugin_id not in PluginManager().get_plugin_ids()):
|
if not force and plugin_id in PluginManager().get_plugin_ids():
|
||||||
# 下载安装
|
PluginHelper().install_reg(pid=plugin_id)
|
||||||
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
|
else:
|
||||||
if not state:
|
# 插件不存在或需要强制安装,下载安装并注册插件
|
||||||
# 安装失败
|
if repo_url:
|
||||||
return schemas.Response(success=False, message=msg)
|
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
|
||||||
|
# 安装失败则直接响应
|
||||||
|
if not state:
|
||||||
|
return schemas.Response(success=False, message=msg)
|
||||||
|
else:
|
||||||
|
# repo_url 为空时,也直接响应
|
||||||
|
return schemas.Response(success=False, message="没有传入仓库地址,无法正确安装插件,请检查配置")
|
||||||
# 安装插件
|
# 安装插件
|
||||||
if plugin_id not in install_plugins:
|
if plugin_id not in install_plugins:
|
||||||
install_plugins.append(plugin_id)
|
install_plugins.append(plugin_id)
|
||||||
@@ -124,6 +211,8 @@ def install(plugin_id: str,
|
|||||||
PluginManager().reload_plugin(plugin_id)
|
PluginManager().reload_plugin(plugin_id)
|
||||||
# 注册插件服务
|
# 注册插件服务
|
||||||
Scheduler().update_plugin_job(plugin_id)
|
Scheduler().update_plugin_job(plugin_id)
|
||||||
|
# 注册菜单命令
|
||||||
|
CommandChain().init_commands(plugin_id)
|
||||||
# 注册插件API
|
# 注册插件API
|
||||||
register_plugin_api(plugin_id)
|
register_plugin_api(plugin_id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
@@ -131,7 +220,7 @@ def install(plugin_id: str,
|
|||||||
|
|
||||||
@router.get("/form/{plugin_id}", summary="获取插件表单页面")
|
@router.get("/form/{plugin_id}", summary="获取插件表单页面")
|
||||||
def plugin_form(plugin_id: str,
|
def plugin_form(plugin_id: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||||
"""
|
"""
|
||||||
根据插件ID获取插件配置表单
|
根据插件ID获取插件配置表单
|
||||||
"""
|
"""
|
||||||
@@ -143,7 +232,7 @@ def plugin_form(plugin_id: str,
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
||||||
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
根据插件ID获取插件数据页面
|
根据插件ID获取插件数据页面
|
||||||
"""
|
"""
|
||||||
@@ -164,7 +253,7 @@ def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()]
|
|||||||
"""
|
"""
|
||||||
根据插件ID获取插件仪表板
|
根据插件ID获取插件仪表板
|
||||||
"""
|
"""
|
||||||
return PluginManager().get_plugin_dashboard(plugin_id, key=None, user_agent=user_agent)
|
return PluginManager().get_plugin_dashboard(plugin_id, user_agent=user_agent)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
|
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
|
||||||
@@ -177,7 +266,8 @@ def plugin_dashboard(plugin_id: str, key: str, user_agent: Annotated[str | None,
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
|
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
|
||||||
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def reset_plugin(plugin_id: str,
|
||||||
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据插件ID重置插件配置及数据
|
根据插件ID重置插件配置及数据
|
||||||
"""
|
"""
|
||||||
@@ -186,19 +276,19 @@ def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)
|
|||||||
# 删除插件所有数据
|
# 删除插件所有数据
|
||||||
PluginManager().delete_plugin_data(plugin_id)
|
PluginManager().delete_plugin_data(plugin_id)
|
||||||
# 重新生效插件
|
# 重新生效插件
|
||||||
PluginManager().init_plugin(plugin_id, {
|
PluginManager().reload_plugin(plugin_id)
|
||||||
"enabled": False,
|
|
||||||
"enable": False
|
|
||||||
})
|
|
||||||
# 注册插件服务
|
# 注册插件服务
|
||||||
Scheduler().update_plugin_job(plugin_id)
|
Scheduler().update_plugin_job(plugin_id)
|
||||||
|
# 注册菜单命令
|
||||||
|
CommandChain().init_commands(plugin_id)
|
||||||
# 注册插件API
|
# 注册插件API
|
||||||
register_plugin_api(plugin_id)
|
register_plugin_api(plugin_id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{plugin_id}", summary="获取插件配置")
|
@router.get("/{plugin_id}", summary="获取插件配置")
|
||||||
def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
def plugin_config(plugin_id: str,
|
||||||
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||||
"""
|
"""
|
||||||
根据插件ID获取插件配置信息
|
根据插件ID获取插件配置信息
|
||||||
"""
|
"""
|
||||||
@@ -207,7 +297,7 @@ def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token
|
|||||||
|
|
||||||
@router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response)
|
@router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response)
|
||||||
def set_plugin_config(plugin_id: str, conf: dict,
|
def set_plugin_config(plugin_id: str, conf: dict,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
更新插件配置
|
更新插件配置
|
||||||
"""
|
"""
|
||||||
@@ -217,6 +307,8 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
|||||||
PluginManager().init_plugin(plugin_id, conf)
|
PluginManager().init_plugin(plugin_id, conf)
|
||||||
# 注册插件服务
|
# 注册插件服务
|
||||||
Scheduler().update_plugin_job(plugin_id)
|
Scheduler().update_plugin_job(plugin_id)
|
||||||
|
# 注册菜单命令
|
||||||
|
CommandChain().init_commands(plugin_id)
|
||||||
# 注册插件API
|
# 注册插件API
|
||||||
register_plugin_api(plugin_id)
|
register_plugin_api(plugin_id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
@@ -224,7 +316,7 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
|||||||
|
|
||||||
@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response)
|
@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response)
|
||||||
def uninstall_plugin(plugin_id: str,
|
def uninstall_plugin(plugin_id: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
卸载插件
|
卸载插件
|
||||||
"""
|
"""
|
||||||
@@ -236,12 +328,12 @@ def uninstall_plugin(plugin_id: str,
|
|||||||
break
|
break
|
||||||
# 保存
|
# 保存
|
||||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||||
# 移除插件
|
|
||||||
PluginManager().remove_plugin(plugin_id)
|
|
||||||
# 移除插件服务
|
|
||||||
Scheduler().remove_plugin_job(plugin_id)
|
|
||||||
# 移除插件API
|
# 移除插件API
|
||||||
remove_plugin_api(plugin_id)
|
remove_plugin_api(plugin_id)
|
||||||
|
# 移除插件服务
|
||||||
|
Scheduler().remove_plugin_job(plugin_id)
|
||||||
|
# 移除插件
|
||||||
|
PluginManager().remove_plugin(plugin_id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ from app.db.models import User
|
|||||||
from app.db.models.site import Site
|
from app.db.models.site import Site
|
||||||
from app.db.models.siteicon import SiteIcon
|
from app.db.models.siteicon import SiteIcon
|
||||||
from app.db.models.sitestatistic import SiteStatistic
|
from app.db.models.sitestatistic import SiteStatistic
|
||||||
|
from app.db.models.siteuserdata import SiteUserData
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.db.userauth import get_current_active_superuser
|
from app.db.user_oper import get_current_active_superuser
|
||||||
from app.helper.sites import SitesHelper
|
from app.helper.sites import SitesHelper
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.schemas.types import SystemConfigKey, EventType
|
from app.schemas.types import SystemConfigKey, EventType
|
||||||
@@ -26,7 +27,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
|
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
|
||||||
def read_sites(db: Session = Depends(get_db),
|
def read_sites(db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
获取站点列表
|
获取站点列表
|
||||||
"""
|
"""
|
||||||
@@ -38,7 +39,7 @@ def add_site(
|
|||||||
*,
|
*,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
site_in: schemas.Site,
|
site_in: schemas.Site,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
新增站点
|
新增站点
|
||||||
@@ -75,7 +76,7 @@ def update_site(
|
|||||||
*,
|
*,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
site_in: schemas.Site,
|
site_in: schemas.Site,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
更新站点信息
|
更新站点信息
|
||||||
@@ -96,7 +97,7 @@ def update_site(
|
|||||||
|
|
||||||
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
|
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
|
||||||
def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
运行CookieCloud同步站点信息
|
运行CookieCloud同步站点信息
|
||||||
"""
|
"""
|
||||||
@@ -127,7 +128,7 @@ def reset(db: Session = Depends(get_db),
|
|||||||
def update_sites_priority(
|
def update_sites_priority(
|
||||||
priorities: List[dict],
|
priorities: List[dict],
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
批量更新站点优先级
|
批量更新站点优先级
|
||||||
"""
|
"""
|
||||||
@@ -145,7 +146,7 @@ def update_cookie(
|
|||||||
password: str,
|
password: str,
|
||||||
code: str = None,
|
code: str = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
使用用户密码更新站点Cookie
|
使用用户密码更新站点Cookie
|
||||||
"""
|
"""
|
||||||
@@ -164,6 +165,61 @@ def update_cookie(
|
|||||||
return schemas.Response(success=state, message=message)
|
return schemas.Response(success=state, message=message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/userdata/{site_id}", summary="更新站点用户数据", response_model=schemas.Response)
|
||||||
|
def refresh_userdata(
|
||||||
|
site_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
刷新站点用户数据
|
||||||
|
"""
|
||||||
|
site = Site.get(db, site_id)
|
||||||
|
if not site:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"站点 {site_id} 不存在",
|
||||||
|
)
|
||||||
|
indexer = SitesHelper().get_indexer(site.domain)
|
||||||
|
if not indexer:
|
||||||
|
return schemas.Response(success=False, message="站点不支持索引或未通过用户认证!")
|
||||||
|
user_data = SiteChain().refresh_userdata(site=indexer) or {}
|
||||||
|
return schemas.Response(success=True, data=user_data)
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
"""
|
||||||
|
查询所有站点最新用户数据
|
||||||
|
"""
|
||||||
|
user_datas = SiteUserData.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(
|
||||||
|
site_id: int,
|
||||||
|
workdate: str = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
查询站点用户数据
|
||||||
|
"""
|
||||||
|
site = Site.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)
|
||||||
|
if not user_data:
|
||||||
|
return schemas.Response(success=False, data=[])
|
||||||
|
return schemas.Response(success=True, data=user_data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/test/{site_id}", summary="连接测试", response_model=schemas.Response)
|
@router.get("/test/{site_id}", summary="连接测试", response_model=schemas.Response)
|
||||||
def test_site(site_id: int,
|
def test_site(site_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -205,7 +261,7 @@ def site_icon(site_id: int,
|
|||||||
@router.get("/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo])
|
@router.get("/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo])
|
||||||
def site_resource(site_id: int,
|
def site_resource(site_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
浏览站点资源
|
浏览站点资源
|
||||||
"""
|
"""
|
||||||
@@ -257,7 +313,8 @@ def read_site_by_domain(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
|
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
|
||||||
def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
|
def read_rss_sites(db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
获取站点列表
|
获取站点列表
|
||||||
"""
|
"""
|
||||||
@@ -278,7 +335,7 @@ def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
|
|||||||
def read_site(
|
def read_site(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
通过ID获取站点信息
|
通过ID获取站点信息
|
||||||
|
|||||||
218
app/api/endpoints/storage.py
Normal file
218
app/api/endpoints/storage.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from starlette.responses import FileResponse, Response
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.chain.storage import StorageChain
|
||||||
|
from app.chain.transfer import TransferChain
|
||||||
|
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.helper.progress import ProgressHelper
|
||||||
|
from app.schemas.types import ProgressKey
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qrcode/{name}", summary="生成二维码内容", response_model=schemas.Response)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
"""
|
||||||
|
二维码登录确认
|
||||||
|
"""
|
||||||
|
if ck or t:
|
||||||
|
data, errmsg = StorageChain().check_login(name, ck=ck, t=t)
|
||||||
|
else:
|
||||||
|
data, errmsg = StorageChain().check_login(name)
|
||||||
|
if data:
|
||||||
|
return schemas.Response(success=True, data=data)
|
||||||
|
return schemas.Response(success=False, message=errmsg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/save/{name}", summary="保存存储配置", response_model=schemas.Response)
|
||||||
|
def save(name: str,
|
||||||
|
conf: dict,
|
||||||
|
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
保存存储配置
|
||||||
|
"""
|
||||||
|
StorageChain().save_config(name, conf)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
|
||||||
|
def list_files(fileitem: schemas.FileItem,
|
||||||
|
sort: str = 'updated_at',
|
||||||
|
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
查询当前目录下所有目录和文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param sort: 排序方式,name:按名称排序,time:按修改时间排序
|
||||||
|
:param _: token
|
||||||
|
:return: 所有目录和文件
|
||||||
|
"""
|
||||||
|
file_list = StorageChain().list_files(fileitem)
|
||||||
|
if file_list:
|
||||||
|
if sort == "name":
|
||||||
|
file_list.sort(key=lambda x: x.name or "")
|
||||||
|
else:
|
||||||
|
file_list.sort(key=lambda x: x.modify_time or datetime.min, reverse=True)
|
||||||
|
return file_list
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mkdir", summary="创建目录", response_model=schemas.Response)
|
||||||
|
def mkdir(fileitem: schemas.FileItem,
|
||||||
|
name: str,
|
||||||
|
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
创建目录
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param name: 目录名称
|
||||||
|
:param _: token
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return schemas.Response(success=False)
|
||||||
|
result = StorageChain().create_folder(fileitem, name)
|
||||||
|
if result:
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
return schemas.Response(success=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response)
|
||||||
|
def delete(fileitem: schemas.FileItem,
|
||||||
|
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
删除文件或目录
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param _: token
|
||||||
|
"""
|
||||||
|
result = StorageChain().delete_file(fileitem)
|
||||||
|
if result:
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
return schemas.Response(success=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/download", summary="下载文件")
|
||||||
|
def download(fileitem: schemas.FileItem,
|
||||||
|
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
下载文件或目录
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param _: token
|
||||||
|
"""
|
||||||
|
# 临时目录
|
||||||
|
tmp_file = StorageChain().download_file(fileitem)
|
||||||
|
if tmp_file:
|
||||||
|
return FileResponse(path=tmp_file)
|
||||||
|
return schemas.Response(success=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image", summary="预览图片")
|
||||||
|
def image(fileitem: schemas.FileItem,
|
||||||
|
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
下载文件或目录
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param _: token
|
||||||
|
"""
|
||||||
|
# 临时目录
|
||||||
|
tmp_file = StorageChain().download_file(fileitem)
|
||||||
|
if not tmp_file:
|
||||||
|
raise HTTPException(status_code=500, detail="图片读取出错")
|
||||||
|
return Response(content=tmp_file.read_bytes(), media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response)
|
||||||
|
def rename(fileitem: schemas.FileItem,
|
||||||
|
new_name: str,
|
||||||
|
recursive: bool = False,
|
||||||
|
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
重命名文件或目录
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param new_name: 新名称
|
||||||
|
:param recursive: 是否递归修改
|
||||||
|
:param _: token
|
||||||
|
"""
|
||||||
|
if not fileitem.fileid or not new_name:
|
||||||
|
return schemas.Response(success=False)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage/{name}", summary="存储空间信息", response_model=schemas.StorageUsage)
|
||||||
|
def usage(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
查询存储空间
|
||||||
|
"""
|
||||||
|
ret = StorageChain().storage_usage(name)
|
||||||
|
if ret:
|
||||||
|
return ret
|
||||||
|
return schemas.StorageUsage()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transtype/{name}", summary="支持的整理方式获取", response_model=schemas.StorageTransType)
|
||||||
|
def transtype(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
查询支持的整理方式
|
||||||
|
"""
|
||||||
|
ret = StorageChain().support_transtype(name)
|
||||||
|
if ret:
|
||||||
|
return schemas.StorageTransType(transtype=ret)
|
||||||
|
return schemas.StorageTransType()
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
from typing import List, Any
|
from typing import List, Any
|
||||||
|
|
||||||
import cn2an
|
import cn2an
|
||||||
@@ -15,7 +14,7 @@ from app.db import get_db
|
|||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
from app.db.models.subscribehistory import SubscribeHistory
|
from app.db.models.subscribehistory import SubscribeHistory
|
||||||
from app.db.models.user import User
|
from app.db.models.user import User
|
||||||
from app.db.userauth import get_current_active_user
|
from app.db.user_oper import get_current_active_user
|
||||||
from app.helper.subscribe import SubscribeHelper
|
from app.helper.subscribe import SubscribeHelper
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
@@ -39,16 +38,7 @@ def read_subscribes(
|
|||||||
"""
|
"""
|
||||||
查询所有订阅
|
查询所有订阅
|
||||||
"""
|
"""
|
||||||
subscribes = Subscribe.list(db)
|
return Subscribe.list(db)
|
||||||
for subscribe in subscribes:
|
|
||||||
if subscribe.sites:
|
|
||||||
try:
|
|
||||||
subscribe.sites = json.loads(str(subscribe.sites))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
subscribe.sites = []
|
|
||||||
else:
|
|
||||||
subscribe.sites = []
|
|
||||||
return subscribes
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list", summary="查询所有订阅(API_TOKEN)", response_model=List[schemas.Subscribe])
|
@router.get("/list", summary="查询所有订阅(API_TOKEN)", response_model=List[schemas.Subscribe])
|
||||||
@@ -64,7 +54,7 @@ def create_subscribe(
|
|||||||
*,
|
*,
|
||||||
subscribe_in: schemas.Subscribe,
|
subscribe_in: schemas.Subscribe,
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
) -> Any:
|
) -> schemas.Response:
|
||||||
"""
|
"""
|
||||||
新增订阅
|
新增订阅
|
||||||
"""
|
"""
|
||||||
@@ -94,6 +84,9 @@ def create_subscribe(
|
|||||||
best_version=subscribe_in.best_version,
|
best_version=subscribe_in.best_version,
|
||||||
save_path=subscribe_in.save_path,
|
save_path=subscribe_in.save_path,
|
||||||
search_imdbid=subscribe_in.search_imdbid,
|
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)
|
exist_ok=True)
|
||||||
return schemas.Response(
|
return schemas.Response(
|
||||||
success=bool(sid), message=message, data={"id": sid}
|
success=bool(sid), message=message, data={"id": sid}
|
||||||
@@ -113,8 +106,6 @@ def update_subscribe(
|
|||||||
subscribe = Subscribe.get(db, subscribe_in.id)
|
subscribe = Subscribe.get(db, subscribe_in.id)
|
||||||
if not subscribe:
|
if not subscribe:
|
||||||
return schemas.Response(success=False, message="订阅不存在")
|
return schemas.Response(success=False, message="订阅不存在")
|
||||||
if subscribe_in.sites is not None:
|
|
||||||
subscribe_in.sites = json.dumps(subscribe_in.sites)
|
|
||||||
# 避免更新缺失集数
|
# 避免更新缺失集数
|
||||||
subscribe_dict = subscribe_in.dict()
|
subscribe_dict = subscribe_in.dict()
|
||||||
if not subscribe_in.lack_episode:
|
if not subscribe_in.lack_episode:
|
||||||
@@ -170,11 +161,6 @@ def subscribe_mediaid(
|
|||||||
if season:
|
if season:
|
||||||
meta.begin_season = season
|
meta.begin_season = season
|
||||||
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
|
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
|
||||||
if result and result.sites:
|
|
||||||
try:
|
|
||||||
result.sites = json.loads(result.sites)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
result.sites = []
|
|
||||||
|
|
||||||
return result if result else Subscribe()
|
return result if result else Subscribe()
|
||||||
|
|
||||||
@@ -334,7 +320,7 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe])
|
@router.get("/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe])
|
||||||
def read_subscribe(
|
def subscribe_history(
|
||||||
mtype: str,
|
mtype: str,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
count: int = 30,
|
count: int = 30,
|
||||||
@@ -343,14 +329,7 @@ def read_subscribe(
|
|||||||
"""
|
"""
|
||||||
查询电影/电视剧订阅历史
|
查询电影/电视剧订阅历史
|
||||||
"""
|
"""
|
||||||
historys = SubscribeHistory.list_by_type(db, mtype=mtype, page=page, count=count)
|
return SubscribeHistory.list_by_type(db, mtype=mtype, page=page, count=count)
|
||||||
for history in historys:
|
|
||||||
if history and history.sites:
|
|
||||||
try:
|
|
||||||
history.sites = json.loads(history.sites)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
history.sites = []
|
|
||||||
return historys
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response)
|
@router.delete("/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response)
|
||||||
@@ -411,6 +390,76 @@ def popular_subscribes(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{username}", summary="用户订阅", response_model=List[schemas.Subscribe])
|
||||||
|
def user_subscribes(
|
||||||
|
username: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询用户订阅
|
||||||
|
"""
|
||||||
|
return Subscribe.list_by_username(db, username)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{subscribe_id}", summary="订阅相关文件信息", response_model=schemas.SubscrbieInfo)
|
||||||
|
def subscribe_files(
|
||||||
|
subscribe_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
订阅相关文件信息
|
||||||
|
"""
|
||||||
|
subscribe = Subscribe.get(db, subscribe_id)
|
||||||
|
if subscribe:
|
||||||
|
return SubscribeChain().subscribe_files_info(subscribe)
|
||||||
|
return schemas.SubscrbieInfo()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/share", summary="分享订阅", response_model=schemas.Response)
|
||||||
|
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)
|
||||||
|
return schemas.Response(success=state, message=errmsg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fork", summary="复用订阅", response_model=schemas.Response)
|
||||||
|
def subscribe_fork(
|
||||||
|
sub: schemas.SubscribeShare,
|
||||||
|
current_user: User = Depends(get_current_active_user)) -> Any:
|
||||||
|
"""
|
||||||
|
复用订阅
|
||||||
|
"""
|
||||||
|
sub_dict = sub.dict()
|
||||||
|
sub_dict.pop("id")
|
||||||
|
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)
|
||||||
|
if result.success:
|
||||||
|
SubscribeHelper().sub_fork(share_id=sub.id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare])
|
||||||
|
def popular_subscribes(
|
||||||
|
name: str = None,
|
||||||
|
page: int = 1,
|
||||||
|
count: int = 30,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询分享的订阅
|
||||||
|
"""
|
||||||
|
return SubscribeHelper().get_shares(name=name, page=page, count=count)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
|
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
|
||||||
def read_subscribe(
|
def read_subscribe(
|
||||||
subscribe_id: int,
|
subscribe_id: int,
|
||||||
@@ -421,13 +470,7 @@ def read_subscribe(
|
|||||||
"""
|
"""
|
||||||
if not subscribe_id:
|
if not subscribe_id:
|
||||||
return Subscribe()
|
return Subscribe()
|
||||||
subscribe = Subscribe.get(db, subscribe_id)
|
return Subscribe.get(db, subscribe_id)
|
||||||
if subscribe and subscribe.sites:
|
|
||||||
try:
|
|
||||||
subscribe.sites = json.loads(subscribe.sites)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
subscribe.sites = []
|
|
||||||
return subscribe
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
|
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
|
||||||
|
|||||||
@@ -1,57 +1,188 @@
|
|||||||
|
import asyncio
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import time
|
import tempfile
|
||||||
|
from collections import deque
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Union, Any
|
from pathlib import Path
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
import tailer
|
import aiofiles
|
||||||
from dotenv import set_key
|
from PIL import Image
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Response
|
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
from app.chain.system import SystemChain
|
from app.chain.system import SystemChain
|
||||||
from app.core.config import settings, global_vars
|
from app.core.config import global_vars, settings
|
||||||
from app.core.module import ModuleManager
|
from app.core.module import ModuleManager
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_apitoken, verify_resource_token, verify_token
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.db.userauth import get_current_active_superuser
|
from app.db.user_oper import get_current_active_superuser
|
||||||
|
from app.helper.mediaserver import MediaServerHelper
|
||||||
from app.helper.message import MessageHelper
|
from app.helper.message import MessageHelper
|
||||||
from app.helper.progress import ProgressHelper
|
from app.helper.progress import ProgressHelper
|
||||||
|
from app.helper.rule import RuleHelper
|
||||||
from app.helper.sites import SitesHelper
|
from app.helper.sites import SitesHelper
|
||||||
|
from app.log import logger
|
||||||
|
from app.monitor import Monitor
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
|
from app.utils.crypto import HashUtils
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
|
from app.utils.security import SecurityUtils
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
|
from app.utils.url import UrlUtils
|
||||||
from version import APP_VERSION
|
from version import APP_VERSION
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_image(
|
||||||
|
url: str,
|
||||||
|
proxy: bool = False,
|
||||||
|
use_disk_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")
|
||||||
|
|
||||||
|
if allowed_domains is None:
|
||||||
|
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS)
|
||||||
|
|
||||||
|
# 验证URL安全性
|
||||||
|
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
|
||||||
|
|
||||||
|
# 确保缓存路径和文件类型合法
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 目前暂不考虑磁盘缓存文件是否过期,后续通过缓存清理机制处理
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# 请求远程图片
|
||||||
|
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)
|
||||||
|
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()
|
||||||
|
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-None-Match
|
||||||
|
etag = HashUtils.md5(content)
|
||||||
|
if if_none_match == etag:
|
||||||
|
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"),
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/img/{proxy}", summary="图片代理")
|
@router.get("/img/{proxy}", summary="图片代理")
|
||||||
def get_img(imgurl: str, proxy: bool = False) -> Any:
|
def proxy_img(
|
||||||
|
imgurl: str,
|
||||||
|
proxy: bool = False,
|
||||||
|
if_none_match: Optional[str] = Header(None),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_resource_token)
|
||||||
|
) -> Response:
|
||||||
"""
|
"""
|
||||||
通过图片代理(使用代理服务器)
|
图片代理,可选是否使用代理服务器,支持 HTTP 缓存
|
||||||
"""
|
"""
|
||||||
if not imgurl:
|
# 媒体服务器添加图片代理支持
|
||||||
return None
|
hosts = [config.config.get("host") for config in MediaServerHelper().get_configs().values() if
|
||||||
if proxy:
|
config and config.config and config.config.get("host")]
|
||||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=settings.PROXY).get_res(url=imgurl)
|
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts)
|
||||||
else:
|
return fetch_image(url=imgurl, proxy=proxy, use_disk_cache=False,
|
||||||
response = RequestUtils(ua=settings.USER_AGENT).get_res(url=imgurl)
|
if_none_match=if_none_match, allowed_domains=allowed_domains)
|
||||||
if response:
|
|
||||||
return Response(content=response.content, media_type="image/jpeg")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env", summary="查询系统环境变量", response_model=schemas.Response)
|
@router.get("/cache/image", summary="图片缓存")
|
||||||
|
def cache_img(
|
||||||
|
url: str,
|
||||||
|
if_none_match: Optional[str] = Header(None),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_resource_token)
|
||||||
|
) -> Response:
|
||||||
|
"""
|
||||||
|
本地缓存图片文件,支持 HTTP 缓存,如果启用全局图片缓存,则使用磁盘缓存
|
||||||
|
"""
|
||||||
|
# 如果没有启用全局图片缓存,则不使用磁盘缓存
|
||||||
|
return fetch_image(url=url, proxy=False, use_disk_cache=settings.GLOBAL_IMAGE_CACHE, if_none_match=if_none_match)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response)
|
||||||
|
def get_global_setting():
|
||||||
|
"""
|
||||||
|
查询非敏感系统设置(无需鉴权)
|
||||||
|
"""
|
||||||
|
# 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"}
|
||||||
|
)
|
||||||
|
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)):
|
def get_env_setting(_: User = Depends(get_current_active_superuser)):
|
||||||
"""
|
"""
|
||||||
查询系统环境变量,包括当前版本号
|
查询系统环境变量,包括当前版本号(仅管理员)
|
||||||
"""
|
"""
|
||||||
info = settings.dict(
|
info = settings.dict(
|
||||||
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD"}
|
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY"}
|
||||||
)
|
)
|
||||||
info.update({
|
info.update({
|
||||||
"VERSION": APP_VERSION,
|
"VERSION": APP_VERSION,
|
||||||
@@ -63,47 +194,53 @@ def get_env_setting(_: User = Depends(get_current_active_superuser)):
|
|||||||
data=info)
|
data=info)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/env", summary="更新系统环境变量", response_model=schemas.Response)
|
@router.post("/env", summary="更新系统配置", response_model=schemas.Response)
|
||||||
def set_env_setting(env: dict,
|
def set_env_setting(env: dict,
|
||||||
_: User = Depends(get_current_active_superuser)):
|
_: User = Depends(get_current_active_superuser)):
|
||||||
"""
|
"""
|
||||||
更新系统环境变量
|
更新系统环境变量(仅管理员)
|
||||||
"""
|
"""
|
||||||
for k, v in env.items():
|
result = settings.update_settings(env=env)
|
||||||
if k == "undefined":
|
# 统计成功和失败的结果
|
||||||
continue
|
success_updates = {k: v for k, v in result.items() if v[0]}
|
||||||
if hasattr(settings, k):
|
failed_updates = {k: v for k, v in result.items() if not v[0]}
|
||||||
if v == "None":
|
|
||||||
v = None
|
if failed_updates:
|
||||||
setattr(settings, k, v)
|
return schemas.Response(
|
||||||
if v is None:
|
success=False,
|
||||||
v = ''
|
message="部分配置项更新失败",
|
||||||
else:
|
data={
|
||||||
v = str(v)
|
"success_updates": success_updates,
|
||||||
set_key(settings.CONFIG_PATH / "app.env", k, v)
|
"failed_updates": failed_updates
|
||||||
return schemas.Response(success=True)
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return schemas.Response(
|
||||||
|
success=True,
|
||||||
|
message="所有配置项更新成功",
|
||||||
|
data={
|
||||||
|
"success_updates": success_updates
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/progress/{process_type}", summary="实时进度")
|
@router.get("/progress/{process_type}", summary="实时进度")
|
||||||
def get_progress(process_type: str, token: str):
|
async def get_progress(request: Request, process_type: str, _: schemas.TokenPayload = Depends(verify_resource_token)):
|
||||||
"""
|
"""
|
||||||
实时获取处理进度,返回格式为SSE
|
实时获取处理进度,返回格式为SSE
|
||||||
"""
|
"""
|
||||||
if not token or not verify_token(token):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
|
|
||||||
progress = ProgressHelper()
|
progress = ProgressHelper()
|
||||||
|
|
||||||
def event_generator():
|
async def event_generator():
|
||||||
while True:
|
try:
|
||||||
if global_vars.is_system_stopped():
|
while not global_vars.is_system_stopped:
|
||||||
break
|
if await request.is_disconnected():
|
||||||
detail = progress.get(process_type)
|
break
|
||||||
yield 'data: %s\n\n' % json.dumps(detail)
|
detail = progress.get(process_type)
|
||||||
time.sleep(0.2)
|
yield f"data: {json.dumps(detail)}\n\n"
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
@@ -112,7 +249,7 @@ def get_progress(process_type: str, token: str):
|
|||||||
def get_setting(key: str,
|
def get_setting(key: str,
|
||||||
_: User = Depends(get_current_active_superuser)):
|
_: User = Depends(get_current_active_superuser)):
|
||||||
"""
|
"""
|
||||||
查询系统设置
|
查询系统设置(仅管理员)
|
||||||
"""
|
"""
|
||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
value = getattr(settings, key)
|
value = getattr(settings, key)
|
||||||
@@ -127,82 +264,89 @@ def get_setting(key: str,
|
|||||||
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
|
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||||
_: User = Depends(get_current_active_superuser)):
|
_: User = Depends(get_current_active_superuser)):
|
||||||
"""
|
"""
|
||||||
更新系统设置
|
更新系统设置(仅管理员)
|
||||||
"""
|
"""
|
||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
if value == "None":
|
success, message = settings.update_setting(key=key, value=value)
|
||||||
value = None
|
return schemas.Response(success=success, message=message)
|
||||||
setattr(settings, key, value)
|
elif key in {item.value for item in SystemConfigKey}:
|
||||||
if value is None:
|
|
||||||
value = ''
|
|
||||||
else:
|
|
||||||
value = str(value)
|
|
||||||
set_key(settings.CONFIG_PATH / "app.env", key, value)
|
|
||||||
else:
|
|
||||||
SystemConfigOper().set(key, value)
|
SystemConfigOper().set(key, value)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
else:
|
||||||
|
return schemas.Response(success=False, message=f"配置项 '{key}' 不存在")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/message", summary="实时消息")
|
@router.get("/message", summary="实时消息")
|
||||||
def get_message(token: str, role: str = "system"):
|
async def get_message(request: Request, role: str = "system", _: schemas.TokenPayload = Depends(verify_resource_token)):
|
||||||
"""
|
"""
|
||||||
实时获取系统消息,返回格式为SSE
|
实时获取系统消息,返回格式为SSE
|
||||||
"""
|
"""
|
||||||
if not token or not verify_token(token):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
|
|
||||||
message = MessageHelper()
|
message = MessageHelper()
|
||||||
|
|
||||||
def event_generator():
|
async def event_generator():
|
||||||
while True:
|
try:
|
||||||
if global_vars.is_system_stopped():
|
while not global_vars.is_system_stopped:
|
||||||
break
|
if await request.is_disconnected():
|
||||||
detail = message.get(role)
|
break
|
||||||
yield 'data: %s\n\n' % (detail or '')
|
detail = message.get(role)
|
||||||
time.sleep(3)
|
yield f"data: {detail or ''}\n\n"
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logging", summary="实时日志")
|
@router.get("/logging", summary="实时日志")
|
||||||
def get_logging(token: str, length: int = 50, logfile: str = "moviepilot.log"):
|
async def get_logging(request: Request, length: int = 50, logfile: str = "moviepilot.log",
|
||||||
|
_: schemas.TokenPayload = Depends(verify_resource_token)):
|
||||||
"""
|
"""
|
||||||
实时获取系统日志
|
实时获取系统日志
|
||||||
length = -1 时, 返回text/plain
|
length = -1 时, 返回text/plain
|
||||||
否则 返回格式SSE
|
否则 返回格式SSE
|
||||||
"""
|
"""
|
||||||
if not token or not verify_token(token):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="认证失败!",
|
|
||||||
)
|
|
||||||
|
|
||||||
log_path = settings.LOG_PATH / logfile
|
log_path = settings.LOG_PATH / logfile
|
||||||
|
|
||||||
def log_generator():
|
if not SecurityUtils.is_safe_path(settings.LOG_PATH, log_path, allowed_suffixes={".log"}):
|
||||||
# 读取文件末尾50行,不使用tailer模块
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
with open(log_path, 'r', encoding='utf-8') as f:
|
|
||||||
for line in f.readlines()[-max(length, 50):]:
|
if not log_path.exists() or not log_path.is_file():
|
||||||
yield 'data: %s\n\n' % line
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
while True:
|
|
||||||
if global_vars.is_system_stopped():
|
async def log_generator():
|
||||||
break
|
try:
|
||||||
for t in tailer.follow(open(log_path, 'r', encoding='utf-8')):
|
# 使用固定大小的双向队列来限制内存使用
|
||||||
yield 'data: %s\n\n' % (t or '')
|
lines_queue = deque(maxlen=max(length, 50))
|
||||||
time.sleep(1)
|
# 使用 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():
|
||||||
|
lines_queue.append(line)
|
||||||
|
for line in lines_queue:
|
||||||
|
yield f"data: {line}\n\n"
|
||||||
|
# 移动文件指针到文件末尾,继续监听新增内容
|
||||||
|
await f.seek(0, 2)
|
||||||
|
while not global_vars.is_system_stopped:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
line = await f.readline()
|
||||||
|
if not line:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
continue
|
||||||
|
yield f"data: {line}\n\n"
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
# 根据length参数返回不同的响应
|
# 根据length参数返回不同的响应
|
||||||
if length == -1:
|
if length == -1:
|
||||||
# 返回全部日志作为文本响应
|
# 返回全部日志作为文本响应
|
||||||
if not log_path.exists():
|
if not log_path.exists():
|
||||||
return Response(content="日志文件不存在!", media_type="text/plain")
|
return Response(content="日志文件不存在!", media_type="text/plain")
|
||||||
with open(log_path, 'r', encoding='utf-8') as file:
|
with open(log_path, "r", encoding='utf-8') as file:
|
||||||
text = file.read()
|
text = file.read()
|
||||||
# 倒序输出
|
# 倒序输出
|
||||||
text = '\n'.join(text.split('\n')[::-1])
|
text = "\n".join(text.split("\n")[::-1])
|
||||||
return Response(content=text, media_type="text/plain")
|
return Response(content=text, media_type="text/plain")
|
||||||
else:
|
else:
|
||||||
# 返回SSE流响应
|
# 返回SSE流响应
|
||||||
@@ -223,10 +367,10 @@ def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
|||||||
return schemas.Response(success=False)
|
return schemas.Response(success=False)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ruletest", summary="优先级规则测试", response_model=schemas.Response)
|
@router.get("/ruletest", summary="过滤规则测试", response_model=schemas.Response)
|
||||||
def ruletest(title: str,
|
def ruletest(title: str,
|
||||||
|
rulegroup_name: str,
|
||||||
subtitle: str = None,
|
subtitle: str = None,
|
||||||
ruletype: str = None,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)):
|
_: schemas.TokenPayload = Depends(verify_token)):
|
||||||
"""
|
"""
|
||||||
过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索
|
过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索
|
||||||
@@ -235,20 +379,16 @@ def ruletest(title: str,
|
|||||||
title=title,
|
title=title,
|
||||||
description=subtitle,
|
description=subtitle,
|
||||||
)
|
)
|
||||||
if ruletype == "2":
|
# 查询规则组详情
|
||||||
rule_string = SystemConfigOper().get(SystemConfigKey.BestVersionFilterRules)
|
rulegroup = RuleHelper().get_rule_group(rulegroup_name)
|
||||||
elif ruletype == "3":
|
if not rulegroup:
|
||||||
rule_string = SystemConfigOper().get(SystemConfigKey.SearchFilterRules)
|
return schemas.Response(success=False, message=f"过滤规则组 {rulegroup_name} 不存在!")
|
||||||
else:
|
|
||||||
rule_string = SystemConfigOper().get(SystemConfigKey.SubscribeFilterRules)
|
|
||||||
if not rule_string:
|
|
||||||
return schemas.Response(success=False, message="优先级规则未设置!")
|
|
||||||
|
|
||||||
# 过滤
|
# 过滤
|
||||||
result = SearchChain().filter_torrents(rule_string=rule_string,
|
result = SearchChain().filter_torrents(rule_groups=[rulegroup.name],
|
||||||
torrent_list=[torrent])
|
torrent_list=[torrent])
|
||||||
if not result:
|
if not result:
|
||||||
return schemas.Response(success=False, message="不符合优先级规则!")
|
return schemas.Response(success=False, message="不符合过滤规则!")
|
||||||
return schemas.Response(success=True, data={
|
return schemas.Response(success=True, data={
|
||||||
"priority": 100 - result[0].pri_order + 1
|
"priority": 100 - result[0].pri_order + 1
|
||||||
})
|
})
|
||||||
@@ -307,7 +447,7 @@ def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
|
|||||||
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
|
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
|
||||||
def restart_system(_: User = Depends(get_current_active_superuser)):
|
def restart_system(_: User = Depends(get_current_active_superuser)):
|
||||||
"""
|
"""
|
||||||
重启系统
|
重启系统(仅管理员)
|
||||||
"""
|
"""
|
||||||
if not SystemUtils.can_restart():
|
if not SystemUtils.can_restart():
|
||||||
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
|
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
|
||||||
@@ -321,20 +461,34 @@ def restart_system(_: User = Depends(get_current_active_superuser)):
|
|||||||
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
|
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
|
||||||
def reload_module(_: User = Depends(get_current_active_superuser)):
|
def reload_module(_: User = Depends(get_current_active_superuser)):
|
||||||
"""
|
"""
|
||||||
重新加载模块
|
重新加载模块(仅管理员)
|
||||||
"""
|
"""
|
||||||
ModuleManager().reload()
|
ModuleManager().reload()
|
||||||
Scheduler().init()
|
Scheduler().init()
|
||||||
|
Monitor().init()
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
||||||
def execute_command(jobid: str,
|
def run_scheduler(jobid: str,
|
||||||
_: User = Depends(get_current_active_superuser)):
|
_: User = Depends(get_current_active_superuser)):
|
||||||
"""
|
"""
|
||||||
执行命令
|
执行命令(仅管理员)
|
||||||
"""
|
"""
|
||||||
if not jobid:
|
if not jobid:
|
||||||
return schemas.Response(success=False, message="命令不能为空!")
|
return schemas.Response(success=False, message="命令不能为空!")
|
||||||
Scheduler().start(jobid)
|
Scheduler().start(jobid)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runscheduler2", summary="运行服务(API_TOKEN)", response_model=schemas.Response)
|
||||||
|
def run_scheduler2(jobid: str,
|
||||||
|
_: str = Depends(verify_apitoken)):
|
||||||
|
"""
|
||||||
|
执行命令(API_TOKEN认证)
|
||||||
|
"""
|
||||||
|
if not jobid:
|
||||||
|
return schemas.Response(success=False, message="命令不能为空!")
|
||||||
|
|
||||||
|
Scheduler().start(jobid)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|||||||
@@ -1,21 +1,43 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
|
from app.chain.storage import StorageChain
|
||||||
from app.chain.transfer import TransferChain
|
from app.chain.transfer import TransferChain
|
||||||
from app.core.metainfo import MetaInfoPath
|
from app.core.metainfo import MetaInfoPath
|
||||||
from app.core.security import verify_token, verify_apitoken
|
from app.core.security import verify_token, verify_apitoken
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.models.transferhistory import TransferHistory
|
from app.db.models.transferhistory import TransferHistory
|
||||||
from app.schemas import MediaType
|
from app.db.user_oper import get_current_active_superuser
|
||||||
|
from app.schemas import MediaType, FileItem
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ManualTransferItem(BaseModel):
|
||||||
|
fileitem: FileItem = None,
|
||||||
|
logid: Optional[int] = None,
|
||||||
|
target_storage: Optional[str] = None,
|
||||||
|
target_path: Optional[str] = None,
|
||||||
|
tmdbid: Optional[int] = None,
|
||||||
|
doubanid: Optional[str] = None,
|
||||||
|
type_name: Optional[str] = None,
|
||||||
|
season: Optional[int] = None,
|
||||||
|
transfer_type: Optional[str] = None,
|
||||||
|
episode_format: Optional[str] = None,
|
||||||
|
episode_detail: Optional[str] = None,
|
||||||
|
episode_part: Optional[str] = None,
|
||||||
|
episode_offset: Optional[int] = 0,
|
||||||
|
min_filesize: Optional[int] = 0,
|
||||||
|
scrape: bool = False,
|
||||||
|
from_history: bool = False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/name", summary="查询整理后的名称", response_model=schemas.Response)
|
@router.get("/name", summary="查询整理后的名称", response_model=schemas.Response)
|
||||||
def query_name(path: str, filetype: str,
|
def query_name(path: str, filetype: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
@@ -46,101 +68,86 @@ def query_name(path: str, filetype: str,
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
||||||
def manual_transfer(storage: str = "local",
|
def manual_transfer(transer_item: ManualTransferItem,
|
||||||
path: str = None,
|
|
||||||
drive_id: str = None,
|
|
||||||
fileid: str = None,
|
|
||||||
filetype: str = None,
|
|
||||||
logid: int = None,
|
|
||||||
target: str = None,
|
|
||||||
tmdbid: int = None,
|
|
||||||
doubanid: str = None,
|
|
||||||
type_name: str = None,
|
|
||||||
season: int = None,
|
|
||||||
transfer_type: str = None,
|
|
||||||
episode_format: str = None,
|
|
||||||
episode_detail: str = None,
|
|
||||||
episode_part: str = None,
|
|
||||||
episode_offset: int = 0,
|
|
||||||
min_filesize: int = 0,
|
|
||||||
scrape: bool = None,
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
手动转移,文件或历史记录,支持自定义剧集识别格式
|
手动转移,文件或历史记录,支持自定义剧集识别格式
|
||||||
:param storage: 存储类型:local/aliyun/u115
|
:param transer_item: 手工整理项
|
||||||
:param path: 转移路径或文件
|
|
||||||
:param drive_id: 云盘ID(网盘等)
|
|
||||||
:param fileid: 文件ID(网盘等)
|
|
||||||
:param filetype: 文件类型,dir/file
|
|
||||||
:param logid: 转移历史记录ID
|
|
||||||
:param target: 目标路径
|
|
||||||
:param type_name: 媒体类型、电影/电视剧
|
|
||||||
:param tmdbid: tmdbid
|
|
||||||
:param doubanid: 豆瓣ID
|
|
||||||
:param season: 剧集季号
|
|
||||||
:param transfer_type: 转移类型,move/copy 等
|
|
||||||
:param episode_format: 剧集识别格式
|
|
||||||
:param episode_detail: 剧集识别详细信息
|
|
||||||
:param episode_part: 剧集识别分集信息
|
|
||||||
:param episode_offset: 剧集识别偏移量
|
|
||||||
:param min_filesize: 最小文件大小(MB)
|
|
||||||
:param scrape: 是否刮削元数据
|
|
||||||
:param db: 数据库
|
:param db: 数据库
|
||||||
:param _: Token校验
|
:param _: Token校验
|
||||||
"""
|
"""
|
||||||
force = False
|
force = False
|
||||||
target = Path(target) if target else None
|
target_path = Path(transer_item.target_path) if transer_item.target_path else None
|
||||||
transfer = TransferChain()
|
if transer_item.logid:
|
||||||
if logid:
|
|
||||||
# 查询历史记录
|
# 查询历史记录
|
||||||
history: TransferHistory = TransferHistory.get(db, logid)
|
history: TransferHistory = TransferHistory.get(db, transer_item.logid)
|
||||||
if not history:
|
if not history:
|
||||||
return schemas.Response(success=False, message=f"历史记录不存在,ID:{logid}")
|
return schemas.Response(success=False, message=f"历史记录不存在,ID:{transer_item.logid}")
|
||||||
# 强制转移
|
# 强制转移
|
||||||
force = True
|
force = True
|
||||||
if history.status and ("move" in history.mode):
|
if history.status and ("move" in history.mode):
|
||||||
# 重新整理成功的转移,则使用成功的 dest 做 in_path
|
# 重新整理成功的转移,则使用成功的 dest 做 in_path
|
||||||
in_path = Path(history.dest)
|
src_fileitem = FileItem(**history.dest_fileitem)
|
||||||
else:
|
else:
|
||||||
# 源路径
|
# 源路径
|
||||||
in_path = Path(history.src)
|
src_fileitem = FileItem(**history.src_fileitem)
|
||||||
# 目的路径
|
# 目的路径
|
||||||
if history.dest and str(history.dest) != "None":
|
if history.dest_fileitem:
|
||||||
# 删除旧的已整理文件
|
# 删除旧的已整理文件
|
||||||
transfer.delete_files(Path(history.dest))
|
dest_fileitem = FileItem(**history.dest_fileitem)
|
||||||
elif path:
|
state = StorageChain().delete_media_file(dest_fileitem, mtype=MediaType(history.type))
|
||||||
in_path = Path(path)
|
if not state:
|
||||||
|
return schemas.Response(success=False, message=f"{dest_fileitem.path} 删除失败")
|
||||||
|
|
||||||
|
# 从历史数据获取信息
|
||||||
|
if transer_item.from_history:
|
||||||
|
transer_item.type_name = history.type if history.type else transer_item.type_name
|
||||||
|
transer_item.tmdbid = int(history.tmdbid) if history.tmdbid else transer_item.tmdbid
|
||||||
|
transer_item.doubanid = str(history.doubanid) if history.doubanid else transer_item.doubanid
|
||||||
|
transer_item.season = int(str(history.seasons).replace("S", "")) if history.seasons else transer_item.season
|
||||||
|
if history.episodes:
|
||||||
|
if "-" in str(history.episodes):
|
||||||
|
# E01-E03多集合并
|
||||||
|
episode_start, episode_end = str(history.episodes).split("-")
|
||||||
|
episode_list: list[int] = []
|
||||||
|
for i in range(int(episode_start.replace("E", "")), int(episode_end.replace("E", "")) + 1):
|
||||||
|
episode_list.append(i)
|
||||||
|
transer_item.episode_detail = ",".join(str(e) for e in episode_list)
|
||||||
|
else:
|
||||||
|
# E01单集
|
||||||
|
transer_item.episode_detail = str(history.episodes).replace("E", "")
|
||||||
|
|
||||||
|
elif transer_item.fileitem:
|
||||||
|
src_fileitem = transer_item.fileitem
|
||||||
else:
|
else:
|
||||||
return schemas.Response(success=False, message=f"缺少参数:path/logid")
|
return schemas.Response(success=False, message=f"缺少参数")
|
||||||
|
|
||||||
# 类型
|
# 类型
|
||||||
mtype = MediaType(type_name) if type_name else None
|
mtype = MediaType(transer_item.type_name) if transer_item.type_name else None
|
||||||
# 自定义格式
|
# 自定义格式
|
||||||
epformat = None
|
epformat = None
|
||||||
if episode_offset or episode_part or episode_detail or episode_format:
|
if transer_item.episode_offset or transer_item.episode_part \
|
||||||
|
or transer_item.episode_detail or transer_item.episode_format:
|
||||||
epformat = schemas.EpisodeFormat(
|
epformat = schemas.EpisodeFormat(
|
||||||
format=episode_format,
|
format=transer_item.episode_format,
|
||||||
detail=episode_detail,
|
detail=transer_item.episode_detail,
|
||||||
part=episode_part,
|
part=transer_item.episode_part,
|
||||||
offset=episode_offset,
|
offset=transer_item.episode_offset,
|
||||||
)
|
)
|
||||||
# 开始转移
|
# 开始转移
|
||||||
state, errormsg = transfer.manual_transfer(
|
state, errormsg = TransferChain().manual_transfer(
|
||||||
storage=storage,
|
fileitem=src_fileitem,
|
||||||
in_path=in_path,
|
target_storage=transer_item.target_storage,
|
||||||
drive_id=drive_id,
|
target_path=target_path,
|
||||||
fileid=fileid,
|
tmdbid=transer_item.tmdbid,
|
||||||
filetype=filetype,
|
doubanid=transer_item.doubanid,
|
||||||
target=target,
|
|
||||||
tmdbid=tmdbid,
|
|
||||||
doubanid=doubanid,
|
|
||||||
mtype=mtype,
|
mtype=mtype,
|
||||||
season=season,
|
season=transer_item.season,
|
||||||
transfer_type=transfer_type,
|
transfer_type=transer_item.transfer_type,
|
||||||
epformat=epformat,
|
epformat=epformat,
|
||||||
min_filesize=min_filesize,
|
min_filesize=transer_item.min_filesize,
|
||||||
scrape=scrape,
|
scrape=transer_item.scrape,
|
||||||
force=force
|
force=force
|
||||||
)
|
)
|
||||||
# 失败
|
# 失败
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import Any, List
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from starlette.responses import Response
|
|
||||||
|
|
||||||
from app import schemas
|
|
||||||
from app.chain.transfer import TransferChain
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.metainfo import MetaInfoPath
|
|
||||||
from app.core.security import verify_token, verify_uri_token
|
|
||||||
from app.helper.progress import ProgressHelper
|
|
||||||
from app.helper.u115 import U115Helper
|
|
||||||
from app.schemas.types import ProgressKey
|
|
||||||
from app.utils.http import RequestUtils
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/qrcode", summary="生成二维码内容", response_model=schemas.Response)
|
|
||||||
def qrcode(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
生成二维码
|
|
||||||
"""
|
|
||||||
qrcode_data = U115Helper().generate_qrcode()
|
|
||||||
if qrcode_data:
|
|
||||||
return schemas.Response(success=True, data={
|
|
||||||
'codeContent': qrcode_data
|
|
||||||
})
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/check", summary="二维码登录确认", response_model=schemas.Response)
|
|
||||||
def check(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
二维码登录确认
|
|
||||||
"""
|
|
||||||
data, errmsg = U115Helper().check_login()
|
|
||||||
if data:
|
|
||||||
return schemas.Response(success=True, data=data)
|
|
||||||
return schemas.Response(success=False, message=errmsg)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/storage", summary="查询存储空间信息", response_model=schemas.Response)
|
|
||||||
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
查询存储空间信息
|
|
||||||
"""
|
|
||||||
storage_info = U115Helper().storage()
|
|
||||||
if storage_info:
|
|
||||||
return schemas.Response(success=True, data={
|
|
||||||
"total": storage_info[0],
|
|
||||||
"used": storage_info[1]
|
|
||||||
})
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/list", summary="所有目录和文件(115网盘)", response_model=List[schemas.FileItem])
|
|
||||||
def list_115(fileitem: schemas.FileItem,
|
|
||||||
sort: str = 'updated_at',
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
查询当前目录下所有目录和文件
|
|
||||||
:param fileitem: 文件项
|
|
||||||
:param sort: 排序方式,name:按名称排序,time:按修改时间排序
|
|
||||||
:param _: token
|
|
||||||
:return: 所有目录和文件
|
|
||||||
"""
|
|
||||||
if not fileitem.fileid:
|
|
||||||
return []
|
|
||||||
if not fileitem.path:
|
|
||||||
path = "/"
|
|
||||||
else:
|
|
||||||
path = fileitem.path
|
|
||||||
if fileitem.fileid == "root":
|
|
||||||
fileid = "0"
|
|
||||||
else:
|
|
||||||
fileid = fileitem.fileid
|
|
||||||
if fileitem.type == "file":
|
|
||||||
name = Path(path).name
|
|
||||||
suffix = Path(name).suffix[1:]
|
|
||||||
return [schemas.FileItem(
|
|
||||||
fileid=fileid,
|
|
||||||
type="file",
|
|
||||||
path=path.rstrip('/'),
|
|
||||||
name=name,
|
|
||||||
extension=suffix,
|
|
||||||
pickcode=fileitem.pickcode
|
|
||||||
)]
|
|
||||||
file_list = U115Helper().list(parent_file_id=fileid, path=path)
|
|
||||||
if sort == "name":
|
|
||||||
file_list.sort(key=lambda x: x.name)
|
|
||||||
else:
|
|
||||||
file_list.sort(key=lambda x: x.modify_time, reverse=True)
|
|
||||||
return file_list
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mkdir", summary="创建目录(115网盘)", response_model=schemas.Response)
|
|
||||||
def mkdir_115(fileitem: schemas.FileItem,
|
|
||||||
name: str,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
创建目录
|
|
||||||
"""
|
|
||||||
if not fileitem.fileid or not name:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
result = U115Helper().create_folder(parent_file_id=fileitem.fileid, name=name, path=fileitem.path)
|
|
||||||
if result:
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/delete", summary="删除文件或目录(115网盘)", response_model=schemas.Response)
|
|
||||||
def delete_115(fileitem: schemas.FileItem,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
删除文件或目录
|
|
||||||
"""
|
|
||||||
if not fileitem.fileid:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
result = U115Helper().delete(fileitem.fileid)
|
|
||||||
if result:
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/download", summary="下载文件(115网盘)")
|
|
||||||
def download_115(pickcode: str,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
|
|
||||||
"""
|
|
||||||
下载文件或目录
|
|
||||||
"""
|
|
||||||
if not pickcode:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
ticket = U115Helper().download(pickcode)
|
|
||||||
if ticket:
|
|
||||||
# 请求数据,并以文件流的方式返回
|
|
||||||
res = RequestUtils(headers=ticket.headers).get_res(ticket.url)
|
|
||||||
if res:
|
|
||||||
return Response(content=res.content, media_type="application/octet-stream")
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rename", summary="重命名文件或目录(115网盘)", response_model=schemas.Response)
|
|
||||||
def rename_115(fileitem: schemas.FileItem,
|
|
||||||
new_name: str,
|
|
||||||
recursive: bool = False,
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
重命名文件或目录
|
|
||||||
"""
|
|
||||||
if not fileitem.fileid or not new_name:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
result = U115Helper().rename(fileitem.fileid, 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] = list_115(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_115(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)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/image", summary="读取图片(115网盘)")
|
|
||||||
def image_115(pickcode: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
|
|
||||||
"""
|
|
||||||
读取图片
|
|
||||||
"""
|
|
||||||
if not pickcode:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
ticket = U115Helper().download(pickcode)
|
|
||||||
if ticket:
|
|
||||||
# 请求数据,获取内容编码为图片base64返回
|
|
||||||
res = RequestUtils(headers=ticket.headers).get_res(ticket.url)
|
|
||||||
if res:
|
|
||||||
content_type = res.headers.get("Content-Type")
|
|
||||||
return Response(content=res.content, media_type=content_type)
|
|
||||||
raise HTTPException(status_code=500, detail="下载图片出错")
|
|
||||||
@@ -9,7 +9,7 @@ from app import schemas
|
|||||||
from app.core.security import get_password_hash
|
from app.core.security import get_password_hash
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.models.user import User
|
from app.db.models.user import User
|
||||||
from app.db.userauth import get_current_active_superuser, get_current_active_user
|
from app.db.user_oper import get_current_active_superuser, get_current_active_user
|
||||||
from app.db.userconfig_oper import UserConfigOper
|
from app.db.userconfig_oper import UserConfigOper
|
||||||
from app.utils.otp import OtpUtils
|
from app.utils.otp import OtpUtils
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="所有用户", response_model=List[schemas.User])
|
@router.get("/", summary="所有用户", response_model=List[schemas.User])
|
||||||
def read_users(
|
def list_users(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_active_superuser),
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -54,7 +54,7 @@ def create_user(
|
|||||||
def update_user(
|
def update_user(
|
||||||
*,
|
*,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user_in: schemas.UserCreate,
|
user_in: schemas.UserUpdate,
|
||||||
_: User = Depends(get_current_active_superuser),
|
_: User = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -69,7 +69,15 @@ def update_user(
|
|||||||
message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位")
|
message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位")
|
||||||
user_info["hashed_password"] = get_password_hash(user_info["password"])
|
user_info["hashed_password"] = get_password_hash(user_info["password"])
|
||||||
user_info.pop("password")
|
user_info.pop("password")
|
||||||
user = User.get_by_name(db, name=user_info["name"])
|
user = User.get_by_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)
|
||||||
|
for u in users:
|
||||||
|
if u.name == user_name and u.id != user_info["id"]:
|
||||||
|
return schemas.Response(success=False, message="用户名已被使用")
|
||||||
if not user:
|
if not user:
|
||||||
return schemas.Response(success=False, message="用户不存在")
|
return schemas.Response(success=False, message="用户不存在")
|
||||||
user.update(db, user_info)
|
user.update(db, user_info)
|
||||||
@@ -139,7 +147,7 @@ def otp_disable(
|
|||||||
def otp_enable(userid: str, db: Session = Depends(get_db)) -> Any:
|
def otp_enable(userid: str, db: Session = Depends(get_db)) -> Any:
|
||||||
user: User = User.get_by_name(db, userid)
|
user: User = User.get_by_name(db, userid)
|
||||||
if not user:
|
if not user:
|
||||||
return schemas.Response(success=False, message="用户不存在")
|
return schemas.Response(success=False)
|
||||||
return schemas.Response(success=user.is_otp)
|
return schemas.Response(success=user.is_otp)
|
||||||
|
|
||||||
|
|
||||||
@@ -165,15 +173,32 @@ def set_config(key: str, value: Union[list, dict, bool, int, str] = None,
|
|||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_name}", summary="删除用户", response_model=schemas.Response)
|
@router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response)
|
||||||
def delete_user(
|
def delete_user_by_id(
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
通过唯一ID删除用户
|
||||||
|
"""
|
||||||
|
user = current_user.get_by_id(db, user_id=user_id)
|
||||||
|
if not user:
|
||||||
|
return schemas.Response(success=False, message="用户不存在")
|
||||||
|
user.delete_by_id(db, user_id)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response)
|
||||||
|
def delete_user_by_name(
|
||||||
*,
|
*,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user_name: str,
|
user_name: str,
|
||||||
current_user: User = Depends(get_current_active_superuser),
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
删除用户
|
通过用户名删除用户
|
||||||
"""
|
"""
|
||||||
user = current_user.get_by_name(db, name=user_name)
|
user = current_user.get_by_name(db, name=user_name)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -182,16 +207,16 @@ def delete_user(
|
|||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", summary="用户详情", response_model=schemas.User)
|
@router.get("/{username}", summary="用户详情", response_model=schemas.User)
|
||||||
def read_user_by_id(
|
def read_user_by_name(
|
||||||
user_id: int,
|
username: str,
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
查询用户详情
|
查询用户详情
|
||||||
"""
|
"""
|
||||||
user = current_user.get(db, rid=user_id)
|
user = current_user.get_by_name(db, name=username)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
@@ -199,7 +224,7 @@ def read_user_by_id(
|
|||||||
)
|
)
|
||||||
if user == current_user:
|
if user == current_user:
|
||||||
return user
|
return user
|
||||||
if not user.is_superuser:
|
if not current_user.is_superuser:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="用户权限不足"
|
detail="用户权限不足"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ async def webhook_message(background_tasks: BackgroundTasks,
|
|||||||
_: str = Depends(verify_apitoken)
|
_: str = Depends(verify_apitoken)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Webhook响应
|
Webhook响应,配置请求中需要添加参数:token=API_TOKEN&source=媒体服务器名
|
||||||
"""
|
"""
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -35,7 +35,7 @@ async def webhook_message(background_tasks: BackgroundTasks,
|
|||||||
def webhook_message(background_tasks: BackgroundTasks,
|
def webhook_message(background_tasks: BackgroundTasks,
|
||||||
request: Request, _: str = Depends(verify_apitoken)) -> Any:
|
request: Request, _: str = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
Webhook响应
|
Webhook响应,配置请求中需要添加参数:token=API_TOKEN&source=媒体服务器名
|
||||||
"""
|
"""
|
||||||
args = request.query_params
|
args = request.query_params
|
||||||
background_tasks.add_task(start_webhook_chain, None, None, args)
|
background_tasks.add_task(start_webhook_chain, None, None, args)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import gzip
|
import gzip
|
||||||
import json
|
import json
|
||||||
from hashlib import md5
|
from typing import Annotated, Callable, Any, Dict, Optional
|
||||||
from typing import Annotated, Callable
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
@@ -11,7 +9,7 @@ from fastapi.routing import APIRoute
|
|||||||
from app import schemas
|
from app import schemas
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.utils.common import decrypt
|
from app.utils.crypto import CryptoJsUtils, HashUtils
|
||||||
|
|
||||||
|
|
||||||
class GzipRequest(Request):
|
class GzipRequest(Request):
|
||||||
@@ -47,7 +45,7 @@ async def verify_server_enabled():
|
|||||||
|
|
||||||
|
|
||||||
cookie_router = APIRouter(route_class=GzipRoute,
|
cookie_router = APIRouter(route_class=GzipRoute,
|
||||||
tags=['servcookie'],
|
tags=["servcookie"],
|
||||||
dependencies=[Depends(verify_server_enabled)])
|
dependencies=[Depends(verify_server_enabled)])
|
||||||
|
|
||||||
|
|
||||||
@@ -100,15 +98,14 @@ def get_decrypted_cookie_data(uuid: str, password: str,
|
|||||||
"""
|
"""
|
||||||
加载本地加密数据并解密为Cookie
|
加载本地加密数据并解密为Cookie
|
||||||
"""
|
"""
|
||||||
key_md5 = md5()
|
combined_string = f"{uuid}-{password}"
|
||||||
key_md5.update((uuid + '-' + password).encode('utf-8'))
|
aes_key = HashUtils.md5(combined_string)[:16].encode("utf-8")
|
||||||
aes_key = (key_md5.hexdigest()[:16]).encode('utf-8')
|
|
||||||
|
|
||||||
if encrypted:
|
if encrypted:
|
||||||
try:
|
try:
|
||||||
decrypted_data = decrypt(encrypted, aes_key).decode('utf-8')
|
decrypted_data = CryptoJsUtils.decrypt(encrypted, aes_key).decode("utf-8")
|
||||||
decrypted_data = json.loads(decrypted_data)
|
decrypted_data = json.loads(decrypted_data)
|
||||||
if 'cookie_data' in decrypted_data:
|
if "cookie_data" in decrypted_data:
|
||||||
return decrypted_data
|
return decrypted_data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"解密Cookie数据失败:{str(e)}")
|
logger.error(f"解密Cookie数据失败:{str(e)}")
|
||||||
|
|||||||
@@ -10,16 +10,17 @@ from ruamel.yaml import CommentedMap
|
|||||||
from transmission_rpc import File
|
from transmission_rpc import File
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import Context
|
from app.core.context import Context, MediaInfo, TorrentInfo
|
||||||
from app.core.context import MediaInfo, TorrentInfo
|
|
||||||
from app.core.event import EventManager
|
from app.core.event import EventManager
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.module import ModuleManager
|
from app.core.module import ModuleManager
|
||||||
from app.db.message_oper import MessageOper
|
from app.db.message_oper import MessageOper
|
||||||
|
from app.db.user_oper import UserOper
|
||||||
from app.helper.message import MessageHelper
|
from app.helper.message import MessageHelper
|
||||||
|
from app.helper.service import ServiceConfigHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||||
WebhookEventInfo, TmdbEpisode, MediaPerson
|
WebhookEventInfo, TmdbEpisode, MediaPerson, FileItem, TransferDirectoryConf
|
||||||
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
|
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
|
||||||
from app.utils.object import ObjectUtils
|
from app.utils.object import ObjectUtils
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
self.eventmanager = EventManager()
|
self.eventmanager = EventManager()
|
||||||
self.messageoper = MessageOper()
|
self.messageoper = MessageOper()
|
||||||
self.messagehelper = MessageHelper()
|
self.messagehelper = MessageHelper()
|
||||||
|
self.useroper = UserOper()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_cache(filename: str) -> Any:
|
def load_cache(filename: str) -> Any:
|
||||||
@@ -79,6 +81,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
def run_module(self, method: str, *args, **kwargs) -> Any:
|
def run_module(self, method: str, *args, **kwargs) -> Any:
|
||||||
"""
|
"""
|
||||||
运行包含该方法的所有模块,然后返回结果
|
运行包含该方法的所有模块,然后返回结果
|
||||||
|
当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def is_result_empty(ret):
|
def is_result_empty(ret):
|
||||||
@@ -93,12 +96,14 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
logger.debug(f"请求模块执行:{method} ...")
|
logger.debug(f"请求模块执行:{method} ...")
|
||||||
result = None
|
result = None
|
||||||
modules = self.modulemanager.get_running_modules(method)
|
modules = self.modulemanager.get_running_modules(method)
|
||||||
|
# 按优先级排序
|
||||||
|
modules = sorted(modules, key=lambda x: x.get_priority())
|
||||||
for module in modules:
|
for module in modules:
|
||||||
module_id = module.__class__.__name__
|
module_id = module.__class__.__name__
|
||||||
try:
|
try:
|
||||||
module_name = module.get_name()
|
module_name = module.get_name()
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"获取模块名称出错:{str(err)}")
|
logger.debug(f"获取模块名称出错:{str(err)}")
|
||||||
module_name = module_id
|
module_name = module_id
|
||||||
try:
|
try:
|
||||||
func = getattr(module, method)
|
func = getattr(module, method)
|
||||||
@@ -117,6 +122,8 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
# 中止继续执行
|
# 中止继续执行
|
||||||
break
|
break
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
|
if kwargs.get("raise_exception"):
|
||||||
|
raise
|
||||||
logger.error(
|
logger.error(
|
||||||
f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
|
f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||||
self.messagehelper.put(title=f"{module_name}发生了错误",
|
self.messagehelper.put(title=f"{module_name}发生了错误",
|
||||||
@@ -166,7 +173,8 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, cache=cache)
|
tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, cache=cache)
|
||||||
|
|
||||||
def match_doubaninfo(self, name: str, imdbid: str = None,
|
def match_doubaninfo(self, name: str, imdbid: str = None,
|
||||||
mtype: MediaType = None, year: str = None, season: int = None) -> Optional[dict]:
|
mtype: MediaType = None, year: str = None, season: int = None,
|
||||||
|
raise_exception: bool = False) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
搜索和匹配豆瓣信息
|
搜索和匹配豆瓣信息
|
||||||
:param name: 标题
|
:param name: 标题
|
||||||
@@ -174,9 +182,10 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:param mtype: 类型
|
:param mtype: 类型
|
||||||
:param year: 年份
|
:param year: 年份
|
||||||
:param season: 季
|
:param season: 季
|
||||||
|
:param raise_exception: 触发速率限制时是否抛出异常
|
||||||
"""
|
"""
|
||||||
return self.run_module("match_doubaninfo", name=name, imdbid=imdbid,
|
return self.run_module("match_doubaninfo", name=name, imdbid=imdbid,
|
||||||
mtype=mtype, year=year, season=season)
|
mtype=mtype, year=year, season=season, raise_exception=raise_exception)
|
||||||
|
|
||||||
def match_tmdbinfo(self, name: str, mtype: MediaType = None,
|
def match_tmdbinfo(self, name: str, mtype: MediaType = None,
|
||||||
year: str = None, season: int = None) -> Optional[dict]:
|
year: str = None, season: int = None) -> Optional[dict]:
|
||||||
@@ -214,14 +223,16 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
image_prefix=image_prefix, image_type=image_type,
|
image_prefix=image_prefix, image_type=image_type,
|
||||||
season=season, episode=episode)
|
season=season, episode=episode)
|
||||||
|
|
||||||
def douban_info(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
|
def douban_info(self, doubanid: str, mtype: MediaType = None,
|
||||||
|
raise_exception: bool = False) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
获取豆瓣信息
|
获取豆瓣信息
|
||||||
:param doubanid: 豆瓣ID
|
:param doubanid: 豆瓣ID
|
||||||
:param mtype: 媒体类型
|
:param mtype: 媒体类型
|
||||||
:return: 豆瓣信息
|
:return: 豆瓣信息
|
||||||
|
:param raise_exception: 触发速率限制时是否抛出异常
|
||||||
"""
|
"""
|
||||||
return self.run_module("douban_info", doubanid=doubanid, mtype=mtype)
|
return self.run_module("douban_info", doubanid=doubanid, mtype=mtype, raise_exception=raise_exception)
|
||||||
|
|
||||||
def tvdb_info(self, tvdbid: int) -> Optional[dict]:
|
def tvdb_info(self, tvdbid: int) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -249,19 +260,20 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("bangumi_info", bangumiid=bangumiid)
|
return self.run_module("bangumi_info", bangumiid=bangumiid)
|
||||||
|
|
||||||
def message_parser(self, body: Any, form: Any,
|
def message_parser(self, source: str, body: Any, form: Any,
|
||||||
args: Any) -> Optional[CommingMessage]:
|
args: Any) -> Optional[CommingMessage]:
|
||||||
"""
|
"""
|
||||||
解析消息内容,返回字典,注意以下约定值:
|
解析消息内容,返回字典,注意以下约定值:
|
||||||
userid: 用户ID
|
userid: 用户ID
|
||||||
username: 用户名
|
username: 用户名
|
||||||
text: 内容
|
text: 内容
|
||||||
|
:param source: 消息来源(渠道配置名称)
|
||||||
:param body: 请求体
|
:param body: 请求体
|
||||||
:param form: 表单
|
:param form: 表单
|
||||||
:param args: 参数
|
:param args: 参数
|
||||||
:return: 消息渠道、消息内容
|
:return: 消息渠道、消息内容
|
||||||
"""
|
"""
|
||||||
return self.run_module("message_parser", body=body, form=form, args=args)
|
return self.run_module("message_parser", source=source, body=body, form=form, args=args)
|
||||||
|
|
||||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:
|
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:
|
||||||
"""
|
"""
|
||||||
@@ -311,26 +323,26 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("refresh_torrents", site=site)
|
return self.run_module("refresh_torrents", site=site)
|
||||||
|
|
||||||
def filter_torrents(self, rule_string: str,
|
def filter_torrents(self, rule_groups: List[str],
|
||||||
torrent_list: List[TorrentInfo],
|
torrent_list: List[TorrentInfo],
|
||||||
season_episodes: Dict[int, list] = None,
|
season_episodes: Dict[int, list] = None,
|
||||||
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
||||||
"""
|
"""
|
||||||
过滤种子资源
|
过滤种子资源
|
||||||
:param rule_string: 过滤规则
|
:param rule_groups: 过滤规则组名称列表
|
||||||
:param torrent_list: 资源列表
|
:param torrent_list: 资源列表
|
||||||
:param season_episodes: 季集数过滤 {season:[episodes]}
|
:param season_episodes: 季集数过滤 {season:[episodes]}
|
||||||
:param mediainfo: 识别的媒体信息
|
:param mediainfo: 识别的媒体信息
|
||||||
:return: 过滤后的资源列表,添加资源优先级
|
:return: 过滤后的资源列表,添加资源优先级
|
||||||
"""
|
"""
|
||||||
return self.run_module("filter_torrents", rule_string=rule_string,
|
return self.run_module("filter_torrents", rule_groups=rule_groups,
|
||||||
torrent_list=torrent_list, season_episodes=season_episodes,
|
torrent_list=torrent_list, season_episodes=season_episodes,
|
||||||
mediainfo=mediainfo)
|
mediainfo=mediainfo)
|
||||||
|
|
||||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||||
episodes: Set[int] = None, category: str = None,
|
episodes: Set[int] = None, category: str = None,
|
||||||
downloader: str = settings.DEFAULT_DOWNLOADER
|
downloader: str = None
|
||||||
) -> Optional[Tuple[Optional[str], str]]:
|
) -> Optional[Tuple[Optional[str], Optional[str], str]]:
|
||||||
"""
|
"""
|
||||||
根据种子文件,选择并添加下载任务
|
根据种子文件,选择并添加下载任务
|
||||||
:param content: 种子文件地址或者磁力链接
|
:param content: 种子文件地址或者磁力链接
|
||||||
@@ -339,7 +351,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:param episodes: 需要下载的集数
|
:param episodes: 需要下载的集数
|
||||||
:param category: 种子分类
|
:param category: 种子分类
|
||||||
:param downloader: 下载器
|
:param downloader: 下载器
|
||||||
:return: 种子Hash,错误信息
|
:return: 下载器名称、种子Hash、错误信息
|
||||||
"""
|
"""
|
||||||
return self.run_module("download", content=content, download_dir=download_dir,
|
return self.run_module("download", content=content, download_dir=download_dir,
|
||||||
cookie=cookie, episodes=episodes, category=category,
|
cookie=cookie, episodes=episodes, category=category,
|
||||||
@@ -358,7 +370,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
|
|
||||||
def list_torrents(self, status: TorrentStatus = None,
|
def list_torrents(self, status: TorrentStatus = None,
|
||||||
hashs: Union[list, str] = None,
|
hashs: Union[list, str] = None,
|
||||||
downloader: str = settings.DEFAULT_DOWNLOADER
|
downloader: str = None
|
||||||
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||||
"""
|
"""
|
||||||
获取下载器种子列表
|
获取下载器种子列表
|
||||||
@@ -369,37 +381,41 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("list_torrents", status=status, hashs=hashs, downloader=downloader)
|
return self.run_module("list_torrents", status=status, hashs=hashs, downloader=downloader)
|
||||||
|
|
||||||
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
def transfer(self, fileitem: FileItem, meta: MetaBase, mediainfo: MediaInfo,
|
||||||
transfer_type: str, target: Path = None,
|
target_directory: TransferDirectoryConf = None,
|
||||||
episodes_info: List[TmdbEpisode] = None,
|
target_storage: str = None, target_path: Path = None,
|
||||||
scrape: bool = None) -> Optional[TransferInfo]:
|
transfer_type: str = None, scrape: bool = None,
|
||||||
|
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
||||||
"""
|
"""
|
||||||
文件转移
|
文件转移
|
||||||
:param path: 文件路径
|
:param fileitem: 文件信息
|
||||||
:param meta: 预识别的元数据
|
:param meta: 预识别的元数据
|
||||||
:param mediainfo: 识别的媒体信息
|
:param mediainfo: 识别的媒体信息
|
||||||
|
:param target_directory: 目标目录配置
|
||||||
|
:param target_storage: 目标存储
|
||||||
|
:param target_path: 目标路径
|
||||||
:param transfer_type: 转移模式
|
:param transfer_type: 转移模式
|
||||||
:param target: 转移目标路径
|
|
||||||
:param episodes_info: 当前季的全部集信息
|
|
||||||
:param scrape: 是否刮削元数据
|
:param scrape: 是否刮削元数据
|
||||||
|
:param episodes_info: 当前季的全部集信息
|
||||||
:return: {path, target_path, message}
|
:return: {path, target_path, message}
|
||||||
"""
|
"""
|
||||||
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
|
return self.run_module("transfer",
|
||||||
transfer_type=transfer_type, target=target, episodes_info=episodes_info,
|
fileitem=fileitem, meta=meta, mediainfo=mediainfo,
|
||||||
scrape=scrape)
|
target_directory=target_directory,
|
||||||
|
target_path=target_path, target_storage=target_storage,
|
||||||
|
transfer_type=transfer_type, scrape=scrape,
|
||||||
|
episodes_info=episodes_info)
|
||||||
|
|
||||||
def transfer_completed(self, hashs: str, path: Path = None,
|
def transfer_completed(self, hashs: str, downloader: str = None) -> None:
|
||||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
|
|
||||||
"""
|
"""
|
||||||
转移完成后的处理
|
下载器转移完成后的处理
|
||||||
:param hashs: 种子Hash
|
:param hashs: 种子Hash
|
||||||
:param path: 源目录
|
|
||||||
:param downloader: 下载器
|
:param downloader: 下载器
|
||||||
"""
|
"""
|
||||||
return self.run_module("transfer_completed", hashs=hashs, path=path, downloader=downloader)
|
return self.run_module("transfer_completed", hashs=hashs, downloader=downloader)
|
||||||
|
|
||||||
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True,
|
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True,
|
||||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
|
downloader: str = None) -> bool:
|
||||||
"""
|
"""
|
||||||
删除下载器种子
|
删除下载器种子
|
||||||
:param hashs: 种子Hash
|
:param hashs: 种子Hash
|
||||||
@@ -409,7 +425,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("remove_torrents", hashs=hashs, delete_file=delete_file, downloader=downloader)
|
return self.run_module("remove_torrents", hashs=hashs, delete_file=delete_file, downloader=downloader)
|
||||||
|
|
||||||
def start_torrents(self, hashs: Union[list, str], downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
|
def start_torrents(self, hashs: Union[list, str], downloader: str = None) -> bool:
|
||||||
"""
|
"""
|
||||||
开始下载
|
开始下载
|
||||||
:param hashs: 种子Hash
|
:param hashs: 种子Hash
|
||||||
@@ -418,7 +434,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("start_torrents", hashs=hashs, downloader=downloader)
|
return self.run_module("start_torrents", hashs=hashs, downloader=downloader)
|
||||||
|
|
||||||
def stop_torrents(self, hashs: Union[list, str], downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
|
def stop_torrents(self, hashs: Union[list, str], downloader: str = None) -> bool:
|
||||||
"""
|
"""
|
||||||
停止下载
|
停止下载
|
||||||
:param hashs: 种子Hash
|
:param hashs: 种子Hash
|
||||||
@@ -428,7 +444,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
return self.run_module("stop_torrents", hashs=hashs, downloader=downloader)
|
return self.run_module("stop_torrents", hashs=hashs, downloader=downloader)
|
||||||
|
|
||||||
def torrent_files(self, tid: str,
|
def torrent_files(self, tid: str,
|
||||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[Union[TorrentFilesList, List[File]]]:
|
downloader: str = None) -> Optional[Union[TorrentFilesList, List[File]]]:
|
||||||
"""
|
"""
|
||||||
获取种子文件
|
获取种子文件
|
||||||
:param tid: 种子Hash
|
:param tid: 种子Hash
|
||||||
@@ -437,14 +453,24 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("torrent_files", tid=tid, downloader=downloader)
|
return self.run_module("torrent_files", tid=tid, downloader=downloader)
|
||||||
|
|
||||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
def media_exists(self, mediainfo: MediaInfo, itemid: str = None,
|
||||||
|
server: str = None) -> Optional[ExistMediaInfo]:
|
||||||
"""
|
"""
|
||||||
判断媒体文件是否存在
|
判断媒体文件是否存在
|
||||||
:param mediainfo: 识别的媒体信息
|
:param mediainfo: 识别的媒体信息
|
||||||
:param itemid: 媒体服务器ItemID
|
:param itemid: 媒体服务器ItemID
|
||||||
|
:param server: 媒体服务器
|
||||||
:return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
|
:return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
|
||||||
"""
|
"""
|
||||||
return self.run_module("media_exists", mediainfo=mediainfo, itemid=itemid)
|
return self.run_module("media_exists", mediainfo=mediainfo, itemid=itemid, server=server)
|
||||||
|
|
||||||
|
def media_files(self, mediainfo: MediaInfo) -> Optional[List[FileItem]]:
|
||||||
|
"""
|
||||||
|
获取媒体文件清单
|
||||||
|
:param mediainfo: 识别的媒体信息
|
||||||
|
:return: 媒体文件列表
|
||||||
|
"""
|
||||||
|
return self.run_module("media_files", mediainfo=mediainfo)
|
||||||
|
|
||||||
def post_message(self, message: Notification) -> None:
|
def post_message(self, message: Notification) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -453,29 +479,39 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:return: 成功或失败
|
:return: 成功或失败
|
||||||
"""
|
"""
|
||||||
logger.info(f"发送消息:channel={message.channel},"
|
logger.info(f"发送消息:channel={message.channel},"
|
||||||
|
f"source={message.source},"
|
||||||
f"title={message.title}, "
|
f"title={message.title}, "
|
||||||
f"text={message.text},"
|
f"text={message.text},"
|
||||||
f"userid={message.userid}")
|
f"userid={message.userid}")
|
||||||
|
if not message.userid and message.mtype:
|
||||||
|
# 没有指定用户ID时,按规则确定发送对象
|
||||||
|
# 默认发送全体
|
||||||
|
to_targets = None
|
||||||
|
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
|
||||||
|
if notify_action == "admin":
|
||||||
|
# 仅发送管理员
|
||||||
|
logger.info(f"已设置 {message.mtype} 的消息只发送给管理员")
|
||||||
|
to_targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||||
|
elif notify_action == "user":
|
||||||
|
# 发送对应用户
|
||||||
|
if message.username:
|
||||||
|
logger.info(f"已设置 {message.mtype} 的消息只发送给用户 {message.username}")
|
||||||
|
to_targets = self.useroper.get_settings(message.username)
|
||||||
|
if not message.username or to_targets is None:
|
||||||
|
if message.username:
|
||||||
|
logger.info(f"没有 {message.username} 这个用户,该消息将发送给管理员")
|
||||||
|
# 回滚发送管理员
|
||||||
|
to_targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||||
|
message.targets = to_targets
|
||||||
# 发送事件
|
# 发送事件
|
||||||
self.eventmanager.send_event(etype=EventType.NoticeMessage,
|
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
|
||||||
data={
|
|
||||||
"channel": message.channel,
|
|
||||||
"type": message.mtype,
|
|
||||||
"title": message.title,
|
|
||||||
"text": message.text,
|
|
||||||
"image": message.image,
|
|
||||||
"userid": message.userid,
|
|
||||||
})
|
|
||||||
# 保存消息
|
# 保存消息
|
||||||
self.messagehelper.put(message, role="user")
|
self.messagehelper.put(message, role="user", title=message.title)
|
||||||
self.messageoper.add(channel=message.channel, mtype=message.mtype,
|
self.messageoper.add(**message.dict())
|
||||||
title=message.title, text=message.text,
|
|
||||||
image=message.image, link=message.link,
|
|
||||||
userid=message.userid, action=1)
|
|
||||||
# 发送
|
# 发送
|
||||||
self.run_module("post_message", message=message)
|
self.run_module("post_message", message=message)
|
||||||
|
|
||||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
|
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||||
"""
|
"""
|
||||||
发送媒体信息选择列表
|
发送媒体信息选择列表
|
||||||
:param message: 消息体
|
:param message: 消息体
|
||||||
@@ -483,15 +519,11 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:return: 成功或失败
|
:return: 成功或失败
|
||||||
"""
|
"""
|
||||||
note_list = [media.to_dict() for media in medias]
|
note_list = [media.to_dict() for media in medias]
|
||||||
self.messagehelper.put(message, role="user", note=note_list)
|
self.messagehelper.put(message, role="user", note=note_list, title=message.title)
|
||||||
self.messageoper.add(channel=message.channel, mtype=message.mtype,
|
self.messageoper.add(**message.dict(), note=note_list)
|
||||||
title=message.title, text=message.text,
|
|
||||||
image=message.image, link=message.link,
|
|
||||||
userid=message.userid, action=1,
|
|
||||||
note=note_list)
|
|
||||||
return self.run_module("post_medias_message", message=message, medias=medias)
|
return self.run_module("post_medias_message", message=message, medias=medias)
|
||||||
|
|
||||||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
|
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
|
||||||
"""
|
"""
|
||||||
发送种子信息选择列表
|
发送种子信息选择列表
|
||||||
:param message: 消息体
|
:param message: 消息体
|
||||||
@@ -499,36 +531,18 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:return: 成功或失败
|
:return: 成功或失败
|
||||||
"""
|
"""
|
||||||
note_list = [torrent.torrent_info.to_dict() for torrent in torrents]
|
note_list = [torrent.torrent_info.to_dict() for torrent in torrents]
|
||||||
self.messagehelper.put(message, role="user", note=note_list)
|
self.messagehelper.put(message, role="user", note=note_list, title=message.title)
|
||||||
self.messageoper.add(channel=message.channel, mtype=message.mtype,
|
self.messageoper.add(**message.dict(), note=note_list)
|
||||||
title=message.title, text=message.text,
|
|
||||||
image=message.image, link=message.link,
|
|
||||||
userid=message.userid, action=1,
|
|
||||||
note=note_list)
|
|
||||||
return self.run_module("post_torrents_message", message=message, torrents=torrents)
|
return self.run_module("post_torrents_message", message=message, torrents=torrents)
|
||||||
|
|
||||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
|
def metadata_img(self, mediainfo: MediaInfo, season: int = None, episode: int = None) -> Optional[dict]:
|
||||||
metainfo: MetaBase = None, force_nfo: bool = False, force_img: bool = False) -> None:
|
|
||||||
"""
|
|
||||||
刮削元数据
|
|
||||||
:param path: 媒体文件路径
|
|
||||||
:param mediainfo: 识别的媒体信息
|
|
||||||
:param metainfo: 源文件的识别元数据
|
|
||||||
:param transfer_type: 转移模式
|
|
||||||
:param force_nfo: 强制刮削nfo
|
|
||||||
:param force_img: 强制刮削图片
|
|
||||||
:return: 成功或失败
|
|
||||||
"""
|
|
||||||
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo, metainfo=metainfo,
|
|
||||||
transfer_type=transfer_type, force_nfo=force_nfo, force_img=force_img)
|
|
||||||
|
|
||||||
def metadata_img(self, mediainfo: MediaInfo, season: int = None) -> Optional[dict]:
|
|
||||||
"""
|
"""
|
||||||
获取图片名称和url
|
获取图片名称和url
|
||||||
:param mediainfo: 媒体信息
|
:param mediainfo: 媒体信息
|
||||||
:param season: 季号
|
:param season: 季号
|
||||||
|
:param episode: 集号
|
||||||
"""
|
"""
|
||||||
return self.run_module("metadata_img", mediainfo=mediainfo, season=season)
|
return self.run_module("metadata_img", mediainfo=mediainfo, season=season, episode=episode)
|
||||||
|
|
||||||
def media_category(self) -> Optional[Dict[str, list]]:
|
def media_category(self) -> Optional[Dict[str, list]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import importlib
|
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
from threading import Thread
|
from typing import Any, Union, Dict, Optional
|
||||||
from typing import Any, Union, Dict
|
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
@@ -11,53 +9,34 @@ from app.chain.subscribe import SubscribeChain
|
|||||||
from app.chain.system import SystemChain
|
from app.chain.system import SystemChain
|
||||||
from app.chain.transfer import TransferChain
|
from app.chain.transfer import TransferChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.event import Event as ManagerEvent
|
from app.core.event import Event as ManagerEvent, eventmanager, Event
|
||||||
from app.core.event import eventmanager, EventManager
|
|
||||||
from app.core.plugin import PluginManager
|
from app.core.plugin import PluginManager
|
||||||
from app.helper.message import MessageHelper
|
from app.helper.message import MessageHelper
|
||||||
from app.helper.thread import ThreadHelper
|
from app.helper.thread import ThreadHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.schemas import Notification
|
from app.schemas import Notification
|
||||||
from app.schemas.types import EventType, MessageChannel
|
from app.schemas.event import CommandRegisterEventData
|
||||||
|
from app.schemas.types import EventType, MessageChannel, ChainEventType
|
||||||
from app.utils.object import ObjectUtils
|
from app.utils.object import ObjectUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
from app.utils.structures import DictUtils
|
||||||
|
|
||||||
|
|
||||||
class CommandChian(ChainBase):
|
class CommandChain(ChainBase, metaclass=Singleton):
|
||||||
"""
|
|
||||||
插件处理链
|
|
||||||
"""
|
|
||||||
|
|
||||||
def process(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Command(metaclass=Singleton):
|
|
||||||
"""
|
"""
|
||||||
全局命令管理,消费事件
|
全局命令管理,消费事件
|
||||||
"""
|
"""
|
||||||
# 内建命令
|
|
||||||
_commands = {}
|
|
||||||
|
|
||||||
# 退出事件
|
|
||||||
_event = threading.Event()
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# 事件管理器
|
|
||||||
self.eventmanager = EventManager()
|
|
||||||
# 插件管理器
|
# 插件管理器
|
||||||
self.pluginmanager = PluginManager()
|
super().__init__()
|
||||||
# 处理链
|
# 注册的命令集合
|
||||||
self.chain = CommandChian()
|
self._registered_commands = {}
|
||||||
# 定时服务管理
|
# 所有命令集合
|
||||||
self.scheduler = Scheduler()
|
self._commands = {}
|
||||||
# 消息管理器
|
# 内建命令集合
|
||||||
self.messagehelper = MessageHelper()
|
self._preset_commands = {
|
||||||
# 线程管理器
|
|
||||||
self.threader = ThreadHelper()
|
|
||||||
# 内置命令
|
|
||||||
self._commands = {
|
|
||||||
"/cookiecloud": {
|
"/cookiecloud": {
|
||||||
"id": "cookiecloud",
|
"id": "cookiecloud",
|
||||||
"type": "scheduler",
|
"type": "scheduler",
|
||||||
@@ -155,98 +134,148 @@ class Command(metaclass=Singleton):
|
|||||||
"data": {}
|
"data": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
# 汇总插件命令
|
# 插件命令集合
|
||||||
plugin_commands = self.pluginmanager.get_plugin_commands()
|
self._plugin_commands = {}
|
||||||
for command in plugin_commands:
|
# 其他命令集合
|
||||||
self.register(
|
self._other_commands = {}
|
||||||
cmd=command.get('cmd'),
|
# 初始化锁
|
||||||
func=Command.send_plugin_event,
|
self._rlock = threading.RLock()
|
||||||
desc=command.get('desc'),
|
# 插件管理
|
||||||
category=command.get('category'),
|
self.pluginmanager = PluginManager()
|
||||||
data={
|
# 定时服务管理
|
||||||
'etype': command.get('event'),
|
self.scheduler = Scheduler()
|
||||||
'data': command.get('data')
|
# 消息管理器
|
||||||
|
self.messagehelper = MessageHelper()
|
||||||
|
# 初始化命令
|
||||||
|
self.init_commands()
|
||||||
|
|
||||||
|
def init_commands(self, pid: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
初始化菜单命令
|
||||||
|
"""
|
||||||
|
if settings.DEV:
|
||||||
|
logger.debug("Development mode active. Skipping command initialization.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 使用线程池提交后台任务,避免引起阻塞
|
||||||
|
ThreadHelper().submit(self.__init_commands_background, pid)
|
||||||
|
|
||||||
|
def __init_commands_background(self, pid: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
后台初始化菜单命令
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self._rlock:
|
||||||
|
logger.debug("Acquired lock for initializing commands in background.")
|
||||||
|
self._plugin_commands = self.__build_plugin_commands(pid)
|
||||||
|
self._commands = {
|
||||||
|
**self._preset_commands,
|
||||||
|
**self._plugin_commands,
|
||||||
|
**self._other_commands
|
||||||
}
|
}
|
||||||
)
|
|
||||||
# 广播注册命令菜单
|
|
||||||
if not settings.DEV:
|
|
||||||
self.chain.register_commands(commands=self.get_commands())
|
|
||||||
# 消息处理线程
|
|
||||||
self._thread = Thread(target=self.__run)
|
|
||||||
# 启动事件处理线程
|
|
||||||
self._thread.start()
|
|
||||||
# 重启msg
|
|
||||||
SystemChain().restart_finish()
|
|
||||||
|
|
||||||
def __run(self):
|
# 强制触发注册
|
||||||
|
force_register = False
|
||||||
|
# 触发事件允许可以拦截和调整命令
|
||||||
|
event, initial_commands = self.__trigger_register_commands_event()
|
||||||
|
|
||||||
|
if event and event.event_data:
|
||||||
|
# 如果事件返回有效的 event_data,使用事件中调整后的命令
|
||||||
|
event_data: CommandRegisterEventData = event.event_data
|
||||||
|
# 如果事件被取消,跳过命令注册
|
||||||
|
if event_data.cancel:
|
||||||
|
logger.debug(f"Command initialization canceled by event: {event_data.source}")
|
||||||
|
return
|
||||||
|
# 如果拦截源与插件标识一致时,这里认为需要强制触发注册
|
||||||
|
if pid is not None and pid == event_data.source:
|
||||||
|
force_register = True
|
||||||
|
initial_commands = event_data.commands or {}
|
||||||
|
logger.debug(f"Registering command count from event: {len(initial_commands)}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Registering initial command count: {len(initial_commands)}")
|
||||||
|
|
||||||
|
# initial_commands 必须是 self._commands 的子集
|
||||||
|
filtered_initial_commands = DictUtils.filter_keys_to_subset(initial_commands, self._commands)
|
||||||
|
# 如果 filtered_initial_commands 为空,则跳过注册
|
||||||
|
if not filtered_initial_commands and not force_register:
|
||||||
|
logger.debug("Filtered commands are empty, skipping registration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 对比调整后的命令与当前命令
|
||||||
|
if filtered_initial_commands != self._registered_commands or force_register:
|
||||||
|
logger.debug("Command set has changed or force registration is enabled.")
|
||||||
|
self._registered_commands = filtered_initial_commands
|
||||||
|
super().register_commands(commands=filtered_initial_commands)
|
||||||
|
else:
|
||||||
|
logger.debug("Command set unchanged, skipping broadcast registration.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error occurred during command initialization in background: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def __trigger_register_commands_event(self) -> (Optional[Event], dict):
|
||||||
"""
|
"""
|
||||||
事件处理线程
|
触发事件,允许调整命令数据
|
||||||
"""
|
"""
|
||||||
while not self._event.is_set():
|
|
||||||
event, handlers = self.eventmanager.get_event()
|
|
||||||
if event:
|
|
||||||
logger.info(f"处理事件:{event.event_type} - {handlers}")
|
|
||||||
for handler in handlers:
|
|
||||||
names = handler.__qualname__.split(".")
|
|
||||||
[class_name, method_name] = names
|
|
||||||
try:
|
|
||||||
if class_name in self.pluginmanager.get_plugin_ids():
|
|
||||||
# 插件事件
|
|
||||||
self.threader.submit(
|
|
||||||
self.pluginmanager.run_plugin_method,
|
|
||||||
class_name, method_name, event
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
def add_commands(source, command_type):
|
||||||
# 检查全局变量中是否存在
|
"""
|
||||||
if class_name not in globals():
|
添加命令集合
|
||||||
# 导入模块,除了插件和Command本身,只有chain能响应事件
|
"""
|
||||||
try:
|
for cmd, command in source.items():
|
||||||
module = importlib.import_module(
|
command_data = {
|
||||||
f"app.chain.{class_name[:-5].lower()}"
|
"type": command_type,
|
||||||
)
|
"description": command.get("description"),
|
||||||
class_obj = getattr(module, class_name)()
|
"category": command.get("category")
|
||||||
except Exception as e:
|
}
|
||||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
# 如果有 pid,则添加到命令数据中
|
||||||
continue
|
plugin_id = command.get("pid")
|
||||||
|
if plugin_id:
|
||||||
|
command_data["pid"] = plugin_id
|
||||||
|
commands[cmd] = command_data
|
||||||
|
|
||||||
else:
|
# 初始化命令字典
|
||||||
# 通过类名创建类实例
|
commands: Dict[str, dict] = {}
|
||||||
class_obj = globals()[class_name]()
|
add_commands(self._preset_commands, "preset")
|
||||||
# 检查类是否存在并调用方法
|
add_commands(self._plugin_commands, "plugin")
|
||||||
if hasattr(class_obj, method_name):
|
add_commands(self._other_commands, "other")
|
||||||
self.threader.submit(
|
|
||||||
getattr(class_obj, method_name),
|
|
||||||
event
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
|
||||||
self.messagehelper.put(title=f"{event.event_type} 事件处理出错",
|
|
||||||
message=f"{class_name}.{method_name}:{str(e)}",
|
|
||||||
role="system")
|
|
||||||
self.eventmanager.send_event(
|
|
||||||
EventType.SystemError,
|
|
||||||
{
|
|
||||||
"type": "event",
|
|
||||||
"event_type": event.event_type,
|
|
||||||
"event_handle": f"{class_name}.{method_name}",
|
|
||||||
"error": str(e),
|
|
||||||
"traceback": traceback.format_exc()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def __run_command(self, command: Dict[str, any],
|
# 触发事件允许可以拦截和调整命令
|
||||||
data_str: str = "",
|
event_data = CommandRegisterEventData(commands=commands, origin="CommandChain", service=None)
|
||||||
channel: MessageChannel = None, userid: Union[str, int] = None):
|
event = eventmanager.send_event(ChainEventType.CommandRegister, event_data)
|
||||||
|
return event, commands
|
||||||
|
|
||||||
|
def __build_plugin_commands(self, pid: Optional[str] = None) -> Dict[str, dict]:
|
||||||
|
"""
|
||||||
|
构建插件命令
|
||||||
|
"""
|
||||||
|
# 为了保证命令顺序的一致性,目前这里没有直接使用 pid 获取单一插件命令,后续如果存在性能问题,可以考虑优化这里的逻辑
|
||||||
|
plugin_commands = {}
|
||||||
|
for command in self.pluginmanager.get_plugin_commands():
|
||||||
|
cmd = command.get("cmd")
|
||||||
|
if cmd:
|
||||||
|
plugin_commands[cmd] = {
|
||||||
|
"pid": command.get("pid"),
|
||||||
|
"func": self.send_plugin_event,
|
||||||
|
"description": command.get("desc"),
|
||||||
|
"category": command.get("category"),
|
||||||
|
"data": {
|
||||||
|
"etype": command.get("event"),
|
||||||
|
"data": command.get("data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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):
|
||||||
"""
|
"""
|
||||||
运行定时服务
|
运行定时服务
|
||||||
"""
|
"""
|
||||||
if command.get("type") == "scheduler":
|
if command.get("type") == "scheduler":
|
||||||
# 定时服务
|
# 定时服务
|
||||||
if userid:
|
if userid:
|
||||||
self.chain.post_message(
|
self.post_message(
|
||||||
Notification(
|
Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=f"开始执行 {command.get('description')} ...",
|
title=f"开始执行 {command.get('description')} ...",
|
||||||
userid=userid
|
userid=userid
|
||||||
)
|
)
|
||||||
@@ -256,9 +285,10 @@ class Command(metaclass=Singleton):
|
|||||||
self.scheduler.start(job_id=command.get("id"))
|
self.scheduler.start(job_id=command.get("id"))
|
||||||
|
|
||||||
if userid:
|
if userid:
|
||||||
self.chain.post_message(
|
self.post_message(
|
||||||
Notification(
|
Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=f"{command.get('description')} 执行完成",
|
title=f"{command.get('description')} 执行完成",
|
||||||
userid=userid
|
userid=userid
|
||||||
)
|
)
|
||||||
@@ -272,59 +302,50 @@ class Command(metaclass=Singleton):
|
|||||||
# 有内置参数直接使用内置参数
|
# 有内置参数直接使用内置参数
|
||||||
data = cmd_data.get("data") or {}
|
data = cmd_data.get("data") or {}
|
||||||
data['channel'] = channel
|
data['channel'] = channel
|
||||||
|
data['source'] = source
|
||||||
data['user'] = userid
|
data['user'] = userid
|
||||||
if data_str:
|
if data_str:
|
||||||
data['args'] = data_str
|
data['arg_str'] = data_str
|
||||||
cmd_data['data'] = data
|
cmd_data['data'] = data
|
||||||
command['func'](**cmd_data)
|
command['func'](**cmd_data)
|
||||||
elif args_num == 2:
|
elif args_num == 3:
|
||||||
# 没有输入参数,只输入渠道和用户ID
|
# 没有输入参数,只输入渠道来源、用户ID和消息来源
|
||||||
command['func'](channel, userid)
|
command['func'](channel, userid, source)
|
||||||
elif args_num > 2:
|
elif args_num > 3:
|
||||||
# 多个输入参数:用户输入、用户ID
|
# 多个输入参数:用户输入、用户ID
|
||||||
command['func'](data_str, channel, userid)
|
command['func'](data_str, channel, userid, source)
|
||||||
else:
|
else:
|
||||||
# 没有参数
|
# 没有参数
|
||||||
command['func']()
|
command['func']()
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""
|
|
||||||
停止事件处理线程
|
|
||||||
"""
|
|
||||||
logger.info("正在停止事件处理...")
|
|
||||||
self._event.set()
|
|
||||||
try:
|
|
||||||
self._thread.join()
|
|
||||||
logger.info("事件处理停止完成")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
|
|
||||||
|
|
||||||
def get_commands(self):
|
def get_commands(self):
|
||||||
"""
|
"""
|
||||||
获取命令列表
|
获取命令列表
|
||||||
"""
|
"""
|
||||||
return self._commands
|
return self._commands
|
||||||
|
|
||||||
def register(self, cmd: str, func: Any, data: dict = None,
|
|
||||||
desc: str = None, category: str = None) -> None:
|
|
||||||
"""
|
|
||||||
注册命令
|
|
||||||
"""
|
|
||||||
self._commands[cmd] = {
|
|
||||||
"func": func,
|
|
||||||
"description": desc,
|
|
||||||
"category": category,
|
|
||||||
"data": data or {}
|
|
||||||
}
|
|
||||||
|
|
||||||
def get(self, cmd: str) -> Any:
|
def get(self, cmd: str) -> Any:
|
||||||
"""
|
"""
|
||||||
获取命令
|
获取命令
|
||||||
"""
|
"""
|
||||||
return self._commands.get(cmd, {})
|
return self._commands.get(cmd, {})
|
||||||
|
|
||||||
|
def register(self, cmd: str, func: Any, data: dict = None,
|
||||||
|
desc: str = None, category: str = None) -> None:
|
||||||
|
"""
|
||||||
|
注册单个命令
|
||||||
|
"""
|
||||||
|
# 单独调用的,统一注册到其他
|
||||||
|
self._other_commands[cmd] = {
|
||||||
|
"func": func,
|
||||||
|
"description": desc,
|
||||||
|
"category": category,
|
||||||
|
"data": data or {}
|
||||||
|
}
|
||||||
|
|
||||||
def execute(self, cmd: str, data_str: str = "",
|
def execute(self, cmd: str, data_str: str = "",
|
||||||
channel: MessageChannel = None, userid: Union[str, int] = None) -> None:
|
channel: MessageChannel = None, source: str = None,
|
||||||
|
userid: Union[str, int] = None) -> None:
|
||||||
"""
|
"""
|
||||||
执行命令
|
执行命令
|
||||||
"""
|
"""
|
||||||
@@ -338,7 +359,7 @@ class Command(metaclass=Singleton):
|
|||||||
|
|
||||||
# 执行命令
|
# 执行命令
|
||||||
self.__run_command(command, data_str=data_str,
|
self.__run_command(command, data_str=data_str,
|
||||||
channel=channel, userid=userid)
|
channel=channel, source=source, userid=userid)
|
||||||
|
|
||||||
if userid:
|
if userid:
|
||||||
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
|
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
|
||||||
@@ -355,7 +376,7 @@ class Command(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
发送插件命令
|
发送插件命令
|
||||||
"""
|
"""
|
||||||
EventManager().send_event(etype, data)
|
eventmanager.send_event(etype, data)
|
||||||
|
|
||||||
@eventmanager.register(EventType.CommandExcute)
|
@eventmanager.register(EventType.CommandExcute)
|
||||||
def command_event(self, event: ManagerEvent) -> None:
|
def command_event(self, event: ManagerEvent) -> None:
|
||||||
@@ -369,10 +390,21 @@ class Command(metaclass=Singleton):
|
|||||||
event_str = event.event_data.get('cmd')
|
event_str = event.event_data.get('cmd')
|
||||||
# 消息渠道
|
# 消息渠道
|
||||||
event_channel = event.event_data.get('channel')
|
event_channel = event.event_data.get('channel')
|
||||||
|
# 消息来源
|
||||||
|
event_source = event.event_data.get('source')
|
||||||
# 消息用户
|
# 消息用户
|
||||||
event_user = event.event_data.get('user')
|
event_user = event.event_data.get('user')
|
||||||
if event_str:
|
if event_str:
|
||||||
cmd = event_str.split()[0]
|
cmd = event_str.split()[0]
|
||||||
args = " ".join(event_str.split()[1:])
|
args = " ".join(event_str.split()[1:])
|
||||||
if self.get(cmd):
|
if self.get(cmd):
|
||||||
self.execute(cmd, args, event_channel, event_user)
|
self.execute(cmd=cmd, data_str=args,
|
||||||
|
channel=event_channel, source=event_source, userid=event_user)
|
||||||
|
|
||||||
|
@eventmanager.register(EventType.ModuleReload)
|
||||||
|
def module_reload_event(self, event: ManagerEvent) -> None:
|
||||||
|
"""
|
||||||
|
注册模块重载事件
|
||||||
|
"""
|
||||||
|
# 发生模块重载时,重新注册命令
|
||||||
|
self.init_commands()
|
||||||
@@ -9,14 +9,14 @@ class DashboardChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
各类仪表板统计处理链
|
各类仪表板统计处理链
|
||||||
"""
|
"""
|
||||||
def media_statistic(self) -> Optional[List[schemas.Statistic]]:
|
def media_statistic(self, server: str = None) -> Optional[List[schemas.Statistic]]:
|
||||||
"""
|
"""
|
||||||
媒体数量统计
|
媒体数量统计
|
||||||
"""
|
"""
|
||||||
return self.run_module("media_statistic")
|
return self.run_module("media_statistic", server=server)
|
||||||
|
|
||||||
def downloader_info(self) -> Optional[List[schemas.DownloaderInfo]]:
|
def downloader_info(self, downloader: str = None) -> Optional[List[schemas.DownloaderInfo]]:
|
||||||
"""
|
"""
|
||||||
下载器信息
|
下载器信息
|
||||||
"""
|
"""
|
||||||
return self.run_module("downloader_info")
|
return self.run_module("downloader_info", downloader=downloader)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import List, Optional, Tuple, Set, Dict, Union
|
|||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.config import settings
|
from app.core.config import settings, global_vars
|
||||||
from app.core.context import MediaInfo, TorrentInfo, Context
|
from app.core.context import MediaInfo, TorrentInfo, Context
|
||||||
from app.core.event import eventmanager, Event
|
from app.core.event import eventmanager, Event
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
@@ -39,18 +39,18 @@ class DownloadChain(ChainBase):
|
|||||||
self.messagehelper = MessageHelper()
|
self.messagehelper = MessageHelper()
|
||||||
|
|
||||||
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
|
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
|
||||||
channel: MessageChannel = None, userid: str = None, username: str = None,
|
channel: MessageChannel = None, username: str = None,
|
||||||
download_episodes: str = None):
|
download_episodes: str = None):
|
||||||
"""
|
"""
|
||||||
发送添加下载的消息
|
发送添加下载的消息,根据消息场景开关决定发给谁
|
||||||
:param meta: 元数据
|
:param meta: 元数据
|
||||||
:param mediainfo: 媒体信息
|
:param mediainfo: 媒体信息
|
||||||
:param torrent: 种子信息
|
:param torrent: 种子信息
|
||||||
:param channel: 通知渠道
|
:param channel: 通知渠道
|
||||||
:param userid: 用户ID,指定时精确发送对应用户
|
|
||||||
:param username: 通知显示的下载用户信息
|
:param username: 通知显示的下载用户信息
|
||||||
:param download_episodes: 下载的集数
|
:param download_episodes: 下载的集数
|
||||||
"""
|
"""
|
||||||
|
# 拼装消息内容
|
||||||
msg_text = ""
|
msg_text = ""
|
||||||
if username:
|
if username:
|
||||||
msg_text = f"用户:{username}"
|
msg_text = f"用户:{username}"
|
||||||
@@ -76,24 +76,28 @@ class DownloadChain(ChainBase):
|
|||||||
msg_text = f"{msg_text}\n促销:{torrent.volume_factor}"
|
msg_text = f"{msg_text}\n促销:{torrent.volume_factor}"
|
||||||
if torrent.hit_and_run:
|
if torrent.hit_and_run:
|
||||||
msg_text = f"{msg_text}\nHit&Run:是"
|
msg_text = f"{msg_text}\nHit&Run:是"
|
||||||
|
if torrent.labels:
|
||||||
|
msg_text = f"{msg_text}\n标签:{' '.join(torrent.labels)}"
|
||||||
if torrent.description:
|
if torrent.description:
|
||||||
html_re = re.compile(r'<[^>]+>', re.S)
|
html_re = re.compile(r'<[^>]+>', re.S)
|
||||||
description = html_re.sub('', torrent.description)
|
description = html_re.sub('', torrent.description)
|
||||||
torrent.description = re.sub(r'<[^>]+>', '', description)
|
torrent.description = re.sub(r'<[^>]+>', '', description)
|
||||||
msg_text = f"{msg_text}\n描述:{torrent.description}"
|
msg_text = f"{msg_text}\n描述:{torrent.description}"
|
||||||
|
|
||||||
|
# 下载成功按规则发送消息
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
mtype=NotificationType.Download,
|
mtype=NotificationType.Download,
|
||||||
userid=userid,
|
|
||||||
title=f"{mediainfo.title_year} "
|
title=f"{mediainfo.title_year} "
|
||||||
f"{'%s %s' % (meta.season, download_episodes) if download_episodes else meta.season_episode} 开始下载",
|
f"{'%s %s' % (meta.season, download_episodes) if download_episodes else meta.season_episode} 开始下载",
|
||||||
text=msg_text,
|
text=msg_text,
|
||||||
image=mediainfo.get_message_image(),
|
image=mediainfo.get_message_image(),
|
||||||
link=settings.MP_DOMAIN('/#/downloading')))
|
link=settings.MP_DOMAIN('/#/downloading'),
|
||||||
|
username=username))
|
||||||
|
|
||||||
def download_torrent(self, torrent: TorrentInfo,
|
def download_torrent(self, torrent: TorrentInfo,
|
||||||
channel: MessageChannel = None,
|
channel: MessageChannel = None,
|
||||||
|
source: str = None,
|
||||||
userid: Union[str, int] = None
|
userid: Union[str, int] = None
|
||||||
) -> Tuple[Optional[Union[Path, str]], str, list]:
|
) -> Tuple[Optional[Union[Path, str]], str, list]:
|
||||||
"""
|
"""
|
||||||
@@ -187,6 +191,7 @@ class DownloadChain(ChainBase):
|
|||||||
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
|
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
mtype=NotificationType.Manual,
|
mtype=NotificationType.Manual,
|
||||||
title=f"{torrent.title} 种子下载失败!",
|
title=f"{torrent.title} 种子下载失败!",
|
||||||
text=f"错误信息:{error_msg}\n站点:{torrent.site_name}",
|
text=f"错误信息:{error_msg}\n站点:{torrent.site_name}",
|
||||||
@@ -198,19 +203,24 @@ class DownloadChain(ChainBase):
|
|||||||
|
|
||||||
def download_single(self, context: Context, torrent_file: Path = None,
|
def download_single(self, context: Context, torrent_file: Path = None,
|
||||||
episodes: Set[int] = None,
|
episodes: Set[int] = None,
|
||||||
channel: MessageChannel = None,
|
channel: MessageChannel = None, source: str = None,
|
||||||
save_path: str = None,
|
save_path: str = None,
|
||||||
userid: Union[str, int] = None,
|
userid: Union[str, int] = None,
|
||||||
username: str = None) -> Optional[str]:
|
username: str = None,
|
||||||
|
downloader: str = None,
|
||||||
|
media_category: str = None) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
下载及发送通知
|
下载及发送通知
|
||||||
:param context: 资源上下文
|
:param context: 资源上下文
|
||||||
:param torrent_file: 种子文件路径
|
:param torrent_file: 种子文件路径
|
||||||
:param episodes: 需要下载的集数
|
:param episodes: 需要下载的集数
|
||||||
:param channel: 通知渠道
|
:param channel: 通知渠道
|
||||||
|
:param source: 通知来源
|
||||||
:param save_path: 保存路径
|
:param save_path: 保存路径
|
||||||
:param userid: 用户ID
|
:param userid: 用户ID
|
||||||
:param username: 调用下载的用户名/插件名
|
:param username: 调用下载的用户名/插件名
|
||||||
|
:param downloader: 下载器
|
||||||
|
:param media_category: 自定义媒体类别
|
||||||
"""
|
"""
|
||||||
_torrent = context.torrent_info
|
_torrent = context.torrent_info
|
||||||
_media = context.media_info
|
_media = context.media_info
|
||||||
@@ -230,6 +240,7 @@ class DownloadChain(ChainBase):
|
|||||||
# 下载种子文件,得到的可能是文件也可能是磁力链
|
# 下载种子文件,得到的可能是文件也可能是磁力链
|
||||||
content, _folder_name, _file_list = self.download_torrent(_torrent,
|
content, _folder_name, _file_list = self.download_torrent(_torrent,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
userid=userid)
|
userid=userid)
|
||||||
if not content:
|
if not content:
|
||||||
return None
|
return None
|
||||||
@@ -241,22 +252,23 @@ class DownloadChain(ChainBase):
|
|||||||
# 下载目录
|
# 下载目录
|
||||||
if save_path:
|
if save_path:
|
||||||
# 有自定义下载目录时,尝试匹配目录配置
|
# 有自定义下载目录时,尝试匹配目录配置
|
||||||
dir_info = self.directoryhelper.get_download_dir(_media, to_path=Path(save_path))
|
dir_info = self.directoryhelper.get_dir(_media, src_path=Path(save_path), local=True)
|
||||||
else:
|
else:
|
||||||
# 根据媒体信息查询下载目录配置
|
# 根据媒体信息查询下载目录配置
|
||||||
dir_info = self.directoryhelper.get_download_dir(_media)
|
dir_info = self.directoryhelper.get_dir(_media, local=True)
|
||||||
|
|
||||||
# 拼装子目录
|
# 拼装子目录
|
||||||
if dir_info:
|
if dir_info:
|
||||||
# 一级目录
|
# 一级目录
|
||||||
if not dir_info.media_type and dir_info.auto_category:
|
if not dir_info.media_type and dir_info.download_type_folder:
|
||||||
# 一级自动分类
|
# 一级自动分类
|
||||||
download_dir = Path(dir_info.path) / _media.type.value
|
download_dir = Path(dir_info.download_path) / _media.type.value
|
||||||
else:
|
else:
|
||||||
# 一级不分类
|
# 一级不分类
|
||||||
download_dir = Path(dir_info.path)
|
download_dir = Path(dir_info.download_path)
|
||||||
|
|
||||||
# 二级目录
|
# 二级目录
|
||||||
if not dir_info.category and dir_info.auto_category and _media and _media.category:
|
if not dir_info.media_category and dir_info.download_category_folder and _media and _media.category:
|
||||||
# 二级自动分类
|
# 二级自动分类
|
||||||
download_dir = download_dir / _media.category
|
download_dir = download_dir / _media.category
|
||||||
elif save_path:
|
elif save_path:
|
||||||
@@ -274,11 +286,12 @@ class DownloadChain(ChainBase):
|
|||||||
cookie=_torrent.site_cookie,
|
cookie=_torrent.site_cookie,
|
||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
download_dir=download_dir,
|
download_dir=download_dir,
|
||||||
category=_media.category)
|
category=_media.category,
|
||||||
|
downloader=downloader)
|
||||||
if result:
|
if result:
|
||||||
_hash, error_msg = result
|
_downloader, _hash, error_msg = result
|
||||||
else:
|
else:
|
||||||
_hash, error_msg = None, "未知错误"
|
_downloader, _hash, error_msg = None, None, "未找到下载器"
|
||||||
|
|
||||||
if _hash:
|
if _hash:
|
||||||
# 下载文件路径
|
# 下载文件路径
|
||||||
@@ -307,7 +320,8 @@ class DownloadChain(ChainBase):
|
|||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
channel=channel.value if channel else None,
|
channel=channel.value if channel else None,
|
||||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||||
|
media_category=media_category
|
||||||
)
|
)
|
||||||
|
|
||||||
# 登记下载文件
|
# 登记下载文件
|
||||||
@@ -325,7 +339,7 @@ class DownloadChain(ChainBase):
|
|||||||
continue
|
continue
|
||||||
files_to_add.append({
|
files_to_add.append({
|
||||||
"download_hash": _hash,
|
"download_hash": _hash,
|
||||||
"downloader": settings.DEFAULT_DOWNLOADER,
|
"downloader": _downloader,
|
||||||
"fullpath": str(download_dir / _folder_name / file),
|
"fullpath": str(download_dir / _folder_name / file),
|
||||||
"savepath": str(download_dir / _folder_name),
|
"savepath": str(download_dir / _folder_name),
|
||||||
"filepath": file,
|
"filepath": file,
|
||||||
@@ -334,7 +348,7 @@ class DownloadChain(ChainBase):
|
|||||||
if files_to_add:
|
if files_to_add:
|
||||||
self.downloadhis.add_files(files_to_add)
|
self.downloadhis.add_files(files_to_add)
|
||||||
|
|
||||||
# 发送消息(群发,不带channel和userid)
|
# 下载成功发送消息
|
||||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
|
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
|
||||||
username=username, download_episodes=download_episodes)
|
username=username, download_episodes=download_episodes)
|
||||||
# 下载成功后处理
|
# 下载成功后处理
|
||||||
@@ -343,7 +357,8 @@ class DownloadChain(ChainBase):
|
|||||||
self.eventmanager.send_event(EventType.DownloadAdded, {
|
self.eventmanager.send_event(EventType.DownloadAdded, {
|
||||||
"hash": _hash,
|
"hash": _hash,
|
||||||
"context": context,
|
"context": context,
|
||||||
"username": username
|
"username": username,
|
||||||
|
"downloader": _downloader
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# 下载失败
|
# 下载失败
|
||||||
@@ -352,6 +367,7 @@ class DownloadChain(ChainBase):
|
|||||||
# 只发送给对应渠道和用户
|
# 只发送给对应渠道和用户
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
mtype=NotificationType.Manual,
|
mtype=NotificationType.Manual,
|
||||||
title="添加下载任务失败:%s %s"
|
title="添加下载任务失败:%s %s"
|
||||||
% (_media.title_year, _meta.season_episode),
|
% (_media.title_year, _meta.season_episode),
|
||||||
@@ -367,8 +383,10 @@ class DownloadChain(ChainBase):
|
|||||||
no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]] = None,
|
no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]] = None,
|
||||||
save_path: str = None,
|
save_path: str = None,
|
||||||
channel: MessageChannel = None,
|
channel: MessageChannel = None,
|
||||||
|
source: str = None,
|
||||||
userid: str = None,
|
userid: str = None,
|
||||||
username: str = None
|
username: str = None,
|
||||||
|
media_category: str = None
|
||||||
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||||
"""
|
"""
|
||||||
根据缺失数据,自动种子列表中组合择优下载
|
根据缺失数据,自动种子列表中组合择优下载
|
||||||
@@ -376,8 +394,10 @@ class DownloadChain(ChainBase):
|
|||||||
:param no_exists: 缺失的剧集信息
|
:param no_exists: 缺失的剧集信息
|
||||||
:param save_path: 保存路径
|
:param save_path: 保存路径
|
||||||
:param channel: 通知渠道
|
:param channel: 通知渠道
|
||||||
|
:param source: 通知来源
|
||||||
:param userid: 用户ID
|
:param userid: 用户ID
|
||||||
:param username: 调用下载的用户名/插件名
|
:param username: 调用下载的用户名/插件名
|
||||||
|
:param media_category: 自定义媒体类别
|
||||||
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}
|
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}
|
||||||
"""
|
"""
|
||||||
# 已下载的项目
|
# 已下载的项目
|
||||||
@@ -443,10 +463,13 @@ class DownloadChain(ChainBase):
|
|||||||
|
|
||||||
# 如果是电影,直接下载
|
# 如果是电影,直接下载
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
if context.media_info.type == MediaType.MOVIE:
|
if context.media_info.type == MediaType.MOVIE:
|
||||||
logger.info(f"开始下载电影 {context.torrent_info.title} ...")
|
logger.info(f"开始下载电影 {context.torrent_info.title} ...")
|
||||||
if self.download_single(context, save_path=save_path, channel=channel,
|
if self.download_single(context, save_path=save_path, channel=channel,
|
||||||
userid=userid, username=username):
|
source=source, userid=userid, username=username,
|
||||||
|
media_category=media_category):
|
||||||
# 下载成功
|
# 下载成功
|
||||||
logger.info(f"{context.torrent_info.title} 添加下载成功")
|
logger.info(f"{context.torrent_info.title} 添加下载成功")
|
||||||
downloaded_list.append(context)
|
downloaded_list.append(context)
|
||||||
@@ -470,6 +493,8 @@ class DownloadChain(ChainBase):
|
|||||||
for need_mid, need_season in need_seasons.items():
|
for need_mid, need_season in need_seasons.items():
|
||||||
# 循环种子
|
# 循环种子
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
# 媒体信息
|
# 媒体信息
|
||||||
media = context.media_info
|
media = context.media_info
|
||||||
# 识别元数据
|
# 识别元数据
|
||||||
@@ -526,15 +551,18 @@ class DownloadChain(ChainBase):
|
|||||||
torrent_file=content if isinstance(content, Path) else None,
|
torrent_file=content if isinstance(content, Path) else None,
|
||||||
save_path=save_path,
|
save_path=save_path,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username
|
username=username,
|
||||||
|
media_category=media_category
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 下载
|
# 下载
|
||||||
logger.info(f"开始下载 {torrent.title} ...")
|
logger.info(f"开始下载 {torrent.title} ...")
|
||||||
download_id = self.download_single(context,
|
download_id = self.download_single(context, save_path=save_path,
|
||||||
save_path=save_path, channel=channel,
|
channel=channel, source=source,
|
||||||
userid=userid, username=username)
|
userid=userid, username=username,
|
||||||
|
media_category=media_category)
|
||||||
|
|
||||||
if download_id:
|
if download_id:
|
||||||
# 下载成功
|
# 下载成功
|
||||||
@@ -574,6 +602,8 @@ class DownloadChain(ChainBase):
|
|||||||
need_episodes = list(range(start_episode, total_episode + 1))
|
need_episodes = list(range(start_episode, total_episode + 1))
|
||||||
# 循环种子
|
# 循环种子
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
# 媒体信息
|
# 媒体信息
|
||||||
media = context.media_info
|
media = context.media_info
|
||||||
# 识别元数据
|
# 识别元数据
|
||||||
@@ -600,9 +630,10 @@ class DownloadChain(ChainBase):
|
|||||||
if torrent_episodes.issubset(set(need_episodes)):
|
if torrent_episodes.issubset(set(need_episodes)):
|
||||||
# 下载
|
# 下载
|
||||||
logger.info(f"开始下载 {meta.title} ...")
|
logger.info(f"开始下载 {meta.title} ...")
|
||||||
download_id = self.download_single(context,
|
download_id = self.download_single(context, save_path=save_path,
|
||||||
save_path=save_path, channel=channel,
|
channel=channel, source=source,
|
||||||
userid=userid, username=username)
|
userid=userid, username=username,
|
||||||
|
media_category=media_category)
|
||||||
if download_id:
|
if download_id:
|
||||||
# 下载成功
|
# 下载成功
|
||||||
logger.info(f"{meta.title} 添加下载成功")
|
logger.info(f"{meta.title} 添加下载成功")
|
||||||
@@ -639,6 +670,8 @@ class DownloadChain(ChainBase):
|
|||||||
continue
|
continue
|
||||||
# 循环种子
|
# 循环种子
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
# 媒体信息
|
# 媒体信息
|
||||||
media = context.media_info
|
media = context.media_info
|
||||||
# 识别元数据
|
# 识别元数据
|
||||||
@@ -686,8 +719,10 @@ class DownloadChain(ChainBase):
|
|||||||
episodes=selected_episodes,
|
episodes=selected_episodes,
|
||||||
save_path=save_path,
|
save_path=save_path,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username
|
username=username,
|
||||||
|
media_category=media_category
|
||||||
)
|
)
|
||||||
if not download_id:
|
if not download_id:
|
||||||
continue
|
continue
|
||||||
@@ -839,7 +874,7 @@ class DownloadChain(ChainBase):
|
|||||||
# 全部存在
|
# 全部存在
|
||||||
return True, no_exists
|
return True, no_exists
|
||||||
|
|
||||||
def remote_downloading(self, channel: MessageChannel, userid: Union[str, int] = None):
|
def remote_downloading(self, channel: MessageChannel, userid: Union[str, int] = None, source: str = None):
|
||||||
"""
|
"""
|
||||||
查询正在下载的任务,并发送消息
|
查询正在下载的任务,并发送消息
|
||||||
"""
|
"""
|
||||||
@@ -847,6 +882,7 @@ class DownloadChain(ChainBase):
|
|||||||
if not torrents:
|
if not torrents:
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
mtype=NotificationType.Download,
|
mtype=NotificationType.Download,
|
||||||
title="没有正在下载的任务!",
|
title="没有正在下载的任务!",
|
||||||
userid=userid,
|
userid=userid,
|
||||||
@@ -864,6 +900,7 @@ class DownloadChain(ChainBase):
|
|||||||
index += 1
|
index += 1
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
mtype=NotificationType.Download,
|
mtype=NotificationType.Download,
|
||||||
title=title,
|
title=title,
|
||||||
text="\n".join(messages),
|
text="\n".join(messages),
|
||||||
@@ -871,11 +908,11 @@ class DownloadChain(ChainBase):
|
|||||||
link=settings.MP_DOMAIN('#/downloading')
|
link=settings.MP_DOMAIN('#/downloading')
|
||||||
))
|
))
|
||||||
|
|
||||||
def downloading(self) -> List[DownloadingTorrent]:
|
def downloading(self, name: str = None) -> List[DownloadingTorrent]:
|
||||||
"""
|
"""
|
||||||
查询正在下载的任务
|
查询正在下载的任务
|
||||||
"""
|
"""
|
||||||
torrents = self.list_torrents(status=TorrentStatus.DOWNLOADING)
|
torrents = self.list_torrents(downloader=name, status=TorrentStatus.DOWNLOADING)
|
||||||
if not torrents:
|
if not torrents:
|
||||||
return []
|
return []
|
||||||
ret_torrents = []
|
ret_torrents = []
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
import copy
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Optional, List, Tuple, Union
|
from typing import Optional, List, Tuple, Union
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
|
from app.chain.storage import StorageChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import Context, MediaInfo
|
from app.core.context import Context, MediaInfo
|
||||||
from app.core.event import eventmanager, Event
|
from app.core.event import eventmanager, Event
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||||
from app.helper.aliyun import AliyunHelper
|
|
||||||
from app.helper.u115 import U115Helper
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas.types import EventType, MediaType
|
from app.schemas.types import EventType, MediaType, ChainEventType
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
from app.utils.system import SystemUtils
|
|
||||||
|
|
||||||
recognize_lock = Lock()
|
recognize_lock = Lock()
|
||||||
|
|
||||||
@@ -27,10 +23,10 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
媒体信息处理链,单例运行
|
媒体信息处理链,单例运行
|
||||||
"""
|
"""
|
||||||
# 临时识别标题
|
|
||||||
recognize_title: Optional[str] = None
|
def __init__(self):
|
||||||
# 临时识别结果 {title, name, year, season, episode}
|
super().__init__()
|
||||||
recognize_temp: Optional[dict] = None
|
self.storagechain = StorageChain()
|
||||||
|
|
||||||
def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
|
def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||||
season: int = None, episode: int = None) -> Optional[str]:
|
season: int = None, episode: int = None) -> Optional[str]:
|
||||||
@@ -52,7 +48,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
mediainfo: MediaInfo = self.recognize_media(meta=metainfo)
|
mediainfo: MediaInfo = self.recognize_media(meta=metainfo)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
# 尝试使用辅助识别,如果有注册响应事件的话
|
# 尝试使用辅助识别,如果有注册响应事件的话
|
||||||
if eventmanager.check(EventType.NameRecognize):
|
if eventmanager.check(ChainEventType.NameRecognize):
|
||||||
logger.info(f'请求辅助识别,标题:{title} ...')
|
logger.info(f'请求辅助识别,标题:{title} ...')
|
||||||
mediainfo = self.recognize_help(title=title, org_meta=metainfo)
|
mediainfo = self.recognize_help(title=title, org_meta=metainfo)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
@@ -71,83 +67,47 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
:param title: 标题
|
:param title: 标题
|
||||||
:param org_meta: 原始元数据
|
:param org_meta: 原始元数据
|
||||||
"""
|
"""
|
||||||
with recognize_lock:
|
# 发送请求事件,等待结果
|
||||||
self.recognize_temp = None
|
result: Event = eventmanager.send_event(
|
||||||
self.recognize_title = title
|
ChainEventType.NameRecognize,
|
||||||
|
|
||||||
# 发送请求事件
|
|
||||||
eventmanager.send_event(
|
|
||||||
EventType.NameRecognize,
|
|
||||||
{
|
{
|
||||||
'title': title,
|
'title': title,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# 每0.5秒循环一次,等待结果,直到10秒后超时
|
if not result:
|
||||||
for i in range(20):
|
return None
|
||||||
if self.recognize_temp is not None:
|
# 获取返回事件数据
|
||||||
break
|
event_data = result.event_data or {}
|
||||||
time.sleep(0.5)
|
logger.info(f'获取到辅助识别结果:{event_data}')
|
||||||
# 加锁
|
# 处理数据格式
|
||||||
with recognize_lock:
|
title, year, season_number, episode_number = None, None, None, None
|
||||||
mediainfo = None
|
if event_data.get("name"):
|
||||||
if not self.recognize_temp or self.recognize_title != title:
|
title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
|
||||||
# 没有识别结果或者识别标题已改变
|
if event_data.get("year"):
|
||||||
return None
|
year = str(event_data["year"]).split("/")[0].strip()
|
||||||
# 有识别结果
|
if event_data.get("season") and str(event_data["season"]).isdigit():
|
||||||
meta_dict = copy.deepcopy(self.recognize_temp)
|
season_number = int(event_data["season"])
|
||||||
logger.info(f'获取到辅助识别结果:{meta_dict}')
|
if event_data.get("episode") and str(event_data["episode"]).isdigit():
|
||||||
if meta_dict.get("name") == org_meta.name and meta_dict.get("year") == org_meta.year:
|
episode_number = int(event_data["episode"])
|
||||||
logger.info(f'辅助识别结果与原始识别结果一致')
|
if not title:
|
||||||
else:
|
return None
|
||||||
logger.info(f'辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...')
|
if title == 'Unknown':
|
||||||
org_meta.name = meta_dict.get("name")
|
return None
|
||||||
org_meta.year = meta_dict.get("year")
|
if not str(year).isdigit():
|
||||||
org_meta.begin_season = meta_dict.get("season")
|
year = None
|
||||||
org_meta.begin_episode = meta_dict.get("episode")
|
# 结果赋值
|
||||||
if org_meta.begin_season or org_meta.begin_episode:
|
if title == org_meta.name and year == org_meta.year:
|
||||||
org_meta.type = MediaType.TV
|
logger.info(f'辅助识别与原始识别结果一致,无需重新识别媒体信息')
|
||||||
# 重新识别
|
return None
|
||||||
mediainfo = self.recognize_media(meta=org_meta)
|
logger.info(f'辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...')
|
||||||
return mediainfo
|
org_meta.name = title
|
||||||
|
org_meta.year = year
|
||||||
@eventmanager.register(EventType.NameRecognizeResult)
|
org_meta.begin_season = season_number
|
||||||
def recognize_result(self, event: Event):
|
org_meta.begin_episode = episode_number
|
||||||
"""
|
if org_meta.begin_season or org_meta.begin_episode:
|
||||||
监控识别结果事件,获取辅助识别结果,结果格式:{title, name, year, season, episode}
|
org_meta.type = MediaType.TV
|
||||||
"""
|
# 重新识别
|
||||||
if not event:
|
return self.recognize_media(meta=org_meta)
|
||||||
return
|
|
||||||
event_data = event.event_data or {}
|
|
||||||
# 加锁
|
|
||||||
with recognize_lock:
|
|
||||||
# 不是原标题的结果不要
|
|
||||||
if event_data.get("title") != self.recognize_title:
|
|
||||||
return
|
|
||||||
# 标志收到返回
|
|
||||||
self.recognize_temp = {}
|
|
||||||
# 处理数据格式
|
|
||||||
file_title, file_year, season_number, episode_number = None, None, None, None
|
|
||||||
if event_data.get("name"):
|
|
||||||
file_title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
|
|
||||||
if event_data.get("year"):
|
|
||||||
file_year = str(event_data["year"]).split("/")[0].strip()
|
|
||||||
if event_data.get("season") and str(event_data["season"]).isdigit():
|
|
||||||
season_number = int(event_data["season"])
|
|
||||||
if event_data.get("episode") and str(event_data["episode"]).isdigit():
|
|
||||||
episode_number = int(event_data["episode"])
|
|
||||||
if not file_title:
|
|
||||||
return
|
|
||||||
if file_title == 'Unknown':
|
|
||||||
return
|
|
||||||
if not str(file_year).isdigit():
|
|
||||||
file_year = None
|
|
||||||
# 结果赋值
|
|
||||||
self.recognize_temp = {
|
|
||||||
"name": file_title,
|
|
||||||
"year": file_year,
|
|
||||||
"season": season_number,
|
|
||||||
"episode": episode_number
|
|
||||||
}
|
|
||||||
|
|
||||||
def recognize_by_path(self, path: str) -> Optional[Context]:
|
def recognize_by_path(self, path: str) -> Optional[Context]:
|
||||||
"""
|
"""
|
||||||
@@ -161,7 +121,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
mediainfo = self.recognize_media(meta=file_meta)
|
mediainfo = self.recognize_media(meta=file_meta)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
# 尝试使用辅助识别,如果有注册响应事件的话
|
# 尝试使用辅助识别,如果有注册响应事件的话
|
||||||
if eventmanager.check(EventType.NameRecognize):
|
if eventmanager.check(ChainEventType.NameRecognize):
|
||||||
logger.info(f'请求辅助识别,标题:{file_path.name} ...')
|
logger.info(f'请求辅助识别,标题:{file_path.name} ...')
|
||||||
mediainfo = self.recognize_help(title=path, org_meta=file_meta)
|
mediainfo = self.recognize_help(title=path, org_meta=file_meta)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
@@ -333,54 +293,59 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def manual_scrape(self, storage: str, fileitem: schemas.FileItem,
|
@eventmanager.register(EventType.MetadataScrape)
|
||||||
meta: MetaBase = None, mediainfo: MediaInfo = None, init_folder: bool = True):
|
def scrape_metadata_event(self, event: Event):
|
||||||
|
"""
|
||||||
|
监控手动刮削事件
|
||||||
|
"""
|
||||||
|
if not event:
|
||||||
|
return
|
||||||
|
event_data = event.event_data or {}
|
||||||
|
fileitem = event_data.get("fileitem")
|
||||||
|
meta = event_data.get("meta")
|
||||||
|
mediainfo = event_data.get("mediainfo")
|
||||||
|
if not fileitem:
|
||||||
|
return
|
||||||
|
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||||
|
|
||||||
|
def scrape_metadata(self, fileitem: schemas.FileItem,
|
||||||
|
meta: MetaBase = None, mediainfo: MediaInfo = None,
|
||||||
|
init_folder: bool = True, parent: schemas.FileItem = None,
|
||||||
|
overwrite: bool = False):
|
||||||
"""
|
"""
|
||||||
手动刮削媒体信息
|
手动刮削媒体信息
|
||||||
|
:param fileitem: 刮削目录或文件
|
||||||
|
:param meta: 元数据
|
||||||
|
:param mediainfo: 媒体信息
|
||||||
|
:param init_folder: 是否刮削根目录
|
||||||
|
:param parent: 上级目录
|
||||||
|
:param overwrite: 是否覆盖已有文件
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __list_files(_storage: str, _fileid: str, _path: str = None, _drive_id: str = None):
|
def __list_files(_fileitem: schemas.FileItem):
|
||||||
"""
|
"""
|
||||||
列出下级文件
|
列出下级文件
|
||||||
"""
|
"""
|
||||||
if _storage == "aliyun":
|
return self.storagechain.list_files(fileitem=_fileitem)
|
||||||
return AliyunHelper().list(drive_id=_drive_id, parent_file_id=_fileid, path=_path)
|
|
||||||
elif _storage == "u115":
|
|
||||||
return U115Helper().list(parent_file_id=_fileid, path=_path)
|
|
||||||
else:
|
|
||||||
items = SystemUtils.list_sub_all(Path(_path))
|
|
||||||
return [schemas.FileItem(
|
|
||||||
type="file" if item.is_file() else "dir",
|
|
||||||
path=str(item),
|
|
||||||
name=item.name,
|
|
||||||
basename=item.stem,
|
|
||||||
extension=item.suffix[1:],
|
|
||||||
size=item.stat().st_size,
|
|
||||||
modify_time=item.stat().st_mtime
|
|
||||||
) for item in items]
|
|
||||||
|
|
||||||
def __save_file(_storage: str, _drive_id: str, _fileid: str, _path: Path, _content: Union[bytes, str]):
|
def __save_file(_fileitem: schemas.FileItem, _path: Path, _content: Union[bytes, str]):
|
||||||
"""
|
"""
|
||||||
保存或上传文件
|
保存或上传文件
|
||||||
|
:param _fileitem: 关联的媒体文件项
|
||||||
|
:param _path: 元数据文件路径
|
||||||
|
:param _content: 文件内容
|
||||||
"""
|
"""
|
||||||
if _storage != "local":
|
if not _fileitem or not _content or not _path:
|
||||||
# 写入到临时目录
|
return
|
||||||
temp_path = settings.TEMP_PATH / _path.name
|
tmp_file = settings.TEMP_PATH / _path.name
|
||||||
temp_path.write_bytes(_content)
|
tmp_file.write_bytes(_content)
|
||||||
# 上传文件
|
logger.info(f"保存文件:【{_fileitem.storage}】{_path}")
|
||||||
logger.info(f"正在上传 {_path.name} ...")
|
_fileitem.path = str(_path.parent)
|
||||||
if _storage == "aliyun":
|
self.storagechain.upload_file(fileitem=_fileitem, path=tmp_file)
|
||||||
AliyunHelper().upload(drive_id=_drive_id, parent_file_id=_fileid, file_path=temp_path)
|
if tmp_file.exists():
|
||||||
elif _storage == "u115":
|
tmp_file.unlink()
|
||||||
U115Helper().upload(parent_file_id=_fileid, file_path=temp_path)
|
|
||||||
logger.info(f"{_path.name} 上传完成")
|
|
||||||
else:
|
|
||||||
# 保存到本地
|
|
||||||
logger.info(f"正在保存 {_path.name} ...")
|
|
||||||
_path.write_bytes(_content)
|
|
||||||
logger.info(f"{_path} 已保存")
|
|
||||||
|
|
||||||
def __save_image(_url: str) -> Optional[bytes]:
|
def __download_image(_url: str) -> Optional[bytes]:
|
||||||
"""
|
"""
|
||||||
下载图片并保存
|
下载图片并保存
|
||||||
"""
|
"""
|
||||||
@@ -393,6 +358,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
logger.info(f"{_url} 图片下载失败,请检查网络连通性!")
|
logger.info(f"{_url} 图片下载失败,请检查网络连通性!")
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"{_url} 图片下载失败:{str(err)}!")
|
logger.error(f"{_url} 图片下载失败:{str(err)}!")
|
||||||
|
return None
|
||||||
|
|
||||||
# 当前文件路径
|
# 当前文件路径
|
||||||
filepath = Path(fileitem.path)
|
filepath = Path(fileitem.path)
|
||||||
@@ -416,17 +382,19 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
if not movie_nfo:
|
if not movie_nfo:
|
||||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||||
return
|
return
|
||||||
# 保存或上传nfo文件
|
# 保存或上传nfo文件到上级目录
|
||||||
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.parent_fileid,
|
nfo_path = filepath.with_suffix(".nfo")
|
||||||
_path=filepath.with_suffix(".nfo"), _content=movie_nfo)
|
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||||
|
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||||
|
return
|
||||||
|
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||||
else:
|
else:
|
||||||
# 电影目录
|
# 电影目录
|
||||||
files = __list_files(_storage=storage, _fileid=fileitem.fileid,
|
files = __list_files(_fileitem=fileitem)
|
||||||
_drive_id=fileitem.drive_id, _path=fileitem.path)
|
|
||||||
for file in files:
|
for file in files:
|
||||||
self.manual_scrape(storage=storage, fileitem=file,
|
self.scrape_metadata(fileitem=file,
|
||||||
meta=meta, mediainfo=mediainfo,
|
meta=meta, mediainfo=mediainfo,
|
||||||
init_folder=False)
|
init_folder=False, parent=fileitem)
|
||||||
# 生成目录内图片文件
|
# 生成目录内图片文件
|
||||||
if init_folder:
|
if init_folder:
|
||||||
# 图片
|
# 图片
|
||||||
@@ -438,11 +406,15 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
and attr_value.startswith("http"):
|
and attr_value.startswith("http"):
|
||||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||||
image_path = filepath / image_name
|
image_path = filepath / image_name
|
||||||
|
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||||
|
path=image_path):
|
||||||
|
logger.info(f"已存在图片文件:{image_path}")
|
||||||
|
continue
|
||||||
# 下载图片
|
# 下载图片
|
||||||
content = __save_image(_url=attr_value)
|
content = __download_image(_url=attr_value)
|
||||||
# 写入nfo到根目录
|
# 写入图片到当前目录
|
||||||
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid,
|
if content:
|
||||||
_path=image_path, _content=content)
|
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||||
else:
|
else:
|
||||||
# 电视剧
|
# 电视剧
|
||||||
if fileitem.type == "file":
|
if fileitem.type == "file":
|
||||||
@@ -461,17 +433,35 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
if not episode_nfo:
|
if not episode_nfo:
|
||||||
logger.warn(f"{filepath.name} nfo生成失败!")
|
logger.warn(f"{filepath.name} nfo生成失败!")
|
||||||
return
|
return
|
||||||
# 保存或上传nfo文件
|
# 保存或上传nfo文件到上级目录
|
||||||
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.parent_fileid,
|
nfo_path = filepath.with_suffix(".nfo")
|
||||||
_path=filepath.with_suffix(".nfo"), _content=episode_nfo)
|
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||||
|
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||||
|
return
|
||||||
|
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
||||||
|
# 获取集的图片
|
||||||
|
image_dict = self.metadata_img(mediainfo=file_mediainfo,
|
||||||
|
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||||
|
if image_dict:
|
||||||
|
for episode, image_url in image_dict.items():
|
||||||
|
image_path = filepath.with_suffix(Path(image_url).suffix)
|
||||||
|
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
|
||||||
|
logger.info(f"已存在图片文件:{image_path}")
|
||||||
|
continue
|
||||||
|
# 下载图片
|
||||||
|
content = __download_image(image_url)
|
||||||
|
# 保存图片文件到当前目录
|
||||||
|
if content:
|
||||||
|
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# 当前为目录,处理目录内的文件
|
# 当前为目录,处理目录内的文件
|
||||||
files = __list_files(_storage=storage, _fileid=fileitem.fileid,
|
files = __list_files(_fileitem=fileitem)
|
||||||
_drive_id=fileitem.drive_id, _path=fileitem.path)
|
|
||||||
for file in files:
|
for file in files:
|
||||||
self.manual_scrape(storage=storage, fileitem=file,
|
self.scrape_metadata(fileitem=file,
|
||||||
meta=meta, mediainfo=mediainfo,
|
meta=meta, mediainfo=mediainfo,
|
||||||
init_folder=True if file.type == "dir" else False)
|
parent=fileitem if file.type == "file" else None,
|
||||||
|
init_folder=True if file.type == "dir" else False)
|
||||||
# 生成目录的nfo和图片
|
# 生成目录的nfo和图片
|
||||||
if init_folder:
|
if init_folder:
|
||||||
# 识别文件夹名称
|
# 识别文件夹名称
|
||||||
@@ -484,18 +474,25 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
return
|
return
|
||||||
# 写入nfo到根目录
|
# 写入nfo到根目录
|
||||||
nfo_path = filepath / "season.nfo"
|
nfo_path = filepath / "season.nfo"
|
||||||
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid,
|
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||||
_path=nfo_path, _content=season_nfo)
|
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||||
|
return
|
||||||
|
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
|
||||||
# TMDB季poster图片
|
# TMDB季poster图片
|
||||||
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
|
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
|
||||||
if image_dict:
|
if image_dict:
|
||||||
for image_name, image_url in image_dict.items():
|
for image_name, image_url in image_dict.items():
|
||||||
image_path = filepath.with_name(image_name)
|
image_path = filepath.with_name(image_name)
|
||||||
|
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||||
|
path=image_path):
|
||||||
|
logger.info(f"已存在图片文件:{image_path}")
|
||||||
|
continue
|
||||||
# 下载图片
|
# 下载图片
|
||||||
content = __save_image(image_url)
|
content = __download_image(image_url)
|
||||||
# 保存图片文件到当前目录
|
# 保存图片文件到当前目录
|
||||||
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid,
|
if content:
|
||||||
_path=image_path, _content=content)
|
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||||
|
# 判断当前目录是不是剧集根目录
|
||||||
if season_meta.name:
|
if season_meta.name:
|
||||||
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
||||||
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||||
@@ -504,17 +501,23 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
return
|
return
|
||||||
# 写入tvshow nfo到根目录
|
# 写入tvshow nfo到根目录
|
||||||
nfo_path = filepath / "tvshow.nfo"
|
nfo_path = filepath / "tvshow.nfo"
|
||||||
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid,
|
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||||
_path=nfo_path, _content=tv_nfo)
|
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||||
|
return
|
||||||
|
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
|
||||||
# 生成目录图片
|
# 生成目录图片
|
||||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||||
if image_dict:
|
if image_dict:
|
||||||
for image_name, image_url in image_dict.items():
|
for image_name, image_url in image_dict.items():
|
||||||
image_path = filepath.parent.with_name(image_name)
|
image_path = filepath / image_name
|
||||||
|
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||||
|
path=image_path):
|
||||||
|
logger.info(f"已存在图片文件:{image_path}")
|
||||||
|
continue
|
||||||
# 下载图片
|
# 下载图片
|
||||||
content = __save_image(image_url)
|
content = __download_image(image_url)
|
||||||
# 保存图片文件到当前目录
|
# 保存图片文件到当前目录
|
||||||
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid,
|
if content:
|
||||||
_path=image_path, _content=content)
|
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||||
|
|
||||||
logger.info(f"{filepath.name} 刮削完成")
|
logger.info(f"{filepath.name} 刮削完成")
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import json
|
|
||||||
import threading
|
import threading
|
||||||
from typing import List, Union, Optional
|
from typing import List, Union, Optional, Generator
|
||||||
|
|
||||||
|
from cachetools import cached, TTLCache
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.config import settings
|
from app.core.config import global_vars
|
||||||
from app.db.mediaserver_oper import MediaServerOper
|
from app.db.mediaserver_oper import MediaServerOper
|
||||||
|
from app.helper.service import ServiceConfigHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
lock = threading.Lock()
|
lock = threading.Lock()
|
||||||
@@ -20,17 +22,53 @@ class MediaServerChain(ChainBase):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.dboper = MediaServerOper()
|
self.dboper = MediaServerOper()
|
||||||
|
|
||||||
def librarys(self, server: str = None, username: str = None) -> List[schemas.MediaServerLibrary]:
|
def librarys(self, server: str, username: str = None, hidden: bool = False) -> List[schemas.MediaServerLibrary]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器所有媒体库
|
获取媒体服务器所有媒体库
|
||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_librarys", server=server, username=username)
|
return self.run_module("mediaserver_librarys", server=server, username=username, hidden=hidden)
|
||||||
|
|
||||||
def items(self, server: str, library_id: Union[str, int]) -> List[schemas.MediaServerItem]:
|
def items(self, server: str, library_id: Union[str, int], start_index: int = 0, limit: Optional[int] = -1) \
|
||||||
|
-> Optional[Generator]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器所有项目
|
获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据
|
||||||
|
|
||||||
|
:param server: 媒体服务器名称
|
||||||
|
:param library_id: 媒体库ID,用于标识要获取的媒体库
|
||||||
|
:param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取
|
||||||
|
:param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1
|
||||||
|
|
||||||
|
:return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 特别注意的是,这里使用yield from返回迭代器,避免同时使用return与yield导致Python生成器解析异常
|
||||||
|
- 如果 `limit` 为 None 或 -1 时,表示一次性获取所有数据,分页处理将不再生效
|
||||||
|
- 在这种情况下,内存消耗可能会较大,特别是在数据量非常大的场景下
|
||||||
|
- 如果未来评估结果显示,不分页场景下的内存消耗远大于分页处理时的网络请求开销,可以考虑在此方法中实现自分页的处理
|
||||||
|
- 即通过 `while` 循环在上层进行分页控制,逐步获取所有数据,避免内存爆炸,当前该逻辑由具体实例来实现不分页的处理
|
||||||
|
- Plex 实际上已默认支持内部分页处理,Jellyfin 与 Emby 获取数据时存在内部过滤场景,如排除合集等,分页数据可能是错误的
|
||||||
|
if limit is not None and limit != -1:
|
||||||
|
yield from self.run_module("mediaserver_items", server=server, library_id=library_id,
|
||||||
|
start_index=start_index, limit=limit)
|
||||||
|
else:
|
||||||
|
# 自分页逻辑,通过循环逐步获取所有数据
|
||||||
|
page_size = 10
|
||||||
|
while True:
|
||||||
|
data_generator = self.run_module("mediaserver_items", server=server, library_id=library_id,
|
||||||
|
start_index=start_index, limit=page_size)
|
||||||
|
if not data_generator:
|
||||||
|
break
|
||||||
|
count = 0
|
||||||
|
for item in data_generator:
|
||||||
|
if item:
|
||||||
|
count += 1
|
||||||
|
yield item
|
||||||
|
if count < page_size:
|
||||||
|
break
|
||||||
|
start_index += page_size
|
||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_items", server=server, library_id=library_id)
|
yield from self.run_module("mediaserver_items", server=server, library_id=library_id,
|
||||||
|
start_index=start_index, limit=limit)
|
||||||
|
|
||||||
def iteminfo(self, server: str, item_id: Union[str, int]) -> schemas.MediaServerItem:
|
def iteminfo(self, server: str, item_id: Union[str, int]) -> schemas.MediaServerItem:
|
||||||
"""
|
"""
|
||||||
@@ -44,18 +82,34 @@ class MediaServerChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
|
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
|
||||||
|
|
||||||
def playing(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
def playing(self, server: str, count: int = 20, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器正在播放信息
|
获取媒体服务器正在播放信息
|
||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_playing", count=count, server=server, username=username)
|
return self.run_module("mediaserver_playing", count=count, server=server, username=username)
|
||||||
|
|
||||||
def latest(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
def latest(self, server: str, count: int = 20, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器最新入库条目
|
获取媒体服务器最新入库条目
|
||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
|
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]:
|
||||||
|
"""
|
||||||
|
获取最新最新入库条目海报作为壁纸,缓存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]:
|
||||||
|
"""
|
||||||
|
获取最新最新入库条目海报作为壁纸,缓存1小时
|
||||||
|
"""
|
||||||
|
wallpapers = self.get_latest_wallpapers(server=server, count=1, remote=remote, username=username)
|
||||||
|
return wallpapers[0] if wallpapers else None
|
||||||
|
|
||||||
def get_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
|
def get_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
获取播放地址
|
获取播放地址
|
||||||
@@ -67,12 +121,9 @@ class MediaServerChain(ChainBase):
|
|||||||
同步媒体库所有数据到本地数据库
|
同步媒体库所有数据到本地数据库
|
||||||
"""
|
"""
|
||||||
# 设置的媒体服务器
|
# 设置的媒体服务器
|
||||||
if not settings.MEDIASERVER:
|
mediaservers = ServiceConfigHelper.get_mediaserver_configs()
|
||||||
|
if not mediaservers:
|
||||||
return
|
return
|
||||||
# 同步黑名单
|
|
||||||
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
|
|
||||||
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
|
|
||||||
mediaservers = settings.MEDIASERVER.split(",")
|
|
||||||
with lock:
|
with lock:
|
||||||
# 汇总统计
|
# 汇总统计
|
||||||
total_count = 0
|
total_count = 0
|
||||||
@@ -82,35 +133,47 @@ class MediaServerChain(ChainBase):
|
|||||||
for mediaserver in mediaservers:
|
for mediaserver in mediaservers:
|
||||||
if not mediaserver:
|
if not mediaserver:
|
||||||
continue
|
continue
|
||||||
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")
|
logger.info(f"正在准备同步媒体服务器 {mediaserver.name} 的数据")
|
||||||
for library in self.librarys(mediaserver):
|
if not mediaserver.enabled:
|
||||||
# 同步黑名单 跳过
|
logger.info(f"媒体服务器 {mediaserver.name} 未启用,跳过")
|
||||||
if library.name in sync_blacklist:
|
continue
|
||||||
|
server_name = mediaserver.name
|
||||||
|
sync_libraries = mediaserver.sync_libraries or []
|
||||||
|
logger.info(f"开始同步媒体服务器 {server_name} 的数据 ...")
|
||||||
|
libraries = self.librarys(server_name)
|
||||||
|
if not libraries:
|
||||||
|
logger.info(f"没有获取到媒体服务器 {server_name} 的媒体库,跳过")
|
||||||
|
continue
|
||||||
|
for library in libraries:
|
||||||
|
if sync_libraries \
|
||||||
|
and "all" not in sync_libraries \
|
||||||
|
and str(library.id) not in sync_libraries:
|
||||||
|
logger.info(f"{library.name} 未在 {server_name} 同步媒体库列表中,跳过")
|
||||||
continue
|
continue
|
||||||
logger.info(f"正在同步 {mediaserver} 媒体库 {library.name} ...")
|
logger.info(f"正在同步 {server_name} 媒体库 {library.name} ...")
|
||||||
library_count = 0
|
library_count = 0
|
||||||
for item in self.items(mediaserver, library.id):
|
for item in self.items(server=server_name, library_id=library.id):
|
||||||
if not item:
|
if global_vars.is_system_stopped:
|
||||||
continue
|
return
|
||||||
if not item.item_id:
|
if not item or not item.item_id:
|
||||||
continue
|
continue
|
||||||
logger.debug(f"正在同步 {item.title} ...")
|
logger.debug(f"正在同步 {item.title} ...")
|
||||||
# 计数
|
# 计数
|
||||||
library_count += 1
|
library_count += 1
|
||||||
seasoninfo = {}
|
seasoninfo = {}
|
||||||
# 类型
|
# 类型
|
||||||
item_type = "电视剧" if item.item_type in ['Series', 'show'] else "电影"
|
item_type = "电视剧" if item.item_type in ["Series", "show"] else "电影"
|
||||||
if item_type == "电视剧":
|
if item_type == "电视剧":
|
||||||
# 查询剧集信息
|
# 查询剧集信息
|
||||||
espisodes_info = self.episodes(mediaserver, item.item_id) or []
|
espisodes_info = self.episodes(server_name, item.item_id) or []
|
||||||
for episode in espisodes_info:
|
for episode in espisodes_info:
|
||||||
seasoninfo[episode.season] = episode.episodes
|
seasoninfo[episode.season] = episode.episodes
|
||||||
# 插入数据
|
# 插入数据
|
||||||
item_dict = item.dict()
|
item_dict = item.dict()
|
||||||
item_dict['seasoninfo'] = json.dumps(seasoninfo)
|
item_dict["seasoninfo"] = seasoninfo
|
||||||
item_dict['item_type'] = item_type
|
item_dict["item_type"] = item_type
|
||||||
self.dboper.add(**item_dict)
|
self.dboper.add(**item_dict)
|
||||||
logger.info(f"{mediaserver} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
|
logger.info(f"{server_name} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
|
||||||
# 总数累加
|
# 总数累加
|
||||||
total_count += library_count
|
total_count += library_count
|
||||||
logger.info("【MediaServer】媒体库数据同步完成,同步数量:%s" % total_count)
|
logger.info(f"媒体服务器 {server_name} 数据同步完成,总同步数量:{total_count}")
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Optional, Dict, Union
|
from typing import Any, Optional, Dict, Union
|
||||||
|
|
||||||
@@ -106,8 +105,10 @@ class MessageChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
调用模块识别消息内容
|
调用模块识别消息内容
|
||||||
"""
|
"""
|
||||||
|
# 消息来源
|
||||||
|
source = args.get("source")
|
||||||
# 获取消息内容
|
# 获取消息内容
|
||||||
info = self.message_parser(body=body, form=form, args=args)
|
info = self.message_parser(source=source, body=body, form=form, args=args)
|
||||||
if not info:
|
if not info:
|
||||||
return
|
return
|
||||||
# 渠道
|
# 渠道
|
||||||
@@ -125,9 +126,10 @@ class MessageChain(ChainBase):
|
|||||||
logger.debug(f'未识别到消息内容::{body}{form}{args}')
|
logger.debug(f'未识别到消息内容::{body}{form}{args}')
|
||||||
return
|
return
|
||||||
# 处理消息
|
# 处理消息
|
||||||
self.handle_message(channel=channel, userid=userid, username=username, text=text)
|
self.handle_message(channel=channel, source=source, userid=userid, username=username, text=text)
|
||||||
|
|
||||||
def handle_message(self, channel: MessageChannel, userid: Union[str, int], username: str, text: str) -> None:
|
def handle_message(self, channel: MessageChannel, source: str,
|
||||||
|
userid: Union[str, int], username: str, text: str) -> None:
|
||||||
"""
|
"""
|
||||||
识别消息内容,执行操作
|
识别消息内容,执行操作
|
||||||
"""
|
"""
|
||||||
@@ -143,10 +145,12 @@ class MessageChain(ChainBase):
|
|||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
text=text
|
text=text
|
||||||
), role="user")
|
), role="user")
|
||||||
self.messageoper.add(
|
self.messageoper.add(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
userid=username or userid,
|
userid=username or userid,
|
||||||
text=text,
|
text=text,
|
||||||
action=0
|
action=0
|
||||||
@@ -159,7 +163,8 @@ class MessageChain(ChainBase):
|
|||||||
{
|
{
|
||||||
"cmd": text,
|
"cmd": text,
|
||||||
"user": userid,
|
"user": userid,
|
||||||
"channel": channel
|
"channel": channel,
|
||||||
|
"source": source
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -172,7 +177,7 @@ class MessageChain(ChainBase):
|
|||||||
or not cache_data.get('items') \
|
or not cache_data.get('items') \
|
||||||
or len(cache_data.get('items')) < int(text):
|
or len(cache_data.get('items')) < int(text):
|
||||||
# 发送消息
|
# 发送消息
|
||||||
self.post_message(Notification(channel=channel, title="输入有误!", userid=userid))
|
self.post_message(Notification(channel=channel, source=source, title="输入有误!", userid=userid))
|
||||||
return
|
return
|
||||||
# 选择的序号
|
# 选择的序号
|
||||||
_choice = int(text) + _current_page * self._page_size - 1
|
_choice = int(text) + _current_page * self._page_size - 1
|
||||||
@@ -192,6 +197,7 @@ class MessageChain(ChainBase):
|
|||||||
# 媒体库中已存在
|
# 媒体库中已存在
|
||||||
self.post_message(
|
self.post_message(
|
||||||
Notification(channel=channel,
|
Notification(channel=channel,
|
||||||
|
source=source,
|
||||||
title=f"【{_current_media.title_year}"
|
title=f"【{_current_media.title_year}"
|
||||||
f"{_current_meta.sea} 媒体库中已存在,如需重新下载请发送:搜索 名称 或 下载 名称】",
|
f"{_current_meta.sea} 媒体库中已存在,如需重新下载请发送:搜索 名称 或 下载 名称】",
|
||||||
userid=userid))
|
userid=userid))
|
||||||
@@ -215,12 +221,14 @@ class MessageChain(ChainBase):
|
|||||||
for sea, no_exist in no_exists.get(mediakey).items()]
|
for sea, no_exist in no_exists.get(mediakey).items()]
|
||||||
if messages:
|
if messages:
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel,
|
||||||
|
source=source,
|
||||||
title=f"{mediainfo.title_year}:\n" + "\n".join(messages),
|
title=f"{mediainfo.title_year}:\n" + "\n".join(messages),
|
||||||
userid=userid))
|
userid=userid))
|
||||||
# 搜索种子,过滤掉不需要的剧集,以便选择
|
# 搜索种子,过滤掉不需要的剧集,以便选择
|
||||||
logger.info(f"开始搜索 {mediainfo.title_year} ...")
|
logger.info(f"开始搜索 {mediainfo.title_year} ...")
|
||||||
self.post_message(
|
self.post_message(
|
||||||
Notification(channel=channel,
|
Notification(channel=channel,
|
||||||
|
source=source,
|
||||||
title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...",
|
title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...",
|
||||||
userid=userid))
|
userid=userid))
|
||||||
# 开始搜索
|
# 开始搜索
|
||||||
@@ -229,8 +237,10 @@ class MessageChain(ChainBase):
|
|||||||
if not contexts:
|
if not contexts:
|
||||||
# 没有数据
|
# 没有数据
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel, title=f"{mediainfo.title}"
|
channel=channel,
|
||||||
f"{_current_meta.sea} 未搜索到需要的资源!",
|
source=source,
|
||||||
|
title=f"{mediainfo.title}"
|
||||||
|
f"{_current_meta.sea} 未搜索到需要的资源!",
|
||||||
userid=userid))
|
userid=userid))
|
||||||
return
|
return
|
||||||
# 搜索结果排序
|
# 搜索结果排序
|
||||||
@@ -244,6 +254,7 @@ class MessageChain(ChainBase):
|
|||||||
logger.info(f"用户 {userid} 在自动下载用户中,开始自动择优下载 ...")
|
logger.info(f"用户 {userid} 在自动下载用户中,开始自动择优下载 ...")
|
||||||
# 自动选择下载
|
# 自动选择下载
|
||||||
self.__auto_download(channel=channel,
|
self.__auto_download(channel=channel,
|
||||||
|
source=source,
|
||||||
cache_list=contexts,
|
cache_list=contexts,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
@@ -257,6 +268,7 @@ class MessageChain(ChainBase):
|
|||||||
# 发送种子数据
|
# 发送种子数据
|
||||||
logger.info(f"搜索到 {len(contexts)} 条数据,开始发送选择消息 ...")
|
logger.info(f"搜索到 {len(contexts)} 条数据,开始发送选择消息 ...")
|
||||||
self.__post_torrents_message(channel=channel,
|
self.__post_torrents_message(channel=channel,
|
||||||
|
source=source,
|
||||||
title=mediainfo.title,
|
title=mediainfo.title,
|
||||||
items=contexts[:self._page_size],
|
items=contexts[:self._page_size],
|
||||||
userid=userid,
|
userid=userid,
|
||||||
@@ -274,6 +286,7 @@ class MessageChain(ChainBase):
|
|||||||
if exist_flag:
|
if exist_flag:
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=f"【{mediainfo.title_year}"
|
title=f"【{mediainfo.title_year}"
|
||||||
f"{_current_meta.sea} 媒体库中已存在,如需洗版请发送:洗版 XXX】",
|
f"{_current_meta.sea} 媒体库中已存在,如需洗版请发送:洗版 XXX】",
|
||||||
userid=userid))
|
userid=userid))
|
||||||
@@ -287,6 +300,7 @@ class MessageChain(ChainBase):
|
|||||||
tmdbid=mediainfo.tmdb_id,
|
tmdbid=mediainfo.tmdb_id,
|
||||||
season=_current_meta.begin_season,
|
season=_current_meta.begin_season,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
best_version=best_version)
|
best_version=best_version)
|
||||||
@@ -294,6 +308,7 @@ class MessageChain(ChainBase):
|
|||||||
if int(text) == 0:
|
if int(text) == 0:
|
||||||
# 自动选择下载,强制下载模式
|
# 自动选择下载,强制下载模式
|
||||||
self.__auto_download(channel=channel,
|
self.__auto_download(channel=channel,
|
||||||
|
source=source,
|
||||||
cache_list=cache_list,
|
cache_list=cache_list,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username)
|
username=username)
|
||||||
@@ -301,7 +316,7 @@ class MessageChain(ChainBase):
|
|||||||
# 下载种子
|
# 下载种子
|
||||||
context: Context = cache_list[_choice]
|
context: Context = cache_list[_choice]
|
||||||
# 下载
|
# 下载
|
||||||
self.downloadchain.download_single(context, channel=channel,
|
self.downloadchain.download_single(context, channel=channel, source=source,
|
||||||
userid=userid, username=username)
|
userid=userid, username=username)
|
||||||
|
|
||||||
elif text.lower() == "p":
|
elif text.lower() == "p":
|
||||||
@@ -310,13 +325,13 @@ class MessageChain(ChainBase):
|
|||||||
if not cache_data:
|
if not cache_data:
|
||||||
# 没有缓存
|
# 没有缓存
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel, title="输入有误!", userid=userid))
|
channel=channel, source=source, title="输入有误!", userid=userid))
|
||||||
return
|
return
|
||||||
|
|
||||||
if _current_page == 0:
|
if _current_page == 0:
|
||||||
# 第一页
|
# 第一页
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel, title="已经是第一页了!", userid=userid))
|
channel=channel, source=source, title="已经是第一页了!", userid=userid))
|
||||||
return
|
return
|
||||||
# 减一页
|
# 减一页
|
||||||
_current_page -= 1
|
_current_page -= 1
|
||||||
@@ -332,6 +347,7 @@ class MessageChain(ChainBase):
|
|||||||
if cache_type == "Torrent":
|
if cache_type == "Torrent":
|
||||||
# 发送种子数据
|
# 发送种子数据
|
||||||
self.__post_torrents_message(channel=channel,
|
self.__post_torrents_message(channel=channel,
|
||||||
|
source=source,
|
||||||
title=_current_media.title,
|
title=_current_media.title,
|
||||||
items=cache_list[start:end],
|
items=cache_list[start:end],
|
||||||
userid=userid,
|
userid=userid,
|
||||||
@@ -339,6 +355,7 @@ class MessageChain(ChainBase):
|
|||||||
else:
|
else:
|
||||||
# 发送媒体数据
|
# 发送媒体数据
|
||||||
self.__post_medias_message(channel=channel,
|
self.__post_medias_message(channel=channel,
|
||||||
|
source=source,
|
||||||
title=_current_meta.name,
|
title=_current_meta.name,
|
||||||
items=cache_list[start:end],
|
items=cache_list[start:end],
|
||||||
userid=userid,
|
userid=userid,
|
||||||
@@ -350,7 +367,7 @@ class MessageChain(ChainBase):
|
|||||||
if not cache_data:
|
if not cache_data:
|
||||||
# 没有缓存
|
# 没有缓存
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel, title="输入有误!", userid=userid))
|
channel=channel, source=source, title="输入有误!", userid=userid))
|
||||||
return
|
return
|
||||||
cache_type: str = cache_data.get('type')
|
cache_type: str = cache_data.get('type')
|
||||||
# 产生副本,避免修改原值
|
# 产生副本,避免修改原值
|
||||||
@@ -362,7 +379,7 @@ class MessageChain(ChainBase):
|
|||||||
if not cache_list:
|
if not cache_list:
|
||||||
# 没有数据
|
# 没有数据
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel, title="已经是最后一页了!", userid=userid))
|
channel=channel, source=source, title="已经是最后一页了!", userid=userid))
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
# 加一页
|
# 加一页
|
||||||
@@ -370,11 +387,13 @@ class MessageChain(ChainBase):
|
|||||||
if cache_type == "Torrent":
|
if cache_type == "Torrent":
|
||||||
# 发送种子数据
|
# 发送种子数据
|
||||||
self.__post_torrents_message(channel=channel,
|
self.__post_torrents_message(channel=channel,
|
||||||
|
source=source,
|
||||||
title=_current_media.title,
|
title=_current_media.title,
|
||||||
items=cache_list, userid=userid, total=total)
|
items=cache_list, userid=userid, total=total)
|
||||||
else:
|
else:
|
||||||
# 发送媒体数据
|
# 发送媒体数据
|
||||||
self.__post_medias_message(channel=channel,
|
self.__post_medias_message(channel=channel,
|
||||||
|
source=source,
|
||||||
title=_current_meta.name,
|
title=_current_meta.name,
|
||||||
items=cache_list, userid=userid, total=total)
|
items=cache_list, userid=userid, total=total)
|
||||||
|
|
||||||
@@ -411,12 +430,12 @@ class MessageChain(ChainBase):
|
|||||||
# 识别
|
# 识别
|
||||||
if not meta.name:
|
if not meta.name:
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel, title="无法识别输入内容!", userid=userid))
|
channel=channel, source=source, title="无法识别输入内容!", userid=userid))
|
||||||
return
|
return
|
||||||
# 开始搜索
|
# 开始搜索
|
||||||
if not medias:
|
if not medias:
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel, title=f"{meta.name} 没有找到对应的媒体信息!", userid=userid))
|
channel=channel, source=source, title=f"{meta.name} 没有找到对应的媒体信息!", userid=userid))
|
||||||
return
|
return
|
||||||
logger.info(f"搜索到 {len(medias)} 条相关媒体信息")
|
logger.info(f"搜索到 {len(medias)} 条相关媒体信息")
|
||||||
# 记录当前状态
|
# 记录当前状态
|
||||||
@@ -429,6 +448,7 @@ class MessageChain(ChainBase):
|
|||||||
_current_media = None
|
_current_media = None
|
||||||
# 发送媒体列表
|
# 发送媒体列表
|
||||||
self.__post_medias_message(channel=channel,
|
self.__post_medias_message(channel=channel,
|
||||||
|
source=source,
|
||||||
title=meta.name,
|
title=meta.name,
|
||||||
items=medias[:self._page_size],
|
items=medias[:self._page_size],
|
||||||
userid=userid, total=len(medias))
|
userid=userid, total=len(medias))
|
||||||
@@ -439,14 +459,15 @@ class MessageChain(ChainBase):
|
|||||||
{
|
{
|
||||||
"text": content,
|
"text": content,
|
||||||
"userid": userid,
|
"userid": userid,
|
||||||
"channel": channel
|
"channel": channel,
|
||||||
|
"source": source
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 保存缓存
|
# 保存缓存
|
||||||
self.save_cache(user_cache, self._cache_file)
|
self.save_cache(user_cache, self._cache_file)
|
||||||
|
|
||||||
def __auto_download(self, channel: MessageChannel, cache_list: list[Context],
|
def __auto_download(self, channel: MessageChannel, source: str, cache_list: list[Context],
|
||||||
userid: Union[str, int], username: str,
|
userid: Union[str, int], username: str,
|
||||||
no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None):
|
no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None):
|
||||||
"""
|
"""
|
||||||
@@ -466,6 +487,7 @@ class MessageChain(ChainBase):
|
|||||||
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
|
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
|
||||||
no_exists=no_exists,
|
no_exists=no_exists,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username)
|
username=username)
|
||||||
if downloads and not lefts:
|
if downloads and not lefts:
|
||||||
@@ -478,7 +500,7 @@ class MessageChain(ChainBase):
|
|||||||
# 获取已下载剧集
|
# 获取已下载剧集
|
||||||
downloaded = [download.meta_info.begin_episode for download in downloads
|
downloaded = [download.meta_info.begin_episode for download in downloads
|
||||||
if download.meta_info.begin_episode]
|
if download.meta_info.begin_episode]
|
||||||
note = json.dumps(downloaded)
|
note = downloaded
|
||||||
else:
|
else:
|
||||||
note = None
|
note = None
|
||||||
# 添加订阅,状态为R
|
# 添加订阅,状态为R
|
||||||
@@ -488,12 +510,13 @@ class MessageChain(ChainBase):
|
|||||||
tmdbid=_current_media.tmdb_id,
|
tmdbid=_current_media.tmdb_id,
|
||||||
season=_current_meta.begin_season,
|
season=_current_meta.begin_season,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
state="R",
|
state="R",
|
||||||
note=note)
|
note=note)
|
||||||
|
|
||||||
def __post_medias_message(self, channel: MessageChannel,
|
def __post_medias_message(self, channel: MessageChannel, source: str,
|
||||||
title: str, items: list, userid: str, total: int):
|
title: str, items: list, userid: str, total: int):
|
||||||
"""
|
"""
|
||||||
发送媒体列表消息
|
发送媒体列表消息
|
||||||
@@ -504,11 +527,13 @@ class MessageChain(ChainBase):
|
|||||||
title = f"【{title}】共找到{total}条相关信息,请回复对应数字选择"
|
title = f"【{title}】共找到{total}条相关信息,请回复对应数字选择"
|
||||||
self.post_medias_message(Notification(
|
self.post_medias_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=title,
|
title=title,
|
||||||
userid=userid
|
userid=userid
|
||||||
), medias=items)
|
), medias=items)
|
||||||
|
|
||||||
def __post_torrents_message(self, channel: MessageChannel, title: str, items: list,
|
def __post_torrents_message(self, channel: MessageChannel, source: str,
|
||||||
|
title: str, items: list,
|
||||||
userid: str, total: int):
|
userid: str, total: int):
|
||||||
"""
|
"""
|
||||||
发送种子列表消息
|
发送种子列表消息
|
||||||
@@ -519,6 +544,7 @@ class MessageChain(ChainBase):
|
|||||||
title = f"【{title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择)"
|
title = f"【{title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择)"
|
||||||
self.post_torrents_message(Notification(
|
self.post_torrents_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=title,
|
title=title,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
link=settings.MP_DOMAIN('#/resource')
|
link=settings.MP_DOMAIN('#/resource')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Dict
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
|
from app.core.config import global_vars
|
||||||
from app.core.context import Context
|
from app.core.context import Context
|
||||||
from app.core.context import MediaInfo, TorrentInfo
|
from app.core.context import MediaInfo, TorrentInfo
|
||||||
from app.core.event import eventmanager, Event
|
from app.core.event import eventmanager, Event
|
||||||
@@ -24,6 +25,8 @@ class SearchChain(ChainBase):
|
|||||||
站点资源搜索处理链
|
站点资源搜索处理链
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
__result_temp_file = "__search_result__"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.siteshelper = SitesHelper()
|
self.siteshelper = SitesHelper()
|
||||||
@@ -53,9 +56,9 @@ class SearchChain(ChainBase):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
results = self.process(mediainfo=mediainfo, area=area, no_exists=no_exists)
|
results = self.process(mediainfo=mediainfo, area=area, no_exists=no_exists)
|
||||||
# 保存结果
|
# 保存到本地文件
|
||||||
bytes_results = pickle.dumps(results)
|
bytes_results = pickle.dumps(results)
|
||||||
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
|
self.save_cache(bytes_results, self.__result_temp_file)
|
||||||
return results
|
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: int = 0, site: int = None) -> List[Context]:
|
||||||
@@ -77,20 +80,21 @@ class SearchChain(ChainBase):
|
|||||||
# 组装上下文
|
# 组装上下文
|
||||||
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||||
torrent_info=torrent) for torrent in torrents]
|
torrent_info=torrent) for torrent in torrents]
|
||||||
# 保存结果
|
# 保存到本地文件
|
||||||
bytes_results = pickle.dumps(contexts)
|
bytes_results = pickle.dumps(contexts)
|
||||||
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
|
self.save_cache(bytes_results, self.__result_temp_file)
|
||||||
return contexts
|
return contexts
|
||||||
|
|
||||||
def last_search_results(self) -> List[Context]:
|
def last_search_results(self) -> List[Context]:
|
||||||
"""
|
"""
|
||||||
获取上次搜索结果
|
获取上次搜索结果
|
||||||
"""
|
"""
|
||||||
results = self.systemconfig.get(SystemConfigKey.SearchResults)
|
# 读取本地文件缓存
|
||||||
if not results:
|
content = self.load_cache(self.__result_temp_file)
|
||||||
|
if not content:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
return pickle.loads(results)
|
return pickle.loads(content)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
|
logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
|
||||||
return []
|
return []
|
||||||
@@ -99,25 +103,27 @@ class SearchChain(ChainBase):
|
|||||||
keyword: str = None,
|
keyword: str = None,
|
||||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||||
sites: List[int] = None,
|
sites: List[int] = None,
|
||||||
priority_rule: str = None,
|
rule_groups: List[str] = None,
|
||||||
filter_rule: Dict[str, str] = None,
|
area: str = "title",
|
||||||
area: str = "title") -> List[Context]:
|
custom_words: List[str] = None,
|
||||||
|
filter_params: Dict[str, str] = None) -> List[Context]:
|
||||||
"""
|
"""
|
||||||
根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
||||||
:param mediainfo: 媒体信息
|
:param mediainfo: 媒体信息
|
||||||
:param keyword: 搜索关键词
|
:param keyword: 搜索关键词
|
||||||
:param no_exists: 缺失的媒体信息
|
:param no_exists: 缺失的媒体信息
|
||||||
:param sites: 站点ID列表,为空时搜索所有站点
|
:param sites: 站点ID列表,为空时搜索所有站点
|
||||||
:param priority_rule: 优先级规则,为空时使用搜索优先级规则
|
:param rule_groups: 过滤规则组名称列表
|
||||||
:param filter_rule: 过滤规则,为空是使用默认过滤规则
|
|
||||||
:param area: 搜索范围,title or imdbid
|
:param area: 搜索范围,title or imdbid
|
||||||
|
:param custom_words: 自定义识别词列表
|
||||||
|
:param filter_params: 过滤参数
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
|
def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
|
||||||
"""
|
"""
|
||||||
执行优先级过滤
|
执行优先级过滤
|
||||||
"""
|
"""
|
||||||
return self.filter_torrents(rule_string=priority_rule,
|
return self.filter_torrents(rule_groups=rule_groups,
|
||||||
torrent_list=torrent_list,
|
torrent_list=torrent_list,
|
||||||
season_episodes=season_episodes,
|
season_episodes=season_episodes,
|
||||||
mediainfo=mediainfo) or []
|
mediainfo=mediainfo) or []
|
||||||
@@ -158,6 +164,8 @@ class SearchChain(ChainBase):
|
|||||||
keywords = list(dict.fromkeys([k for k in [mediainfo.title,
|
keywords = list(dict.fromkeys([k for k in [mediainfo.title,
|
||||||
mediainfo.original_title,
|
mediainfo.original_title,
|
||||||
mediainfo.en_title,
|
mediainfo.en_title,
|
||||||
|
mediainfo.hk_title,
|
||||||
|
mediainfo.tw_title,
|
||||||
mediainfo.sg_title] if k]))
|
mediainfo.sg_title] if k]))
|
||||||
|
|
||||||
# 执行搜索
|
# 执行搜索
|
||||||
@@ -174,40 +182,69 @@ class SearchChain(ChainBase):
|
|||||||
# 开始新进度
|
# 开始新进度
|
||||||
self.progress.start(ProgressKey.Search)
|
self.progress.start(ProgressKey.Search)
|
||||||
|
|
||||||
|
# 开始过滤
|
||||||
|
self.progress.update(value=0, text=f'开始过滤,总 {len(torrents)} 个资源,请稍候...',
|
||||||
|
key=ProgressKey.Search)
|
||||||
|
|
||||||
|
# 开始过滤规则过滤
|
||||||
|
if rule_groups is None:
|
||||||
|
# 取搜索过滤规则
|
||||||
|
rule_groups: List[str] = self.systemconfig.get(SystemConfigKey.SearchFilterRuleGroups)
|
||||||
|
if rule_groups:
|
||||||
|
logger.info(f'开始过滤规则/剧集过滤,使用规则组:{rule_groups} ...')
|
||||||
|
torrents = __do_filter(torrents)
|
||||||
|
if not torrents:
|
||||||
|
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
||||||
|
return []
|
||||||
|
logger.info(f"过滤规则/剧集过滤完成,剩余 {len(torrents)} 个资源")
|
||||||
|
|
||||||
|
# 过滤完成
|
||||||
|
self.progress.update(value=50, text=f'过滤完成,剩余 {len(torrents)} 个资源', key=ProgressKey.Search)
|
||||||
|
|
||||||
# 开始匹配
|
# 开始匹配
|
||||||
_match_torrents = []
|
_match_torrents = []
|
||||||
# 总数
|
# 总数
|
||||||
_total = len(torrents)
|
_total = len(torrents)
|
||||||
# 已处理数
|
# 已处理数
|
||||||
_count = 0
|
_count = 0
|
||||||
|
|
||||||
if mediainfo:
|
if mediainfo:
|
||||||
# 英文标题应该在别名/原标题中,不需要再匹配
|
# 英文标题应该在别名/原标题中,不需要再匹配
|
||||||
logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
|
logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
|
||||||
self.progress.update(value=0, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
|
self.progress.update(value=51, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
|
||||||
for torrent in torrents:
|
for torrent in torrents:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
_count += 1
|
_count += 1
|
||||||
self.progress.update(value=(_count / _total) * 96,
|
self.progress.update(value=(_count / _total) * 96,
|
||||||
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
|
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
|
||||||
key=ProgressKey.Search)
|
key=ProgressKey.Search)
|
||||||
if not torrent.title:
|
if not torrent.title:
|
||||||
continue
|
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}")
|
||||||
# 比对IMDBID
|
# 比对IMDBID
|
||||||
if torrent.imdbid \
|
if torrent.imdbid \
|
||||||
and mediainfo.imdb_id \
|
and mediainfo.imdb_id \
|
||||||
and torrent.imdbid == mediainfo.imdb_id:
|
and torrent.imdbid == mediainfo.imdb_id:
|
||||||
logger.info(f'{mediainfo.title} 通过IMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
|
logger.info(f'{mediainfo.title} 通过IMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||||
_match_torrents.append(torrent)
|
_match_torrents.append((torrent, torrent_meta))
|
||||||
continue
|
continue
|
||||||
# 识别
|
|
||||||
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
# 匹配订阅附加参数
|
||||||
if torrent.title != torrent_meta.org_string:
|
if filter_params and not self.torrenthelper.filter_torrent(torrent_info=torrent,
|
||||||
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}")
|
filter_params=filter_params):
|
||||||
|
continue
|
||||||
|
|
||||||
# 比对种子
|
# 比对种子
|
||||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||||
torrent_meta=torrent_meta,
|
torrent_meta=torrent_meta,
|
||||||
torrent=torrent):
|
torrent=torrent):
|
||||||
# 匹配成功
|
# 匹配成功
|
||||||
_match_torrents.append(torrent)
|
_match_torrents.append((torrent, torrent_meta))
|
||||||
continue
|
continue
|
||||||
# 匹配完成
|
# 匹配完成
|
||||||
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
||||||
@@ -215,44 +252,15 @@ class SearchChain(ChainBase):
|
|||||||
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
|
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
|
||||||
key=ProgressKey.Search)
|
key=ProgressKey.Search)
|
||||||
else:
|
else:
|
||||||
_match_torrents = torrents
|
_match_torrents = [(t, MetaInfo(title=t.title, subtitle=t.description)) for t in torrents]
|
||||||
|
|
||||||
# 开始过滤
|
|
||||||
self.progress.update(value=98, text=f'开始过滤,总 {len(_match_torrents)} 个资源,请稍候...',
|
|
||||||
key=ProgressKey.Search)
|
|
||||||
|
|
||||||
# 开始过滤规则过滤
|
|
||||||
if _match_torrents:
|
|
||||||
logger.info(f'开始过滤规则过滤,当前规则:{filter_rule} ...')
|
|
||||||
_match_torrents = self.filter_torrents_by_rule(torrents=_match_torrents,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
filter_rule=filter_rule)
|
|
||||||
if not _match_torrents:
|
|
||||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
|
||||||
return []
|
|
||||||
logger.info(f"过滤规则过滤完成,剩余 {len(_match_torrents)} 个资源")
|
|
||||||
|
|
||||||
# 开始优先级规则/剧集过滤
|
|
||||||
if priority_rule is None:
|
|
||||||
# 取搜索优先级规则
|
|
||||||
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
|
|
||||||
if priority_rule:
|
|
||||||
logger.info(f'开始优先级规则/剧集过滤,当前规则:{priority_rule} ...')
|
|
||||||
_match_torrents = __do_filter(_match_torrents)
|
|
||||||
if not _match_torrents:
|
|
||||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
|
||||||
return []
|
|
||||||
logger.info(f"优先级规则/剧集过滤完成,剩余 {len(_match_torrents)} 个资源")
|
|
||||||
|
|
||||||
# 去掉mediainfo中多余的数据
|
# 去掉mediainfo中多余的数据
|
||||||
mediainfo.clear()
|
mediainfo.clear()
|
||||||
|
|
||||||
# 组装上下文
|
# 组装上下文
|
||||||
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
contexts = [Context(torrent_info=t[0],
|
||||||
media_info=mediainfo,
|
media_info=mediainfo,
|
||||||
torrent_info=torrent) for torrent in _match_torrents]
|
meta_info=t[1]) for t in _match_torrents]
|
||||||
|
|
||||||
self.progress.update(value=99, text=f'过滤完成,剩余 {len(contexts)} 个资源', key=ProgressKey.Search)
|
|
||||||
|
|
||||||
# 排序
|
# 排序
|
||||||
self.progress.update(value=99,
|
self.progress.update(value=99,
|
||||||
@@ -261,10 +269,10 @@ class SearchChain(ChainBase):
|
|||||||
contexts = self.torrenthelper.sort_torrents(contexts)
|
contexts = self.torrenthelper.sort_torrents(contexts)
|
||||||
|
|
||||||
# 结束进度
|
# 结束进度
|
||||||
|
logger.info(f'搜索完成,共 {len(contexts)} 个资源')
|
||||||
self.progress.update(value=100,
|
self.progress.update(value=100,
|
||||||
text=f'搜索完成,共 {len(contexts)} 个资源',
|
text=f'搜索完成,共 {len(contexts)} 个资源',
|
||||||
key=ProgressKey.Search)
|
key=ProgressKey.Search)
|
||||||
logger.info(f'搜索完成,共 {len(contexts)} 个资源')
|
|
||||||
self.progress.end(ProgressKey.Search)
|
self.progress.end(ProgressKey.Search)
|
||||||
|
|
||||||
# 返回
|
# 返回
|
||||||
@@ -336,6 +344,8 @@ class SearchChain(ChainBase):
|
|||||||
# 结果集
|
# 结果集
|
||||||
results = []
|
results = []
|
||||||
for future in as_completed(all_task):
|
for future in as_completed(all_task):
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
finish_count += 1
|
finish_count += 1
|
||||||
result = future.result()
|
result = future.result()
|
||||||
if result:
|
if result:
|
||||||
@@ -356,34 +366,6 @@ class SearchChain(ChainBase):
|
|||||||
# 返回
|
# 返回
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def filter_torrents_by_rule(self,
|
|
||||||
torrents: List[TorrentInfo],
|
|
||||||
mediainfo: MediaInfo,
|
|
||||||
filter_rule: Dict[str, str] = None,
|
|
||||||
) -> List[TorrentInfo]:
|
|
||||||
"""
|
|
||||||
使用过滤规则过滤种子
|
|
||||||
:param torrents: 种子列表
|
|
||||||
:param filter_rule: 过滤规则
|
|
||||||
:param mediainfo: 媒体信息
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not filter_rule:
|
|
||||||
# 没有则取搜索默认过滤规则
|
|
||||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules)
|
|
||||||
if not filter_rule:
|
|
||||||
return torrents
|
|
||||||
|
|
||||||
# 使用默认过滤规则再次过滤
|
|
||||||
return list(filter(
|
|
||||||
lambda t: self.torrenthelper.filter_torrent(
|
|
||||||
torrent_info=t,
|
|
||||||
filter_rule=filter_rule,
|
|
||||||
mediainfo=mediainfo
|
|
||||||
),
|
|
||||||
torrents
|
|
||||||
))
|
|
||||||
|
|
||||||
@eventmanager.register(EventType.SiteDeleted)
|
@eventmanager.register(EventType.SiteDeleted)
|
||||||
def remove_site(self, event: Event):
|
def remove_site(self, event: Event):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Tuple, Optional
|
from typing import Optional, Tuple, Union
|
||||||
from typing import Union
|
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
from ruamel.yaml import CommentedMap
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.config import settings
|
from app.core.config import global_vars, settings
|
||||||
from app.core.event import eventmanager, Event, EventManager
|
from app.core.event import Event, EventManager, eventmanager
|
||||||
from app.db.models.site import Site
|
from app.db.models.site import Site
|
||||||
from app.db.site_oper import SiteOper
|
from app.db.site_oper import SiteOper
|
||||||
from app.db.siteicon_oper import SiteIconOper
|
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.db.sitestatistic_oper import SiteStatisticOper
|
|
||||||
from app.helper.browser import PlaywrightHelper
|
from app.helper.browser import PlaywrightHelper
|
||||||
from app.helper.cloudflare import under_challenge
|
from app.helper.cloudflare import under_challenge
|
||||||
from app.helper.cookie import CookieHelper
|
from app.helper.cookie import CookieHelper
|
||||||
@@ -23,8 +21,8 @@ from app.helper.message import MessageHelper
|
|||||||
from app.helper.rss import RssHelper
|
from app.helper.rss import RssHelper
|
||||||
from app.helper.sites import SitesHelper
|
from app.helper.sites import SitesHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import MessageChannel, Notification
|
from app.schemas import MessageChannel, Notification, SiteUserData
|
||||||
from app.schemas.types import EventType
|
from app.schemas.types import EventType, NotificationType
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.site import SiteUtils
|
from app.utils.site import SiteUtils
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
@@ -38,14 +36,12 @@ class SiteChain(ChainBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.siteoper = SiteOper()
|
self.siteoper = SiteOper()
|
||||||
self.siteiconoper = SiteIconOper()
|
|
||||||
self.siteshelper = SitesHelper()
|
self.siteshelper = SitesHelper()
|
||||||
self.rsshelper = RssHelper()
|
self.rsshelper = RssHelper()
|
||||||
self.cookiehelper = CookieHelper()
|
self.cookiehelper = CookieHelper()
|
||||||
self.message = MessageHelper()
|
self.message = MessageHelper()
|
||||||
self.cookiecloud = CookieCloudHelper()
|
self.cookiecloud = CookieCloudHelper()
|
||||||
self.systemconfig = SystemConfigOper()
|
self.systemconfig = SystemConfigOper()
|
||||||
self.sitestatistic = SiteStatisticOper()
|
|
||||||
|
|
||||||
# 特殊站点登录验证
|
# 特殊站点登录验证
|
||||||
self.special_site_test = {
|
self.special_site_test = {
|
||||||
@@ -58,6 +54,58 @@ class SiteChain(ChainBase):
|
|||||||
"yemapt.org": self.__yema_test,
|
"yemapt.org": self.__yema_test,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def refresh_userdata(self, site: CommentedMap = None) -> Optional[SiteUserData]:
|
||||||
|
"""
|
||||||
|
刷新站点的用户数据
|
||||||
|
:param site: 站点
|
||||||
|
:return: 用户数据
|
||||||
|
"""
|
||||||
|
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())
|
||||||
|
# 发送事件
|
||||||
|
EventManager().send_event(EventType.SiteRefreshed, {
|
||||||
|
"site_id": site.get("id")
|
||||||
|
})
|
||||||
|
# 发送站点消息
|
||||||
|
if userdata.message_unread:
|
||||||
|
if userdata.message_unread_contents and len(userdata.message_unread_contents) > 0:
|
||||||
|
for head, date, content in userdata.message_unread_contents:
|
||||||
|
msg_title = f"【站点 {site.get('name')} 消息】"
|
||||||
|
msg_text = f"时间:{date}\n标题:{head}\n内容:\n{content}"
|
||||||
|
self.post_message(Notification(
|
||||||
|
mtype=NotificationType.SiteMessage,
|
||||||
|
title=msg_title, text=msg_text, link=site.get("url")
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
self.post_message(Notification(
|
||||||
|
mtype=NotificationType.SiteMessage,
|
||||||
|
title=f"站点 {site.get('name')} 收到 "
|
||||||
|
f"{userdata.message_unread} 条新消息,请登陆查看",
|
||||||
|
link=site.get("url")
|
||||||
|
))
|
||||||
|
return userdata
|
||||||
|
|
||||||
|
def refresh_userdatas(self) -> None:
|
||||||
|
"""
|
||||||
|
刷新所有站点的用户数据
|
||||||
|
"""
|
||||||
|
sites = self.siteshelper.get_indexers()
|
||||||
|
any_site_updated = False
|
||||||
|
for site in sites:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
return
|
||||||
|
if site.get("is_active"):
|
||||||
|
userdata = self.refresh_userdata(site)
|
||||||
|
if userdata:
|
||||||
|
any_site_updated = True
|
||||||
|
if any_site_updated:
|
||||||
|
EventManager().send_event(EventType.SiteRefreshed, {
|
||||||
|
"site_id": "*"
|
||||||
|
})
|
||||||
|
|
||||||
def is_special_site(self, domain: str) -> bool:
|
def is_special_site(self, domain: str) -> bool:
|
||||||
"""
|
"""
|
||||||
判断是否特殊站点
|
判断是否特殊站点
|
||||||
@@ -183,7 +231,7 @@ class SiteChain(ChainBase):
|
|||||||
logger.error(f"获取站点页面失败:{url}")
|
logger.error(f"获取站点页面失败:{url}")
|
||||||
return favicon_url, None
|
return favicon_url, None
|
||||||
html = etree.HTML(html_text)
|
html = etree.HTML(html_text)
|
||||||
if html:
|
if StringUtils.is_valid_html_element(html):
|
||||||
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
|
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
|
||||||
if fav_link:
|
if fav_link:
|
||||||
favicon_url = urljoin(url, fav_link[0])
|
favicon_url = urljoin(url, fav_link[0])
|
||||||
@@ -340,17 +388,17 @@ class SiteChain(ChainBase):
|
|||||||
logger.warn(f"站点 {domain} 索引器不存在!")
|
logger.warn(f"站点 {domain} 索引器不存在!")
|
||||||
return
|
return
|
||||||
# 查询站点图标
|
# 查询站点图标
|
||||||
site_icon = self.siteiconoper.get_by_domain(domain)
|
site_icon = self.siteoper.get_icon_by_domain(domain)
|
||||||
if not site_icon or not site_icon.base64:
|
if not site_icon or not site_icon.base64:
|
||||||
logger.info(f"开始缓存站点 {indexer.get('name')} 图标 ...")
|
logger.info(f"开始缓存站点 {indexer.get('name')} 图标 ...")
|
||||||
icon_url, icon_base64 = self.__parse_favicon(url=indexer.get("domain"),
|
icon_url, icon_base64 = self.__parse_favicon(url=indexer.get("domain"),
|
||||||
cookie=cookie,
|
cookie=cookie,
|
||||||
ua=settings.USER_AGENT)
|
ua=settings.USER_AGENT)
|
||||||
if icon_url:
|
if icon_url:
|
||||||
self.siteiconoper.update_icon(name=indexer.get("name"),
|
self.siteoper.update_icon(name=indexer.get("name"),
|
||||||
domain=domain,
|
domain=domain,
|
||||||
icon_url=icon_url,
|
icon_url=icon_url,
|
||||||
icon_base64=icon_base64)
|
icon_base64=icon_base64)
|
||||||
logger.info(f"缓存站点 {indexer.get('name')} 图标成功")
|
logger.info(f"缓存站点 {indexer.get('name')} 图标成功")
|
||||||
else:
|
else:
|
||||||
logger.warn(f"缓存站点 {indexer.get('name')} 图标失败")
|
logger.warn(f"缓存站点 {indexer.get('name')} 图标失败")
|
||||||
@@ -376,6 +424,26 @@ class SiteChain(ChainBase):
|
|||||||
logger.info(f"清理站点配置:{key}")
|
logger.info(f"清理站点配置:{key}")
|
||||||
self.systemconfig.delete(key)
|
self.systemconfig.delete(key)
|
||||||
|
|
||||||
|
@eventmanager.register(EventType.SiteUpdated)
|
||||||
|
def cache_site_userdata(self, event: Event):
|
||||||
|
"""
|
||||||
|
缓存站点用户数据
|
||||||
|
"""
|
||||||
|
if not event:
|
||||||
|
return
|
||||||
|
event_data = event.event_data or {}
|
||||||
|
# 主域名
|
||||||
|
domain = event_data.get("domain")
|
||||||
|
if not domain:
|
||||||
|
return
|
||||||
|
if str(domain).startswith("http"):
|
||||||
|
domain = StringUtils.get_url_domain(domain)
|
||||||
|
indexer = self.siteshelper.get_indexer(domain)
|
||||||
|
if not indexer:
|
||||||
|
return
|
||||||
|
# 刷新站点用户数据
|
||||||
|
self.refresh_userdata(site=indexer) or {}
|
||||||
|
|
||||||
def test(self, url: str) -> Tuple[bool, str]:
|
def test(self, url: str) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
测试站点是否可用
|
测试站点是否可用
|
||||||
@@ -401,9 +469,9 @@ class SiteChain(ChainBase):
|
|||||||
# 统计
|
# 统计
|
||||||
seconds = (datetime.now() - start_time).seconds
|
seconds = (datetime.now() - start_time).seconds
|
||||||
if state:
|
if state:
|
||||||
self.sitestatistic.success(domain=domain, seconds=seconds)
|
self.siteoper.success(domain=domain, seconds=seconds)
|
||||||
else:
|
else:
|
||||||
self.sitestatistic.fail(domain)
|
self.siteoper.fail(domain)
|
||||||
return state, message
|
return state, message
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"{str(e)}!"
|
return False, f"{str(e)}!"
|
||||||
@@ -454,7 +522,8 @@ class SiteChain(ChainBase):
|
|||||||
return False, f"无法打开网站!"
|
return False, f"无法打开网站!"
|
||||||
return True, "连接成功"
|
return True, "连接成功"
|
||||||
|
|
||||||
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
|
def remote_list(self, channel: MessageChannel,
|
||||||
|
userid: Union[str, int] = None, source: str = None):
|
||||||
"""
|
"""
|
||||||
查询所有站点,发送消息
|
查询所有站点,发送消息
|
||||||
"""
|
"""
|
||||||
@@ -482,10 +551,13 @@ class SiteChain(ChainBase):
|
|||||||
# 发送列表
|
# 发送列表
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=title, text="\n".join(messages), userid=userid,
|
title=title, text="\n".join(messages), userid=userid,
|
||||||
link=settings.MP_DOMAIN('#/site')))
|
link=settings.MP_DOMAIN('#/site'))
|
||||||
|
)
|
||||||
|
|
||||||
def remote_disable(self, arg_str, channel: MessageChannel, userid: Union[str, int] = None):
|
def remote_disable(self, arg_str: str, channel: MessageChannel,
|
||||||
|
userid: Union[str, int] = None, source: str = None):
|
||||||
"""
|
"""
|
||||||
禁用站点
|
禁用站点
|
||||||
"""
|
"""
|
||||||
@@ -507,9 +579,10 @@ class SiteChain(ChainBase):
|
|||||||
"is_active": False
|
"is_active": False
|
||||||
})
|
})
|
||||||
# 重新发送消息
|
# 重新发送消息
|
||||||
self.remote_list(channel, userid)
|
self.remote_list(channel=channel, userid=userid, source=source)
|
||||||
|
|
||||||
def remote_enable(self, arg_str, channel: MessageChannel, userid: Union[str, int] = None):
|
def remote_enable(self, arg_str: str, channel: MessageChannel,
|
||||||
|
userid: Union[str, int] = None, source: str = None):
|
||||||
"""
|
"""
|
||||||
启用站点
|
启用站点
|
||||||
"""
|
"""
|
||||||
@@ -532,7 +605,7 @@ class SiteChain(ChainBase):
|
|||||||
"is_active": True
|
"is_active": True
|
||||||
})
|
})
|
||||||
# 重新发送消息
|
# 重新发送消息
|
||||||
self.remote_list(channel, userid)
|
self.remote_list(channel=channel, userid=userid, source=source)
|
||||||
|
|
||||||
def update_cookie(self, site_info: Site,
|
def update_cookie(self, site_info: Site,
|
||||||
username: str, password: str, two_step_code: str = None) -> Tuple[bool, str]:
|
username: str, password: str, two_step_code: str = None) -> Tuple[bool, str]:
|
||||||
@@ -563,7 +636,8 @@ class SiteChain(ChainBase):
|
|||||||
return True, msg
|
return True, msg
|
||||||
return False, "未知错误"
|
return False, "未知错误"
|
||||||
|
|
||||||
def remote_cookie(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None):
|
def remote_cookie(self, arg_str: str, channel: MessageChannel,
|
||||||
|
userid: Union[str, int] = None, source: str = None):
|
||||||
"""
|
"""
|
||||||
使用用户名密码更新站点Cookie
|
使用用户名密码更新站点Cookie
|
||||||
"""
|
"""
|
||||||
@@ -572,6 +646,7 @@ class SiteChain(ChainBase):
|
|||||||
if not arg_str:
|
if not arg_str:
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=err_title, userid=userid))
|
title=err_title, userid=userid))
|
||||||
return
|
return
|
||||||
arg_str = str(arg_str).strip()
|
arg_str = str(arg_str).strip()
|
||||||
@@ -583,12 +658,14 @@ class SiteChain(ChainBase):
|
|||||||
elif len(args) != 3:
|
elif len(args) != 3:
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=err_title, userid=userid))
|
title=err_title, userid=userid))
|
||||||
return
|
return
|
||||||
site_id = args[0]
|
site_id = args[0]
|
||||||
if not site_id.isdigit():
|
if not site_id.isdigit():
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=err_title, userid=userid))
|
title=err_title, userid=userid))
|
||||||
return
|
return
|
||||||
# 站点ID
|
# 站点ID
|
||||||
@@ -598,10 +675,12 @@ class SiteChain(ChainBase):
|
|||||||
if not site_info:
|
if not site_info:
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=f"站点编号 {site_id} 不存在!", userid=userid))
|
title=f"站点编号 {site_id} 不存在!", userid=userid))
|
||||||
return
|
return
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=f"开始更新【{site_info.name}】Cookie&UA ...", userid=userid))
|
title=f"开始更新【{site_info.name}】Cookie&UA ...", userid=userid))
|
||||||
# 用户名
|
# 用户名
|
||||||
username = args[1]
|
username = args[1]
|
||||||
@@ -616,11 +695,13 @@ class SiteChain(ChainBase):
|
|||||||
logger.error(msg)
|
logger.error(msg)
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=f"【{site_info.name}】 Cookie&UA更新失败!",
|
title=f"【{site_info.name}】 Cookie&UA更新失败!",
|
||||||
text=f"错误原因:{msg}",
|
text=f"错误原因:{msg}",
|
||||||
userid=userid))
|
userid=userid))
|
||||||
else:
|
else:
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
source=source,
|
||||||
title=f"【{site_info.name}】 Cookie&UA更新成功",
|
title=f"【{site_info.name}】 Cookie&UA更新成功",
|
||||||
userid=userid))
|
userid=userid))
|
||||||
|
|||||||
134
app/chain/storage.py
Normal file
134
app/chain/storage.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple, List, Dict
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.chain import ChainBase
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.log import logger
|
||||||
|
from app.schemas import MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class StorageChain(ChainBase):
|
||||||
|
"""
|
||||||
|
存储处理链
|
||||||
|
"""
|
||||||
|
|
||||||
|
def save_config(self, storage: str, conf: dict) -> None:
|
||||||
|
"""
|
||||||
|
保存存储配置
|
||||||
|
"""
|
||||||
|
self.run_module("save_config", storage=storage, conf=conf)
|
||||||
|
|
||||||
|
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
|
||||||
|
"""
|
||||||
|
生成二维码
|
||||||
|
"""
|
||||||
|
return self.run_module("generate_qrcode", storage=storage)
|
||||||
|
|
||||||
|
def check_login(self, storage: str, **kwargs) -> Optional[Tuple[dict, str]]:
|
||||||
|
"""
|
||||||
|
登录确认
|
||||||
|
"""
|
||||||
|
return self.run_module("check_login", storage=storage, **kwargs)
|
||||||
|
|
||||||
|
def list_files(self, fileitem: schemas.FileItem, recursion: bool = False) -> Optional[List[schemas.FileItem]]:
|
||||||
|
"""
|
||||||
|
查询当前目录下所有目录和文件
|
||||||
|
"""
|
||||||
|
return self.run_module("list_files", fileitem=fileitem, recursion=recursion)
|
||||||
|
|
||||||
|
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||||
|
"""
|
||||||
|
创建目录
|
||||||
|
"""
|
||||||
|
return self.run_module("create_folder", fileitem=fileitem, name=name)
|
||||||
|
|
||||||
|
def download_file(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
下载文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 本地保存路径
|
||||||
|
"""
|
||||||
|
return self.run_module("download_file", fileitem=fileitem, path=path)
|
||||||
|
|
||||||
|
def upload_file(self, fileitem: schemas.FileItem, path: Path) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
上传文件
|
||||||
|
:param fileitem: 保存目录项
|
||||||
|
:param path: 本地文件路径
|
||||||
|
"""
|
||||||
|
return self.run_module("upload_file", fileitem=fileitem, path=path)
|
||||||
|
|
||||||
|
def delete_file(self, fileitem: schemas.FileItem) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
删除文件或目录
|
||||||
|
"""
|
||||||
|
return self.run_module("delete_file", fileitem=fileitem)
|
||||||
|
|
||||||
|
def rename_file(self, fileitem: schemas.FileItem, name: str) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
重命名文件或目录
|
||||||
|
"""
|
||||||
|
return self.run_module("rename_file", fileitem=fileitem, name=name)
|
||||||
|
|
||||||
|
def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]:
|
||||||
|
"""
|
||||||
|
根据路径获取文件项
|
||||||
|
"""
|
||||||
|
return self.run_module("get_file_item", storage=storage, path=path)
|
||||||
|
|
||||||
|
def get_parent_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||||
|
"""
|
||||||
|
获取上级目录项
|
||||||
|
"""
|
||||||
|
return self.run_module("get_parent_item", fileitem=fileitem)
|
||||||
|
|
||||||
|
def snapshot_storage(self, storage: str, path: Path) -> Optional[Dict[str, float]]:
|
||||||
|
"""
|
||||||
|
快照存储
|
||||||
|
"""
|
||||||
|
return self.run_module("snapshot_storage", storage=storage, path=path)
|
||||||
|
|
||||||
|
def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]:
|
||||||
|
"""
|
||||||
|
存储使用情况
|
||||||
|
"""
|
||||||
|
return self.run_module("storage_usage", storage=storage)
|
||||||
|
|
||||||
|
def support_transtype(self, storage: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取支持的整理方式
|
||||||
|
"""
|
||||||
|
return self.run_module("support_transtype", storage=storage)
|
||||||
|
|
||||||
|
def delete_media_file(self, fileitem: schemas.FileItem, mtype: MediaType = None) -> bool:
|
||||||
|
"""
|
||||||
|
删除媒体文件,以及不含媒体文件的目录
|
||||||
|
"""
|
||||||
|
state = self.delete_file(fileitem)
|
||||||
|
if not state:
|
||||||
|
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||||
|
return False
|
||||||
|
# 上级目录
|
||||||
|
if mtype and mtype == MediaType.TV:
|
||||||
|
dir_path = Path(fileitem.path).parent.parent
|
||||||
|
dir_item = self.get_file_item(storage=fileitem.storage, path=dir_path)
|
||||||
|
else:
|
||||||
|
dir_item = self.get_parent_item(fileitem)
|
||||||
|
if dir_item:
|
||||||
|
files = self.list_files(dir_item, recursion=True)
|
||||||
|
|
||||||
|
# 是否存在其他媒体文件
|
||||||
|
media_file_exist = False
|
||||||
|
if files:
|
||||||
|
for file in files:
|
||||||
|
if file.extension and f".{file.extension.lower()}" in settings.RMT_MEDIAEXT:
|
||||||
|
media_file_exist = True
|
||||||
|
break
|
||||||
|
# 不存在其他媒体文件,删除空目录
|
||||||
|
if not media_file_exist:
|
||||||
|
# 返回空目录删除状态
|
||||||
|
return self.delete_file(dir_item)
|
||||||
|
|
||||||
|
# 存在媒体文件,返回文件删除状态
|
||||||
|
return state
|
||||||
@@ -1,30 +1,32 @@
|
|||||||
import json
|
import copy
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from json import JSONDecodeError
|
|
||||||
from typing import Dict, List, Optional, Union, Tuple
|
from typing import Dict, List, Optional, Union, Tuple
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
|
from app.chain.tmdb import TmdbChain
|
||||||
from app.chain.torrents import TorrentsChain
|
from app.chain.torrents import TorrentsChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings, global_vars
|
||||||
from app.core.context import TorrentInfo, Context, MediaInfo
|
from app.core.context import TorrentInfo, Context, MediaInfo
|
||||||
from app.core.event import eventmanager, Event, EventManager
|
from app.core.event import eventmanager, Event, EventManager
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
|
from app.core.meta.words import WordsMatcher
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
|
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
from app.db.site_oper import SiteOper
|
from app.db.site_oper import SiteOper
|
||||||
from app.db.subscribe_oper import SubscribeOper
|
from app.db.subscribe_oper import SubscribeOper
|
||||||
from app.db.subscribehistory_oper import SubscribeHistoryOper
|
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.helper.message import MessageHelper
|
from app.helper.message import MessageHelper
|
||||||
from app.helper.subscribe import SubscribeHelper
|
from app.helper.subscribe import SubscribeHelper
|
||||||
from app.helper.torrent import TorrentHelper
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import NotExistMediaInfo, Notification
|
from app.schemas import NotExistMediaInfo, Notification, SubscrbieInfo, SubscribeEpisodeInfo, SubscribeDownloadFileInfo, \
|
||||||
|
SubscribeLibraryFileInfo
|
||||||
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType
|
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType
|
||||||
|
|
||||||
|
|
||||||
@@ -36,12 +38,13 @@ class SubscribeChain(ChainBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.downloadchain = DownloadChain()
|
self.downloadchain = DownloadChain()
|
||||||
|
self.downloadhis = DownloadHistoryOper()
|
||||||
self.searchchain = SearchChain()
|
self.searchchain = SearchChain()
|
||||||
self.subscribeoper = SubscribeOper()
|
self.subscribeoper = SubscribeOper()
|
||||||
self.subscribehistoryoper = SubscribeHistoryOper()
|
|
||||||
self.subscribehelper = SubscribeHelper()
|
self.subscribehelper = SubscribeHelper()
|
||||||
self.torrentschain = TorrentsChain()
|
self.torrentschain = TorrentsChain()
|
||||||
self.mediachain = MediaChain()
|
self.mediachain = MediaChain()
|
||||||
|
self.tmdbchain = TmdbChain()
|
||||||
self.message = MessageHelper()
|
self.message = MessageHelper()
|
||||||
self.systemconfig = SystemConfigOper()
|
self.systemconfig = SystemConfigOper()
|
||||||
self.torrenthelper = TorrentHelper()
|
self.torrenthelper = TorrentHelper()
|
||||||
@@ -54,6 +57,7 @@ class SubscribeChain(ChainBase):
|
|||||||
bangumiid: int = None,
|
bangumiid: int = None,
|
||||||
season: int = None,
|
season: int = None,
|
||||||
channel: MessageChannel = None,
|
channel: MessageChannel = None,
|
||||||
|
source: str = None,
|
||||||
userid: str = None,
|
userid: str = None,
|
||||||
username: str = None,
|
username: str = None,
|
||||||
message: bool = True,
|
message: bool = True,
|
||||||
@@ -162,8 +166,9 @@ class SubscribeChain(ChainBase):
|
|||||||
if not sid:
|
if not sid:
|
||||||
logger.error(f'{mediainfo.title_year} {err_msg}')
|
logger.error(f'{mediainfo.title_year} {err_msg}')
|
||||||
if not exist_ok and message:
|
if not exist_ok and message:
|
||||||
# 发回原用户
|
# 失败发回原用户
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel,
|
||||||
|
source=source,
|
||||||
mtype=NotificationType.Subscribe,
|
mtype=NotificationType.Subscribe,
|
||||||
title=f"{mediainfo.title_year} {metainfo.season} "
|
title=f"{mediainfo.title_year} {metainfo.season} "
|
||||||
f"添加订阅失败!",
|
f"添加订阅失败!",
|
||||||
@@ -177,16 +182,17 @@ class SubscribeChain(ChainBase):
|
|||||||
text = f"评分:{mediainfo.vote_average},来自用户:{username}"
|
text = f"评分:{mediainfo.vote_average},来自用户:{username}"
|
||||||
else:
|
else:
|
||||||
text = f"评分:{mediainfo.vote_average}"
|
text = f"评分:{mediainfo.vote_average}"
|
||||||
# 群发
|
|
||||||
if mediainfo.type == MediaType.TV:
|
if mediainfo.type == MediaType.TV:
|
||||||
link = settings.MP_DOMAIN('#/subscribe-tv?tab=mysub')
|
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||||
else:
|
else:
|
||||||
link = settings.MP_DOMAIN('#/subscribe-movie?tab=mysub')
|
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||||
|
# 订阅成功按规则发送消息
|
||||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||||
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
||||||
text=text,
|
text=text,
|
||||||
image=mediainfo.get_message_image(),
|
image=mediainfo.get_message_image(),
|
||||||
link=link))
|
link=link,
|
||||||
|
username=username))
|
||||||
# 发送事件
|
# 发送事件
|
||||||
EventManager().send_event(EventType.SubscribeAdded, {
|
EventManager().send_event(EventType.SubscribeAdded, {
|
||||||
"subscribe_id": sid,
|
"subscribe_id": sid,
|
||||||
@@ -236,7 +242,10 @@ class SubscribeChain(ChainBase):
|
|||||||
subscribes = self.subscribeoper.list(state)
|
subscribes = self.subscribeoper.list(state)
|
||||||
# 遍历订阅
|
# 遍历订阅
|
||||||
for subscribe in subscribes:
|
for subscribe in subscribes:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||||
|
custom_word_list = subscribe.custom_words.split("\n") if subscribe.custom_words else None
|
||||||
# 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间
|
# 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间
|
||||||
if subscribe.date:
|
if subscribe.date:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
@@ -326,21 +335,21 @@ class SubscribeChain(ChainBase):
|
|||||||
|
|
||||||
# 优先级过滤规则
|
# 优先级过滤规则
|
||||||
if subscribe.best_version:
|
if subscribe.best_version:
|
||||||
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
rule_groups = subscribe.filter_groups \
|
||||||
|
or self.systemconfig.get(SystemConfigKey.BestVersionFilterRuleGroups)
|
||||||
else:
|
else:
|
||||||
priority_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
|
rule_groups = subscribe.filter_groups \
|
||||||
|
or self.systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups)
|
||||||
# 过滤规则
|
|
||||||
filter_rule = self.get_filter_rule(subscribe)
|
|
||||||
|
|
||||||
# 搜索,同时电视剧会过滤掉不需要的剧集
|
# 搜索,同时电视剧会过滤掉不需要的剧集
|
||||||
contexts = self.searchchain.process(mediainfo=mediainfo,
|
contexts = self.searchchain.process(mediainfo=mediainfo,
|
||||||
keyword=subscribe.keyword,
|
keyword=subscribe.keyword,
|
||||||
no_exists=no_exists,
|
no_exists=no_exists,
|
||||||
sites=sites,
|
sites=sites,
|
||||||
priority_rule=priority_rule,
|
rule_groups=rule_groups,
|
||||||
filter_rule=filter_rule,
|
area="imdbid" if subscribe.search_imdbid else "title",
|
||||||
area="imdbid" if subscribe.search_imdbid else "title")
|
custom_words=custom_word_list,
|
||||||
|
filter_params=self.get_params(subscribe))
|
||||||
if not contexts:
|
if not contexts:
|
||||||
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||||
@@ -353,6 +362,11 @@ class SubscribeChain(ChainBase):
|
|||||||
torrent_meta = context.meta_info
|
torrent_meta = context.meta_info
|
||||||
torrent_info = context.torrent_info
|
torrent_info = context.torrent_info
|
||||||
torrent_mediainfo = context.media_info
|
torrent_mediainfo = context.media_info
|
||||||
|
|
||||||
|
# 匹配订阅附加参数
|
||||||
|
if not self.torrenthelper.filter_torrent(torrent_info=torrent_info,
|
||||||
|
filter_params=self.get_params(subscribe)):
|
||||||
|
continue
|
||||||
# 洗版
|
# 洗版
|
||||||
if subscribe.best_version:
|
if subscribe.best_version:
|
||||||
# 洗版时,非整季不要
|
# 洗版时,非整季不要
|
||||||
@@ -379,7 +393,8 @@ class SubscribeChain(ChainBase):
|
|||||||
no_exists=no_exists,
|
no_exists=no_exists,
|
||||||
userid=subscribe.username,
|
userid=subscribe.username,
|
||||||
username=subscribe.username,
|
username=subscribe.username,
|
||||||
save_path=subscribe.save_path
|
save_path=subscribe.save_path,
|
||||||
|
media_category=subscribe.media_category
|
||||||
)
|
)
|
||||||
|
|
||||||
# 判断是否应完成订阅
|
# 判断是否应完成订阅
|
||||||
@@ -476,21 +491,17 @@ class SubscribeChain(ChainBase):
|
|||||||
# 如果订阅未指定站点信息,直接返回默认站点
|
# 如果订阅未指定站点信息,直接返回默认站点
|
||||||
if not subscribe.sites:
|
if not subscribe.sites:
|
||||||
return default_sites
|
return default_sites
|
||||||
try:
|
# 尝试解析订阅中的站点数据
|
||||||
# 尝试解析订阅中的站点数据
|
user_sites = subscribe.sites
|
||||||
user_sites = json.loads(subscribe.sites)
|
# 计算 user_sites 和 default_sites 的交集
|
||||||
# 计算 user_sites 和 default_sites 的交集
|
intersection_sites = [site for site in user_sites if site in default_sites]
|
||||||
intersection_sites = [site for site in user_sites if site in default_sites]
|
# 如果交集与原始订阅不一致,更新数据库
|
||||||
# 如果交集与原始订阅不一致,更新数据库
|
if set(intersection_sites) != set(user_sites):
|
||||||
if set(intersection_sites) != set(user_sites):
|
self.subscribeoper.update(subscribe.id, {
|
||||||
self.subscribeoper.update(subscribe.id, {
|
"sites": intersection_sites
|
||||||
"sites": json.dumps(intersection_sites)
|
})
|
||||||
})
|
# 如果交集为空,返回默认站点
|
||||||
# 如果交集为空,返回默认站点
|
return intersection_sites if intersection_sites else default_sites
|
||||||
return intersection_sites if intersection_sites else default_sites
|
|
||||||
except JSONDecodeError:
|
|
||||||
# 如果 JSON 解析失败,返回默认站点
|
|
||||||
return default_sites
|
|
||||||
|
|
||||||
def get_subscribed_sites(self) -> Optional[List[int]]:
|
def get_subscribed_sites(self) -> Optional[List[int]]:
|
||||||
"""
|
"""
|
||||||
@@ -512,24 +523,6 @@ class SubscribeChain(ChainBase):
|
|||||||
|
|
||||||
return ret_sites
|
return ret_sites
|
||||||
|
|
||||||
def get_filter_rule(self, subscribe: Subscribe):
|
|
||||||
"""
|
|
||||||
获取订阅过滤规则,同时组合默认规则
|
|
||||||
"""
|
|
||||||
# 默认过滤规则
|
|
||||||
default_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
|
||||||
return {
|
|
||||||
"include": subscribe.include or default_rule.get("include"),
|
|
||||||
"exclude": subscribe.exclude or default_rule.get("exclude"),
|
|
||||||
"quality": subscribe.quality or default_rule.get("quality"),
|
|
||||||
"resolution": subscribe.resolution or default_rule.get("resolution"),
|
|
||||||
"effect": subscribe.effect or default_rule.get("effect"),
|
|
||||||
"tv_size": default_rule.get("tv_size"),
|
|
||||||
"movie_size": default_rule.get("movie_size"),
|
|
||||||
"min_seeders": default_rule.get("min_seeders"),
|
|
||||||
"min_seeders_time": default_rule.get("min_seeders_time"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def match(self, torrents: Dict[str, List[Context]]):
|
def match(self, torrents: Dict[str, List[Context]]):
|
||||||
"""
|
"""
|
||||||
从缓存中匹配订阅,并自动下载
|
从缓存中匹配订阅,并自动下载
|
||||||
@@ -537,12 +530,16 @@ class SubscribeChain(ChainBase):
|
|||||||
if not torrents:
|
if not torrents:
|
||||||
logger.warn('没有缓存资源,无法匹配订阅')
|
logger.warn('没有缓存资源,无法匹配订阅')
|
||||||
return
|
return
|
||||||
|
|
||||||
# 记录重新识别过的种子
|
# 记录重新识别过的种子
|
||||||
_recognize_cached = []
|
_recognize_cached = []
|
||||||
|
|
||||||
# 所有订阅
|
# 所有订阅
|
||||||
subscribes = self.subscribeoper.list('R')
|
subscribes = self.subscribeoper.list('R')
|
||||||
# 遍历订阅
|
# 遍历订阅
|
||||||
for subscribe in subscribes:
|
for subscribe in subscribes:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
|
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
|
||||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||||
# 生成元数据
|
# 生成元数据
|
||||||
@@ -557,12 +554,7 @@ class SubscribeChain(ChainBase):
|
|||||||
# 订阅的站点域名列表
|
# 订阅的站点域名列表
|
||||||
domains = []
|
domains = []
|
||||||
if subscribe.sites:
|
if subscribe.sites:
|
||||||
try:
|
domains = self.siteoper.get_domains_by_ids(subscribe.sites)
|
||||||
siteids = json.loads(subscribe.sites)
|
|
||||||
if siteids:
|
|
||||||
domains = self.siteoper.get_domains_by_ids(siteids)
|
|
||||||
except JSONDecodeError:
|
|
||||||
pass
|
|
||||||
# 识别媒体信息
|
# 识别媒体信息
|
||||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||||
tmdbid=subscribe.tmdbid,
|
tmdbid=subscribe.tmdbid,
|
||||||
@@ -621,31 +613,53 @@ class SubscribeChain(ChainBase):
|
|||||||
downloaded_episodes=self.__get_downloaded_episodes(subscribe)
|
downloaded_episodes=self.__get_downloaded_episodes(subscribe)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 过滤规则
|
|
||||||
filter_rule = self.get_filter_rule(subscribe)
|
|
||||||
|
|
||||||
# 遍历缓存种子
|
# 遍历缓存种子
|
||||||
_match_context = []
|
_match_context = []
|
||||||
for domain, contexts in torrents.items():
|
for domain, contexts in torrents.items():
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
if domains and domain not in domains:
|
if domains and domain not in domains:
|
||||||
continue
|
continue
|
||||||
logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
|
logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
# 检查是否匹配
|
# 提取信息
|
||||||
torrent_meta = context.meta_info
|
torrent_meta = copy.deepcopy(context.meta_info)
|
||||||
torrent_mediainfo = context.media_info
|
torrent_mediainfo = copy.deepcopy(context.media_info)
|
||||||
torrent_info = context.torrent_info
|
torrent_info = context.torrent_info
|
||||||
|
|
||||||
# 先判断是否有没识别的种子
|
# 不在订阅站点范围的不处理
|
||||||
if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
sub_sites = self.get_sub_sites(subscribe)
|
||||||
_cache_key = f"{torrent_info.title}_{torrent_info.description}"
|
if sub_sites and torrent_info.site not in sub_sites:
|
||||||
|
logger.debug(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 有自定义识别词时,需要判断是否需要重新识别
|
||||||
|
if subscribe.custom_words:
|
||||||
|
_, apply_words = WordsMatcher().prepare(torrent_info.title,
|
||||||
|
custom_words=subscribe.custom_words.split("\n"))
|
||||||
|
if apply_words:
|
||||||
|
logger.info(
|
||||||
|
f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据...')
|
||||||
|
# 重新识别元数据
|
||||||
|
torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
|
||||||
|
custom_words=subscribe.custom_words)
|
||||||
|
# 媒体信息需要重新识别
|
||||||
|
torrent_mediainfo = None
|
||||||
|
|
||||||
|
# 先判断是否有没识别的种子,否则重新识别
|
||||||
|
if not torrent_mediainfo \
|
||||||
|
or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||||
|
# 避免重复处理
|
||||||
|
_cache_key = f"{torrent_meta.org_string}_{torrent_info.description}"
|
||||||
if _cache_key not in _recognize_cached:
|
if _cache_key not in _recognize_cached:
|
||||||
_recognize_cached.append(_cache_key)
|
_recognize_cached.append(_cache_key)
|
||||||
logger.info(
|
# 重新识别媒体信息
|
||||||
f'{torrent_info.site_name} - {torrent_info.title} 订阅缓存为未识别状态,尝试重新识别...')
|
torrent_mediainfo = self.recognize_media(meta=torrent_meta)
|
||||||
# 重新识别(不使用缓存)
|
if torrent_mediainfo:
|
||||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta, cache=False)
|
# 更新种子缓存
|
||||||
|
context.media_info = torrent_mediainfo
|
||||||
if not torrent_mediainfo:
|
if not torrent_mediainfo:
|
||||||
|
# 通过标题匹配兜底
|
||||||
logger.warn(
|
logger.warn(
|
||||||
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
||||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||||
@@ -653,8 +667,8 @@ class SubscribeChain(ChainBase):
|
|||||||
torrent=torrent_info):
|
torrent=torrent_info):
|
||||||
# 匹配成功
|
# 匹配成功
|
||||||
logger.info(
|
logger.info(
|
||||||
f'{mediainfo.title_year} 通过标题匹配到资源:{torrent_info.site_name} - {torrent_info.title}')
|
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||||
# 更新缓存
|
# 更新种子缓存
|
||||||
torrent_mediainfo = mediainfo
|
torrent_mediainfo = mediainfo
|
||||||
context.media_info = mediainfo
|
context.media_info = mediainfo
|
||||||
else:
|
else:
|
||||||
@@ -671,30 +685,10 @@ class SubscribeChain(ChainBase):
|
|||||||
and torrent_mediainfo.douban_id != mediainfo.douban_id:
|
and torrent_mediainfo.douban_id != mediainfo.douban_id:
|
||||||
continue
|
continue
|
||||||
logger.info(
|
logger.info(
|
||||||
f'{mediainfo.title_year} 通过媒体信ID匹配到资源:{torrent_info.site_name} - {torrent_info.title}')
|
f'{mediainfo.title_year} 通过媒体信ID匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 优先级过滤规则
|
|
||||||
if subscribe.best_version:
|
|
||||||
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
|
||||||
else:
|
|
||||||
priority_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
|
|
||||||
result: List[TorrentInfo] = self.filter_torrents(
|
|
||||||
rule_string=priority_rule,
|
|
||||||
torrent_list=[torrent_info],
|
|
||||||
mediainfo=torrent_mediainfo)
|
|
||||||
if result is not None and not result:
|
|
||||||
# 不符合过滤规则
|
|
||||||
logger.debug(f"{torrent_info.title} 不匹配当前过滤规则")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 不在订阅站点范围的不处理
|
|
||||||
sub_sites = self.get_sub_sites(subscribe)
|
|
||||||
if sub_sites and torrent_info.site not in sub_sites:
|
|
||||||
logger.debug(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 如果是电视剧
|
# 如果是电视剧
|
||||||
if torrent_mediainfo.type == MediaType.TV:
|
if torrent_mediainfo.type == MediaType.TV:
|
||||||
# 有多季的不要
|
# 有多季的不要
|
||||||
@@ -733,10 +727,25 @@ class SubscribeChain(ChainBase):
|
|||||||
logger.debug(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
logger.debug(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 过滤规则
|
# 匹配订阅附加参数
|
||||||
if not self.torrenthelper.filter_torrent(torrent_info=torrent_info,
|
if not self.torrenthelper.filter_torrent(torrent_info=torrent_info,
|
||||||
filter_rule=filter_rule,
|
filter_params=self.get_params(subscribe)):
|
||||||
mediainfo=torrent_mediainfo):
|
continue
|
||||||
|
|
||||||
|
# 优先级过滤规则
|
||||||
|
if subscribe.best_version:
|
||||||
|
rule_groups = subscribe.filter_groups \
|
||||||
|
or self.systemconfig.get(SystemConfigKey.BestVersionFilterRuleGroups)
|
||||||
|
else:
|
||||||
|
rule_groups = subscribe.filter_groups \
|
||||||
|
or self.systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups)
|
||||||
|
result: List[TorrentInfo] = self.filter_torrents(
|
||||||
|
rule_groups=rule_groups,
|
||||||
|
torrent_list=[torrent_info],
|
||||||
|
mediainfo=torrent_mediainfo)
|
||||||
|
if result is not None and not result:
|
||||||
|
# 不符合过滤规则
|
||||||
|
logger.debug(f"{torrent_info.title} 不匹配过滤规则")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 洗版时,优先级小于已下载优先级的不要
|
# 洗版时,优先级小于已下载优先级的不要
|
||||||
@@ -763,7 +772,8 @@ class SubscribeChain(ChainBase):
|
|||||||
no_exists=no_exists,
|
no_exists=no_exists,
|
||||||
userid=subscribe.username,
|
userid=subscribe.username,
|
||||||
username=subscribe.username,
|
username=subscribe.username,
|
||||||
save_path=subscribe.save_path)
|
save_path=subscribe.save_path,
|
||||||
|
media_category=subscribe.media_category)
|
||||||
# 判断是否要完成订阅
|
# 判断是否要完成订阅
|
||||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||||
downloads=downloads, lefts=lefts)
|
downloads=downloads, lefts=lefts)
|
||||||
@@ -779,6 +789,8 @@ class SubscribeChain(ChainBase):
|
|||||||
return
|
return
|
||||||
# 遍历订阅
|
# 遍历订阅
|
||||||
for subscribe in subscribes:
|
for subscribe in subscribes:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
logger.info(f'开始更新订阅元数据:{subscribe.name} ...')
|
logger.info(f'开始更新订阅元数据:{subscribe.name} ...')
|
||||||
# 生成元数据
|
# 生成元数据
|
||||||
meta = MetaInfo(subscribe.name)
|
meta = MetaInfo(subscribe.name)
|
||||||
@@ -832,10 +844,7 @@ class SubscribeChain(ChainBase):
|
|||||||
return
|
return
|
||||||
note = []
|
note = []
|
||||||
if subscribe.note:
|
if subscribe.note:
|
||||||
try:
|
note = subscribe.note or []
|
||||||
note = json.loads(subscribe.note)
|
|
||||||
except JSONDecodeError:
|
|
||||||
note = []
|
|
||||||
for context in downloads:
|
for context in downloads:
|
||||||
meta = context.meta_info
|
meta = context.meta_info
|
||||||
mediainfo = context.media_info
|
mediainfo = context.media_info
|
||||||
@@ -854,7 +863,7 @@ class SubscribeChain(ChainBase):
|
|||||||
note = list(set(note).union(set(episodes)))
|
note = list(set(note).union(set(episodes)))
|
||||||
# 更新订阅
|
# 更新订阅
|
||||||
self.subscribeoper.update(subscribe.id, {
|
self.subscribeoper.update(subscribe.id, {
|
||||||
"note": json.dumps(note)
|
"note": note
|
||||||
})
|
})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -866,13 +875,9 @@ class SubscribeChain(ChainBase):
|
|||||||
return []
|
return []
|
||||||
if subscribe.type != MediaType.TV.value:
|
if subscribe.type != MediaType.TV.value:
|
||||||
return []
|
return []
|
||||||
try:
|
episodes = subscribe.note or []
|
||||||
episodes = json.loads(subscribe.note)
|
logger.info(f'订阅 {subscribe.name} 第{subscribe.season}季 已下载集数:{episodes}')
|
||||||
logger.info(f'订阅 {subscribe.name} 第{subscribe.season}季 已下载集数:{episodes}')
|
return episodes
|
||||||
return episodes
|
|
||||||
except JSONDecodeError:
|
|
||||||
logger.warn(f'订阅 {subscribe.name} note字段解析失败')
|
|
||||||
return []
|
|
||||||
|
|
||||||
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
|
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
|
||||||
subscribe: Subscribe,
|
subscribe: Subscribe,
|
||||||
@@ -917,18 +922,20 @@ class SubscribeChain(ChainBase):
|
|||||||
msgstr = "洗版"
|
msgstr = "洗版"
|
||||||
logger.info(f'{mediainfo.title_year} 完成{msgstr}')
|
logger.info(f'{mediainfo.title_year} 完成{msgstr}')
|
||||||
# 新增订阅历史
|
# 新增订阅历史
|
||||||
self.subscribehistoryoper.add(**subscribe.to_dict())
|
self.subscribeoper.add_history(**subscribe.to_dict())
|
||||||
# 删除订阅
|
# 删除订阅
|
||||||
self.subscribeoper.delete(subscribe.id)
|
self.subscribeoper.delete(subscribe.id)
|
||||||
# 发送通知
|
# 发送通知
|
||||||
if mediainfo.type == MediaType.TV:
|
if mediainfo.type == MediaType.TV:
|
||||||
link = settings.MP_DOMAIN('#/subscribe-tv?tab=mysub')
|
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||||
else:
|
else:
|
||||||
link = settings.MP_DOMAIN('#/subscribe-movie?tab=mysub')
|
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||||
|
# 完成订阅按规则发送消息
|
||||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||||
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
|
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
|
||||||
image=mediainfo.get_message_image(),
|
image=mediainfo.get_message_image(),
|
||||||
link=link))
|
link=link,
|
||||||
|
username=subscribe.username))
|
||||||
# 发送事件
|
# 发送事件
|
||||||
EventManager().send_event(EventType.SubscribeComplete, {
|
EventManager().send_event(EventType.SubscribeComplete, {
|
||||||
"subscribe_id": subscribe.id,
|
"subscribe_id": subscribe.id,
|
||||||
@@ -941,13 +948,15 @@ class SubscribeChain(ChainBase):
|
|||||||
"doubanid": mediainfo.douban_id
|
"doubanid": mediainfo.douban_id
|
||||||
})
|
})
|
||||||
|
|
||||||
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
|
def remote_list(self, channel: MessageChannel,
|
||||||
|
userid: Union[str, int] = None, source: str = None):
|
||||||
"""
|
"""
|
||||||
查询订阅并发送消息
|
查询订阅并发送消息
|
||||||
"""
|
"""
|
||||||
subscribes = self.subscribeoper.list()
|
subscribes = self.subscribeoper.list()
|
||||||
if not subscribes:
|
if not subscribes:
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel,
|
||||||
|
source=source,
|
||||||
title='没有任何订阅!', userid=userid))
|
title='没有任何订阅!', userid=userid))
|
||||||
return
|
return
|
||||||
title = f"共有 {len(subscribes)} 个订阅,回复对应指令操作: " \
|
title = f"共有 {len(subscribes)} 个订阅,回复对应指令操作: " \
|
||||||
@@ -964,15 +973,16 @@ class SubscribeChain(ChainBase):
|
|||||||
f"[{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
|
f"[{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
|
||||||
f"/{subscribe.total_episode}]")
|
f"/{subscribe.total_episode}]")
|
||||||
# 发送列表
|
# 发送列表
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel, source=source,
|
||||||
title=title, text='\n'.join(messages), userid=userid))
|
title=title, text='\n'.join(messages), userid=userid))
|
||||||
|
|
||||||
def remote_delete(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None):
|
def remote_delete(self, arg_str: str, channel: MessageChannel,
|
||||||
|
userid: Union[str, int] = None, source: str = None):
|
||||||
"""
|
"""
|
||||||
删除订阅
|
删除订阅
|
||||||
"""
|
"""
|
||||||
if not arg_str:
|
if not arg_str:
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel, source=source,
|
||||||
title="请输入正确的命令格式:/subscribe_delete [id],"
|
title="请输入正确的命令格式:/subscribe_delete [id],"
|
||||||
"[id]为订阅编号", userid=userid))
|
"[id]为订阅编号", userid=userid))
|
||||||
return
|
return
|
||||||
@@ -984,7 +994,7 @@ class SubscribeChain(ChainBase):
|
|||||||
subscribe_id = int(arg_str)
|
subscribe_id = int(arg_str)
|
||||||
subscribe = self.subscribeoper.get(subscribe_id)
|
subscribe = self.subscribeoper.get(subscribe_id)
|
||||||
if not subscribe:
|
if not subscribe:
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel, source=source,
|
||||||
title=f"订阅编号 {subscribe_id} 不存在!", userid=userid))
|
title=f"订阅编号 {subscribe_id} 不存在!", userid=userid))
|
||||||
return
|
return
|
||||||
# 删除订阅
|
# 删除订阅
|
||||||
@@ -995,7 +1005,7 @@ class SubscribeChain(ChainBase):
|
|||||||
"doubanid": subscribe.doubanid
|
"doubanid": subscribe.doubanid
|
||||||
})
|
})
|
||||||
# 重新发送消息
|
# 重新发送消息
|
||||||
self.remote_list(channel, userid)
|
self.remote_list(channel=channel, userid=userid, source=source)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_subscribe_no_exits(subscribe_name: str,
|
def __get_subscribe_no_exits(subscribe_name: str,
|
||||||
@@ -1004,7 +1014,8 @@ class SubscribeChain(ChainBase):
|
|||||||
begin_season: int,
|
begin_season: int,
|
||||||
total_episode: int,
|
total_episode: int,
|
||||||
start_episode: int,
|
start_episode: int,
|
||||||
downloaded_episodes: List[int] = None):
|
downloaded_episodes: List[int] = None
|
||||||
|
) -> Dict[Union[int, str], Dict[int, NotExistMediaInfo]]:
|
||||||
"""
|
"""
|
||||||
根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数
|
根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数
|
||||||
:param subscribe_name: 订阅名称
|
:param subscribe_name: 订阅名称
|
||||||
@@ -1127,27 +1138,24 @@ class SubscribeChain(ChainBase):
|
|||||||
for subscribe in self.subscribeoper.list():
|
for subscribe in self.subscribeoper.list():
|
||||||
if not subscribe.sites:
|
if not subscribe.sites:
|
||||||
continue
|
continue
|
||||||
try:
|
sites = subscribe.sites or []
|
||||||
sites = json.loads(subscribe.sites)
|
|
||||||
except JSONDecodeError:
|
|
||||||
sites = []
|
|
||||||
if site_id not in sites:
|
if site_id not in sites:
|
||||||
continue
|
continue
|
||||||
sites.remove(site_id)
|
sites.remove(site_id)
|
||||||
self.subscribeoper.update(subscribe.id, {
|
self.subscribeoper.update(subscribe.id, {
|
||||||
"sites": json.dumps(sites)
|
"sites": sites
|
||||||
})
|
})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_default_subscribe_config(mtype: MediaType, default_config_key: str):
|
def __get_default_subscribe_config(mtype: MediaType, default_config_key: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
获取默认订阅配置
|
获取默认订阅配置
|
||||||
"""
|
"""
|
||||||
default_subscribe_key = None
|
default_subscribe_key = None
|
||||||
if mtype == MediaType.TV:
|
if mtype == MediaType.TV:
|
||||||
default_subscribe_key = "DefaultTvSubscribeConfig"
|
default_subscribe_key = SystemConfigKey.DefaultTvSubscribeConfig.value
|
||||||
if mtype == MediaType.MOVIE:
|
if mtype == MediaType.MOVIE:
|
||||||
default_subscribe_key = "DefaultMovieSubscribeConfig"
|
default_subscribe_key = SystemConfigKey.DefaultMovieSubscribeConfig.value
|
||||||
|
|
||||||
# 默认订阅规则
|
# 默认订阅规则
|
||||||
if hasattr(settings, default_subscribe_key):
|
if hasattr(settings, default_subscribe_key):
|
||||||
@@ -1158,3 +1166,125 @@ class SubscribeChain(ChainBase):
|
|||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
return value.get(default_config_key) or None
|
return value.get(default_config_key) or None
|
||||||
|
|
||||||
|
def get_params(self, subscribe: Subscribe):
|
||||||
|
"""
|
||||||
|
获取订阅默认参数
|
||||||
|
"""
|
||||||
|
# 默认过滤规则
|
||||||
|
default_rule = self.systemconfig.get(SystemConfigKey.SubscribeDefaultParams) or {}
|
||||||
|
return {
|
||||||
|
"include": subscribe.include or default_rule.get("include"),
|
||||||
|
"exclude": subscribe.exclude or default_rule.get("exclude"),
|
||||||
|
"quality": subscribe.quality or default_rule.get("quality"),
|
||||||
|
"resolution": subscribe.resolution or default_rule.get("resolution"),
|
||||||
|
"effect": subscribe.effect or default_rule.get("effect"),
|
||||||
|
"tv_size": default_rule.get("tv_size"),
|
||||||
|
"movie_size": default_rule.get("movie_size"),
|
||||||
|
"min_seeders": default_rule.get("min_seeders"),
|
||||||
|
"min_seeders_time": default_rule.get("min_seeders_time"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def subscribe_files_info(self, subscribe: Subscribe) -> Optional[SubscrbieInfo]:
|
||||||
|
"""
|
||||||
|
订阅相关的下载和文件信息
|
||||||
|
"""
|
||||||
|
if not subscribe:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 返回订阅数据
|
||||||
|
subscribe_info = SubscrbieInfo()
|
||||||
|
|
||||||
|
# 所有集的数据
|
||||||
|
episodes: Dict[int, SubscribeEpisodeInfo] = {}
|
||||||
|
if subscribe.tmdbid and subscribe.type == MediaType.TV.value:
|
||||||
|
# 查询TMDB中的集信息
|
||||||
|
tmdb_episodes = self.tmdbchain.tmdb_episodes(
|
||||||
|
tmdbid=subscribe.tmdbid,
|
||||||
|
season=subscribe.season
|
||||||
|
)
|
||||||
|
if tmdb_episodes:
|
||||||
|
for episode in tmdb_episodes:
|
||||||
|
info = SubscribeEpisodeInfo()
|
||||||
|
info.title = episode.name
|
||||||
|
info.description = episode.overview
|
||||||
|
info.backdrop = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/w500${episode.still_path}"
|
||||||
|
episodes[episode.episode_number] = info
|
||||||
|
elif subscribe.type == MediaType.TV.value:
|
||||||
|
# 根据开始结束集计算集信息
|
||||||
|
for i in range(subscribe.start_episode or 1, subscribe.total_episode + 1):
|
||||||
|
info = SubscribeEpisodeInfo()
|
||||||
|
info.title = f'第 {i} 集'
|
||||||
|
episodes[i] = info
|
||||||
|
else:
|
||||||
|
# 电影
|
||||||
|
info = SubscribeEpisodeInfo()
|
||||||
|
info.title = subscribe.name
|
||||||
|
episodes[0] = info
|
||||||
|
|
||||||
|
# 所有下载记录
|
||||||
|
download_his = self.downloadhis.get_by_mediaid(tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)
|
||||||
|
if download_his:
|
||||||
|
for his in download_his:
|
||||||
|
# 查询下载文件
|
||||||
|
files = self.downloadhis.get_files_by_hash(his.download_hash)
|
||||||
|
if files:
|
||||||
|
for file in files:
|
||||||
|
# 识别文件名
|
||||||
|
file_meta = MetaInfo(file.filepath)
|
||||||
|
# 下载文件信息
|
||||||
|
file_info = SubscribeDownloadFileInfo(
|
||||||
|
torrent_title=his.torrent_name,
|
||||||
|
site_name=his.torrent_site,
|
||||||
|
downloader=file.downloader,
|
||||||
|
hash=his.download_hash,
|
||||||
|
file_path=file.fullpath,
|
||||||
|
)
|
||||||
|
if subscribe.type == MediaType.TV.value:
|
||||||
|
episode_number = file_meta.begin_episode
|
||||||
|
if episode_number and episodes.get(episode_number):
|
||||||
|
episodes[episode_number].download.append(file_info)
|
||||||
|
else:
|
||||||
|
episodes[0].download.append(file_info)
|
||||||
|
|
||||||
|
# 生成元数据
|
||||||
|
meta = MetaInfo(subscribe.name)
|
||||||
|
meta.year = subscribe.year
|
||||||
|
meta.begin_season = subscribe.season or None
|
||||||
|
try:
|
||||||
|
meta.type = MediaType(subscribe.type)
|
||||||
|
except ValueError:
|
||||||
|
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
|
||||||
|
return subscribe_info
|
||||||
|
# 识别媒体信息
|
||||||
|
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||||
|
tmdbid=subscribe.tmdbid,
|
||||||
|
doubanid=subscribe.doubanid,
|
||||||
|
cache=False)
|
||||||
|
if not mediainfo:
|
||||||
|
logger.warn(
|
||||||
|
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||||
|
return subscribe_info
|
||||||
|
|
||||||
|
# 所有媒体库文件记录
|
||||||
|
library_fileitems = self.media_files(mediainfo)
|
||||||
|
if library_fileitems:
|
||||||
|
for fileitem in library_fileitems:
|
||||||
|
# 识别文件名
|
||||||
|
file_meta = MetaInfo(fileitem.path)
|
||||||
|
# 媒体库文件信息
|
||||||
|
file_info = SubscribeLibraryFileInfo(
|
||||||
|
storage=fileitem.storage,
|
||||||
|
file_path=fileitem.path,
|
||||||
|
)
|
||||||
|
if subscribe.type == MediaType.TV.value:
|
||||||
|
episode_number = file_meta.begin_episode
|
||||||
|
if episode_number and episodes.get(episode_number):
|
||||||
|
episodes[episode_number].library.append(file_info)
|
||||||
|
else:
|
||||||
|
episodes[0].library.append(file_info)
|
||||||
|
|
||||||
|
# 更新订阅信息
|
||||||
|
subscribe_info.subscribe = Subscribe(**subscribe.to_dict())
|
||||||
|
subscribe_info.episodes = episodes
|
||||||
|
return subscribe_info
|
||||||
|
|||||||
@@ -19,20 +19,25 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
|||||||
|
|
||||||
_restart_file = "__system_restart__"
|
_restart_file = "__system_restart__"
|
||||||
|
|
||||||
def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str]):
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
# 重启完成检测
|
||||||
|
self.restart_finish()
|
||||||
|
|
||||||
|
def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str], source: str = None):
|
||||||
"""
|
"""
|
||||||
清理系统缓存
|
清理系统缓存
|
||||||
"""
|
"""
|
||||||
self.clear_cache()
|
self.clear_cache()
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel, source=source,
|
||||||
title=f"缓存清理完成!", userid=userid))
|
title=f"缓存清理完成!", userid=userid))
|
||||||
|
|
||||||
def restart(self, channel: MessageChannel, userid: Union[int, str]):
|
def restart(self, channel: MessageChannel, userid: Union[int, str], source: str = None):
|
||||||
"""
|
"""
|
||||||
重启系统
|
重启系统
|
||||||
"""
|
"""
|
||||||
if channel and userid:
|
if channel and userid:
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel, source=source,
|
||||||
title="系统正在重启,请耐心等候!", userid=userid))
|
title="系统正在重启,请耐心等候!", userid=userid))
|
||||||
# 保存重启信息
|
# 保存重启信息
|
||||||
self.save_cache({
|
self.save_cache({
|
||||||
@@ -59,11 +64,11 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
|||||||
title += f"当前前端版本:{front_local_version},远程版本:{front_release_version}"
|
title += f"当前前端版本:{front_local_version},远程版本:{front_release_version}"
|
||||||
return title
|
return title
|
||||||
|
|
||||||
def version(self, channel: MessageChannel, userid: Union[int, str]):
|
def version(self, channel: MessageChannel, userid: Union[int, str], source: str = None):
|
||||||
"""
|
"""
|
||||||
查看当前版本、远程版本
|
查看当前版本、远程版本
|
||||||
"""
|
"""
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel, source=source,
|
||||||
title=self.__get_version_message(),
|
title=self.__get_version_message(),
|
||||||
userid=userid))
|
userid=userid))
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from cachetools import cached, TTLCache
|
|||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
@@ -128,11 +127,11 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
||||||
def get_trending_wallpapers(self, num: int = 10) -> Optional[List[str]]:
|
def get_trending_wallpapers(self, num: int = 10) -> List[str]:
|
||||||
"""
|
"""
|
||||||
获取所有流行壁纸
|
获取所有流行壁纸
|
||||||
"""
|
"""
|
||||||
infos = self.tmdb_trending()
|
infos = self.tmdb_trending()
|
||||||
if infos:
|
if infos:
|
||||||
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
|
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
|
||||||
return None
|
return []
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from cachetools import cached, TTLCache
|
|||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings, global_vars
|
||||||
from app.core.context import TorrentInfo, Context, MediaInfo
|
from app.core.context import TorrentInfo, Context, MediaInfo
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.db.site_oper import SiteOper
|
from app.db.site_oper import SiteOper
|
||||||
@@ -158,6 +158,8 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
domains = []
|
domains = []
|
||||||
# 遍历站点缓存资源
|
# 遍历站点缓存资源
|
||||||
for indexer in indexers:
|
for indexer in indexers:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
# 未开启的站点不刷新
|
# 未开启的站点不刷新
|
||||||
if sites and indexer.get("id") not in sites:
|
if sites and indexer.get("id") not in sites:
|
||||||
continue
|
continue
|
||||||
@@ -185,6 +187,8 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
logger.info(f'{indexer.get("name")} 没有新种子')
|
logger.info(f'{indexer.get("name")} 没有新种子')
|
||||||
continue
|
continue
|
||||||
for torrent in torrents:
|
for torrent in torrents:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
logger.info(f'处理资源:{torrent.title} ...')
|
logger.info(f'处理资源:{torrent.title} ...')
|
||||||
# 识别
|
# 识别
|
||||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,237 @@
|
|||||||
from typing import Optional
|
import secrets
|
||||||
|
from typing import Optional, Tuple, Union
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import get_password_hash, verify_password
|
||||||
|
from app.db.models.user import User
|
||||||
|
from app.db.user_oper import UserOper
|
||||||
|
from app.log import logger
|
||||||
|
from app.schemas.event 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):
|
class UserChain(ChainBase, metaclass=Singleton):
|
||||||
|
"""
|
||||||
|
用户链,处理多种认证协议
|
||||||
|
"""
|
||||||
|
|
||||||
def user_authenticate(self, name, password) -> Optional[str]:
|
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"
|
||||||
|
) -> Union[Tuple[bool, Optional[str]], Tuple[bool, Optional[User]]]:
|
||||||
"""
|
"""
|
||||||
辅助完成用户认证
|
认证用户,根据不同的 grant_type 处理不同的认证流程
|
||||||
:param name: 用户名
|
|
||||||
:param password: 密码
|
:param username: 用户名,适用于 "password" grant_type
|
||||||
:return: token
|
:param password: 用户密码,适用于 "password" grant_type
|
||||||
|
:param mfa_code: 一次性密码,适用于 "password" grant_type
|
||||||
|
:param code: 授权码,适用于 "authorization_code" grant_type
|
||||||
|
:param grant_type: 认证类型,如 "password", "authorization_code", "client_credentials"
|
||||||
|
:return:
|
||||||
|
- 对于成功的认证,返回 (True, User)
|
||||||
|
- 对于失败的认证,返回 (False, "错误信息")
|
||||||
"""
|
"""
|
||||||
return self.run_module("user_authenticate", name=name, password=password)
|
credentials = AuthCredentials(
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
mfa_code=mfa_code,
|
||||||
|
code=code,
|
||||||
|
grant_type=grant_type
|
||||||
|
)
|
||||||
|
logger.debug(f"认证类型:{grant_type},开始准备对用户 {username} 进行身份校验")
|
||||||
|
if credentials.grant_type == "password":
|
||||||
|
# Password 认证
|
||||||
|
success, user_or_message = self.password_authenticate(credentials=credentials)
|
||||||
|
if success:
|
||||||
|
# 如果用户启用了二次验证码,则进一步验证
|
||||||
|
if not self._verify_mfa(user_or_message, credentials.mfa_code):
|
||||||
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
|
logger.info(f"用户 {username} 通过密码认证成功")
|
||||||
|
return True, user_or_message
|
||||||
|
else:
|
||||||
|
# 用户不存在或密码错误,考虑辅助认证
|
||||||
|
if settings.AUXILIARY_AUTH_ENABLE:
|
||||||
|
logger.warning("密码认证失败,尝试通过外部服务进行辅助认证 ...")
|
||||||
|
aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials)
|
||||||
|
if aux_success:
|
||||||
|
# 辅助认证成功后再验证二次验证码
|
||||||
|
if not self._verify_mfa(aux_user_or_message, credentials.mfa_code):
|
||||||
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
|
return True, aux_user_or_message
|
||||||
|
else:
|
||||||
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
|
else:
|
||||||
|
logger.debug(f"辅助认证未启用,用户 {username} 认证失败")
|
||||||
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
|
elif credentials.grant_type == "authorization_code":
|
||||||
|
# 处理其他认证类型的分支
|
||||||
|
if settings.AUXILIARY_AUTH_ENABLE:
|
||||||
|
aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials)
|
||||||
|
if aux_success:
|
||||||
|
return True, aux_user_or_message
|
||||||
|
else:
|
||||||
|
return False, "认证失败"
|
||||||
|
else:
|
||||||
|
return False, "认证失败"
|
||||||
|
else:
|
||||||
|
logger.debug(f"辅助认证未启用,认证类型 {grant_type} 未实现")
|
||||||
|
return False, "不支持的认证类型"
|
||||||
|
|
||||||
|
def password_authenticate(self, credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:
|
||||||
|
"""
|
||||||
|
密码认证
|
||||||
|
|
||||||
|
:param credentials: 认证凭证,包含用户名、密码以及可选的 MFA 认证码
|
||||||
|
:return:
|
||||||
|
- 成功时返回 (True, User),其中 User 是认证通过的用户对象
|
||||||
|
- 失败时返回 (False, "错误信息")
|
||||||
|
"""
|
||||||
|
if not credentials or credentials.grant_type != "password":
|
||||||
|
logger.info("密码认证失败,认证类型不匹配")
|
||||||
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
|
|
||||||
|
user = self.user_oper.get_by_name(name=credentials.username)
|
||||||
|
if not user:
|
||||||
|
logger.info(f"密码认证失败,用户 {credentials.username} 不存在")
|
||||||
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
logger.info(f"密码认证失败,用户 {credentials.username} 已被禁用")
|
||||||
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
|
|
||||||
|
if not verify_password(credentials.password, str(user.hashed_password)):
|
||||||
|
logger.info(f"密码认证失败,用户 {credentials.username} 的密码验证不通过")
|
||||||
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
|
|
||||||
|
return True, user
|
||||||
|
|
||||||
|
def auxiliary_authenticate(self, credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:
|
||||||
|
"""
|
||||||
|
辅助用户认证
|
||||||
|
|
||||||
|
:param credentials: 认证凭证,包含必要的认证信息
|
||||||
|
:return:
|
||||||
|
- 成功时返回 (True, User),其中 User 是认证通过的用户对象
|
||||||
|
- 失败时返回 (False, "错误信息")
|
||||||
|
"""
|
||||||
|
if not credentials:
|
||||||
|
return False, "认证凭证无效"
|
||||||
|
|
||||||
|
# 检查是否因为用户被禁用
|
||||||
|
if credentials.username:
|
||||||
|
user = self.user_oper.get_by_name(name=credentials.username)
|
||||||
|
if user and not user.is_active:
|
||||||
|
logger.info(f"用户 {user.name} 已被禁用,跳过后续身份校验")
|
||||||
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
|
|
||||||
|
logger.debug(f"认证类型:{credentials.grant_type},尝试通过系统模块进行辅助认证,用户: {credentials.username}")
|
||||||
|
result = self.run_module("user_authenticate", credentials=credentials)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
logger.debug(f"通过系统模块辅助认证失败,尝试触发 {ChainEventType.AuthVerification} 事件")
|
||||||
|
event = self.eventmanager.send_event(etype=ChainEventType.AuthVerification, data=credentials)
|
||||||
|
if not event or not event.event_data:
|
||||||
|
logger.error(f"认证类型:{credentials.grant_type},辅助认证失败,未返回有效数据")
|
||||||
|
return False, f"认证类型:{credentials.grant_type},辅助认证事件失败或无效"
|
||||||
|
|
||||||
|
credentials = event.event_data # 使用事件返回的认证数据
|
||||||
|
else:
|
||||||
|
logger.info(f"通过系统模块辅助认证成功,用户: {credentials.username}")
|
||||||
|
credentials = result # 使用模块认证返回的认证数据
|
||||||
|
|
||||||
|
# 处理认证成功的逻辑
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
logger.warning(f"用户 {credentials.username} 辅助认证未通过")
|
||||||
|
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verify_mfa(user: User, mfa_code: Optional[str]) -> bool:
|
||||||
|
"""
|
||||||
|
验证 MFA(二次验证码)
|
||||||
|
|
||||||
|
:param user: 用户对象
|
||||||
|
:param mfa_code: 二次验证码
|
||||||
|
:return: 如果验证成功返回 True,否则返回 False
|
||||||
|
"""
|
||||||
|
if not user.is_otp:
|
||||||
|
return True
|
||||||
|
if not mfa_code:
|
||||||
|
logger.info(f"用户 {user.name} 缺少 MFA 认证码")
|
||||||
|
return False
|
||||||
|
if not OtpUtils.check(str(user.otp_secret), mfa_code):
|
||||||
|
logger.info(f"用户 {user.name} 的 MFA 认证失败")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _process_auth_success(self, username: str, credentials: AuthCredentials) -> bool:
|
||||||
|
"""
|
||||||
|
处理辅助认证成功的逻辑,返回用户对象或创建新用户
|
||||||
|
|
||||||
|
:param username: 用户名
|
||||||
|
:param credentials: 认证凭证,包含 token、channel、service 等信息
|
||||||
|
:return:
|
||||||
|
- 如果认证成功并且用户存在或已创建,返回 User 对象
|
||||||
|
- 如果认证被拦截或失败,返回 None
|
||||||
|
"""
|
||||||
|
if not username:
|
||||||
|
logger.info(f"未能获取到对应的用户信息,{credentials.grant_type} 认证不通过")
|
||||||
|
return False
|
||||||
|
|
||||||
|
token, channel, service = credentials.token, credentials.channel, credentials.service
|
||||||
|
if not all([token, channel, service]):
|
||||||
|
logger.info(f"用户 {username} 未通过 {credentials.grant_type} 认证,必要信息不足")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 触发认证通过的拦截事件
|
||||||
|
intercept_event = self.eventmanager.send_event(
|
||||||
|
etype=ChainEventType.AuthIntercept,
|
||||||
|
data=AuthInterceptCredentials(username=username, channel=channel, service=service,
|
||||||
|
token=token, status="completed")
|
||||||
|
)
|
||||||
|
if intercept_event and intercept_event.event_data:
|
||||||
|
intercept_data: AuthInterceptCredentials = intercept_event.event_data
|
||||||
|
if intercept_data.cancel:
|
||||||
|
logger.warning(
|
||||||
|
f"认证被拦截,用户:{username},渠道:{channel},服务:{service},拦截源:{intercept_data.source}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 检查用户是否存在,如果不存在且当前为密码认证时则创建新用户
|
||||||
|
user = self.user_oper.get_by_name(name=username)
|
||||||
|
if user:
|
||||||
|
# 如果用户存在,但是已经被禁用,则直接响应
|
||||||
|
if not user.is_active:
|
||||||
|
logger.info(f"辅助认证失败,用户 {username} 已被禁用")
|
||||||
|
return False
|
||||||
|
anonymized_token = f"{token[:len(token) // 2]}********"
|
||||||
|
logger.info(
|
||||||
|
f"认证类型:{credentials.grant_type},用户:{username},渠道:{channel},"
|
||||||
|
f"服务:{service} 认证成功,token:{anonymized_token}")
|
||||||
|
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)))
|
||||||
|
logger.info(f"用户 {username} 不存在,已通过 {credentials.grant_type} 认证并已创建普通用户")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"认证类型:{credentials.grant_type},用户:{username},渠道:{channel},"
|
||||||
|
f"服务:{service} 认证不通过,未能在本地找到对应的用户信息")
|
||||||
|
return False
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Any, Dict, List, Optional, Tuple, Type
|
||||||
|
|
||||||
from pydantic import BaseSettings, validator
|
from dotenv import set_key
|
||||||
|
from pydantic import BaseModel, BaseSettings, validator
|
||||||
|
|
||||||
|
from app.log import logger
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
|
from app.utils.url import UrlUtils
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class ConfigModel(BaseModel):
|
||||||
"""
|
"""
|
||||||
系统配置类
|
Pydantic 配置模型,描述所有配置项及其类型和默认值
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = "ignore" # 忽略未定义的配置项
|
||||||
|
|
||||||
# 项目名称
|
# 项目名称
|
||||||
PROJECT_NAME = "MoviePilot"
|
PROJECT_NAME = "MoviePilot"
|
||||||
# 域名 格式;https://movie-pilot.org
|
# 域名 格式;https://movie-pilot.org
|
||||||
@@ -23,10 +33,14 @@ class Settings(BaseSettings):
|
|||||||
FRONTEND_PATH: str = "/public"
|
FRONTEND_PATH: str = "/public"
|
||||||
# 密钥
|
# 密钥
|
||||||
SECRET_KEY: str = secrets.token_urlsafe(32)
|
SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||||
|
# RESOURCE密钥
|
||||||
|
RESOURCE_SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||||
# 允许的域名
|
# 允许的域名
|
||||||
ALLOWED_HOSTS: list = ["*"]
|
ALLOWED_HOSTS: list = ["*"]
|
||||||
# TOKEN过期时间
|
# TOKEN过期时间
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
||||||
|
# RESOURCE_TOKEN过期时间
|
||||||
|
RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS: int = 60 * 30
|
||||||
# 时区
|
# 时区
|
||||||
TZ: str = "Asia/Shanghai"
|
TZ: str = "Asia/Shanghai"
|
||||||
# API监听地址
|
# API监听地址
|
||||||
@@ -39,18 +53,34 @@ class Settings(BaseSettings):
|
|||||||
DEBUG: bool = False
|
DEBUG: bool = False
|
||||||
# 是否开发模式
|
# 是否开发模式
|
||||||
DEV: bool = False
|
DEV: bool = False
|
||||||
# 是否开启插件热加载
|
# 是否在控制台输出 SQL 语句,默认关闭
|
||||||
PLUGIN_AUTO_RELOAD: bool = False
|
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
|
||||||
# 配置文件目录
|
# 配置文件目录
|
||||||
CONFIG_DIR: Optional[str] = None
|
CONFIG_DIR: Optional[str] = None
|
||||||
# 超级管理员
|
# 超级管理员
|
||||||
SUPERUSER: str = "admin"
|
SUPERUSER: str = "admin"
|
||||||
|
# 辅助认证,允许通过外部服务进行认证、单点登录以及自动创建用户
|
||||||
|
AUXILIARY_AUTH_ENABLE: bool = False
|
||||||
# API密钥,需要更换
|
# API密钥,需要更换
|
||||||
API_TOKEN: str = "moviepilot"
|
API_TOKEN: Optional[str] = None
|
||||||
# 登录页面电影海报,tmdb/bing
|
|
||||||
WALLPAPER: str = "tmdb"
|
|
||||||
# 网络代理 IP:PORT
|
# 网络代理 IP:PORT
|
||||||
PROXY_HOST: Optional[str] = None
|
PROXY_HOST: Optional[str] = None
|
||||||
|
# 登录页面电影海报,tmdb/bing/mediaserver
|
||||||
|
WALLPAPER: str = "tmdb"
|
||||||
# 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔
|
# 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔
|
||||||
SEARCH_SOURCE: str = "themoviedb,douban,bangumi"
|
SEARCH_SOURCE: str = "themoviedb,douban,bangumi"
|
||||||
# 媒体识别来源 themoviedb/douban
|
# 媒体识别来源 themoviedb/douban
|
||||||
@@ -71,6 +101,20 @@ class Settings(BaseSettings):
|
|||||||
FANART_ENABLE: bool = True
|
FANART_ENABLE: bool = True
|
||||||
# Fanart API Key
|
# Fanart API Key
|
||||||
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
|
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
|
||||||
|
# 元数据识别缓存过期时间(小时)
|
||||||
|
META_CACHE_EXPIRE: int = 0
|
||||||
|
# 电视剧动漫的分类genre_ids
|
||||||
|
ANIME_GENREIDS = [16]
|
||||||
|
# 用户认证站点
|
||||||
|
AUTH_SITE: str = ""
|
||||||
|
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||||
|
AUTO_UPDATE_RESOURCE: bool = True
|
||||||
|
# 是否启用DOH解析域名
|
||||||
|
DOH_ENABLE: bool = True
|
||||||
|
# 使用 DOH 解析的域名列表
|
||||||
|
DOH_DOMAINS: str = "api.themoviedb.org,api.tmdb.org,webservice.fanart.tv,api.github.com,github.com,raw.githubusercontent.com,api.telegram.org"
|
||||||
|
# DOH 解析服务器列表
|
||||||
|
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
|
||||||
# 支持的后缀格式
|
# 支持的后缀格式
|
||||||
RMT_MEDIAEXT: list = ['.mp4', '.mkv', '.ts', '.iso',
|
RMT_MEDIAEXT: list = ['.mp4', '.mkv', '.ts', '.iso',
|
||||||
'.rmvb', '.avi', '.mov', '.mpeg',
|
'.rmvb', '.avi', '.mov', '.mpeg',
|
||||||
@@ -79,116 +123,40 @@ class Settings(BaseSettings):
|
|||||||
'.tp', '.f4v']
|
'.tp', '.f4v']
|
||||||
# 支持的字幕文件后缀格式
|
# 支持的字幕文件后缀格式
|
||||||
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa', '.sup']
|
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa', '.sup']
|
||||||
# 下载器临时文件后缀
|
|
||||||
DOWNLOAD_TMPEXT: list = ['.!qB', '.part']
|
|
||||||
# 支持的音轨文件后缀格式
|
# 支持的音轨文件后缀格式
|
||||||
RMT_AUDIO_TRACK_EXT: list = ['.mka']
|
RMT_AUDIO_TRACK_EXT: list = ['.mka']
|
||||||
# 索引器
|
# 音轨文件后缀格式
|
||||||
INDEXER: str = "builtin"
|
RMT_AUDIOEXT: list = ['.aac', '.ac3', '.amr', '.caf', '.cda', '.dsf',
|
||||||
|
'.dff', '.kar', '.m4a', '.mp1', '.mp2', '.mp3',
|
||||||
|
'.mid', '.mod', '.mka', '.mpc', '.nsf', '.ogg',
|
||||||
|
'.pcm', '.rmi', '.s3m', '.snd', '.spx', '.tak',
|
||||||
|
'.tta', '.vqf', '.wav', '.wma',
|
||||||
|
'.aifc', '.aiff', '.alac', '.adif', '.adts',
|
||||||
|
'.flac', '.midi', '.opus', '.sfalc']
|
||||||
|
# 下载器临时文件后缀
|
||||||
|
DOWNLOAD_TMPEXT: list = ['.!qB', '.part']
|
||||||
|
# 媒体服务器同步间隔(小时)
|
||||||
|
MEDIASERVER_SYNC_INTERVAL: int = 6
|
||||||
# 订阅模式
|
# 订阅模式
|
||||||
SUBSCRIBE_MODE: str = "spider"
|
SUBSCRIBE_MODE: str = "spider"
|
||||||
# RSS订阅模式刷新时间间隔(分钟)
|
# RSS订阅模式刷新时间间隔(分钟)
|
||||||
SUBSCRIBE_RSS_INTERVAL: int = 30
|
SUBSCRIBE_RSS_INTERVAL: int = 30
|
||||||
|
# 订阅数据共享
|
||||||
|
SUBSCRIBE_STATISTIC_SHARE: bool = True
|
||||||
# 订阅搜索开关
|
# 订阅搜索开关
|
||||||
SUBSCRIBE_SEARCH: bool = False
|
SUBSCRIBE_SEARCH: bool = False
|
||||||
# 用户认证站点
|
# 检查本地媒体库是否存在资源开关
|
||||||
AUTH_SITE: str = ""
|
LOCAL_EXISTS_SEARCH: bool = False
|
||||||
# 交互搜索自动下载用户ID,使用,分割
|
# 搜索多个名称
|
||||||
AUTO_DOWNLOAD_USER: Optional[str] = None
|
SEARCH_MULTIPLE_NAME: bool = False
|
||||||
# 消息通知渠道 telegram/wechat/slack/synologychat/vocechat/webpush,多个通知渠道用,分隔
|
# 站点数据刷新间隔(小时)
|
||||||
MESSAGER: str = "webpush"
|
SITEDATA_REFRESH_INTERVAL: int = 6
|
||||||
# WeChat企业ID
|
|
||||||
WECHAT_CORPID: Optional[str] = None
|
|
||||||
# WeChat应用Secret
|
|
||||||
WECHAT_APP_SECRET: Optional[str] = None
|
|
||||||
# WeChat应用ID
|
|
||||||
WECHAT_APP_ID: Optional[str] = None
|
|
||||||
# WeChat代理服务器
|
|
||||||
WECHAT_PROXY: str = "https://qyapi.weixin.qq.com"
|
|
||||||
# WeChat Token
|
|
||||||
WECHAT_TOKEN: Optional[str] = None
|
|
||||||
# WeChat EncodingAESKey
|
|
||||||
WECHAT_ENCODING_AESKEY: Optional[str] = None
|
|
||||||
# WeChat 管理员
|
|
||||||
WECHAT_ADMINS: Optional[str] = None
|
|
||||||
# Telegram Bot Token
|
|
||||||
TELEGRAM_TOKEN: Optional[str] = None
|
|
||||||
# Telegram Chat ID
|
|
||||||
TELEGRAM_CHAT_ID: Optional[str] = None
|
|
||||||
# Telegram 用户ID,使用,分隔
|
|
||||||
TELEGRAM_USERS: str = ""
|
|
||||||
# Telegram 管理员ID,使用,分隔
|
|
||||||
TELEGRAM_ADMINS: str = ""
|
|
||||||
# Slack Bot User OAuth Token
|
|
||||||
SLACK_OAUTH_TOKEN: str = ""
|
|
||||||
# Slack App-Level Token
|
|
||||||
SLACK_APP_TOKEN: str = ""
|
|
||||||
# Slack 频道名称
|
|
||||||
SLACK_CHANNEL: str = ""
|
|
||||||
# SynologyChat Webhook
|
|
||||||
SYNOLOGYCHAT_WEBHOOK: str = ""
|
|
||||||
# SynologyChat Token
|
|
||||||
SYNOLOGYCHAT_TOKEN: str = ""
|
|
||||||
# VoceChat地址
|
|
||||||
VOCECHAT_HOST: str = ""
|
|
||||||
# VoceChat ApiKey
|
|
||||||
VOCECHAT_API_KEY: str = ""
|
|
||||||
# VoceChat 频道ID
|
|
||||||
VOCECHAT_CHANNEL_ID: str = ""
|
|
||||||
# 下载器 qbittorrent/transmission,启用多个下载器时使用,分隔,只有第一个会被默认使用
|
|
||||||
DOWNLOADER: str = "qbittorrent"
|
|
||||||
# 下载器监控开关
|
|
||||||
DOWNLOADER_MONITOR: bool = True
|
|
||||||
# Qbittorrent地址,IP:PORT
|
|
||||||
QB_HOST: Optional[str] = None
|
|
||||||
# Qbittorrent用户名
|
|
||||||
QB_USER: Optional[str] = None
|
|
||||||
# Qbittorrent密码
|
|
||||||
QB_PASSWORD: Optional[str] = None
|
|
||||||
# Qbittorrent分类自动管理
|
|
||||||
QB_CATEGORY: bool = False
|
|
||||||
# Qbittorrent按顺序下载
|
|
||||||
QB_SEQUENTIAL: bool = True
|
|
||||||
# Qbittorrent忽略队列限制,强制继续
|
|
||||||
QB_FORCE_RESUME: bool = False
|
|
||||||
# Transmission地址,IP:PORT
|
|
||||||
TR_HOST: Optional[str] = None
|
|
||||||
# Transmission用户名
|
|
||||||
TR_USER: Optional[str] = None
|
|
||||||
# Transmission密码
|
|
||||||
TR_PASSWORD: Optional[str] = None
|
|
||||||
# 种子标签
|
# 种子标签
|
||||||
TORRENT_TAG: str = "MOVIEPILOT"
|
TORRENT_TAG: str = "MOVIEPILOT"
|
||||||
# 下载站点字幕
|
# 下载站点字幕
|
||||||
DOWNLOAD_SUBTITLE: bool = True
|
DOWNLOAD_SUBTITLE: bool = True
|
||||||
# 媒体服务器 emby/jellyfin/plex,多个媒体服务器,分割
|
# 交互搜索自动下载用户ID,使用,分割
|
||||||
MEDIASERVER: str = "emby"
|
AUTO_DOWNLOAD_USER: Optional[str] = None
|
||||||
# 媒体服务器同步间隔(小时)
|
|
||||||
MEDIASERVER_SYNC_INTERVAL: Optional[int] = 6
|
|
||||||
# 媒体服务器同步黑名单,多个媒体库名称,分割
|
|
||||||
MEDIASERVER_SYNC_BLACKLIST: Optional[str] = None
|
|
||||||
# EMBY服务器地址,IP:PORT
|
|
||||||
EMBY_HOST: Optional[str] = None
|
|
||||||
# EMBY外网地址,http(s)://DOMAIN:PORT,未设置时使用EMBY_HOST
|
|
||||||
EMBY_PLAY_HOST: Optional[str] = None
|
|
||||||
# EMBY Api Key
|
|
||||||
EMBY_API_KEY: Optional[str] = None
|
|
||||||
# Jellyfin服务器地址,IP:PORT
|
|
||||||
JELLYFIN_HOST: Optional[str] = None
|
|
||||||
# Jellyfin外网地址,http(s)://DOMAIN:PORT,未设置时使用JELLYFIN_HOST
|
|
||||||
JELLYFIN_PLAY_HOST: Optional[str] = None
|
|
||||||
# Jellyfin Api Key
|
|
||||||
JELLYFIN_API_KEY: Optional[str] = None
|
|
||||||
# Plex服务器地址,IP:PORT
|
|
||||||
PLEX_HOST: Optional[str] = None
|
|
||||||
# Plex外网地址,http(s)://DOMAIN:PORT,未设置时使用PLEX_HOST
|
|
||||||
PLEX_PLAY_HOST: Optional[str] = None
|
|
||||||
# Plex Token
|
|
||||||
PLEX_TOKEN: Optional[str] = None
|
|
||||||
# 转移方式 link/copy/move/softlink
|
|
||||||
TRANSFER_TYPE: str = "copy"
|
|
||||||
# 是否同盘优先
|
|
||||||
TRANSFER_SAME_DISK: bool = True
|
|
||||||
# CookieCloud是否启动本地服务
|
# CookieCloud是否启动本地服务
|
||||||
COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False
|
COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False
|
||||||
# CookieCloud服务器地址
|
# CookieCloud服务器地址
|
||||||
@@ -201,12 +169,8 @@ class Settings(BaseSettings):
|
|||||||
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
||||||
# CookieCloud同步黑名单,多个域名,分割
|
# CookieCloud同步黑名单,多个域名,分割
|
||||||
COOKIECLOUD_BLACKLIST: Optional[str] = None
|
COOKIECLOUD_BLACKLIST: Optional[str] = None
|
||||||
# OCR服务器地址
|
|
||||||
OCR_HOST: str = "https://movie-pilot.org"
|
|
||||||
# CookieCloud对应的浏览器UA
|
# CookieCloud对应的浏览器UA
|
||||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
||||||
# 电视剧动漫的分类genre_ids
|
|
||||||
ANIME_GENREIDS = [16]
|
|
||||||
# 电影重命名格式
|
# 电影重命名格式
|
||||||
MOVIE_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \
|
MOVIE_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \
|
||||||
"/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}" \
|
"/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}" \
|
||||||
@@ -216,66 +180,218 @@ class Settings(BaseSettings):
|
|||||||
"/Season {{season}}" \
|
"/Season {{season}}" \
|
||||||
"/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}" \
|
"/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}" \
|
||||||
"{{fileExt}}"
|
"{{fileExt}}"
|
||||||
# 转移时覆盖模式
|
# OCR服务器地址
|
||||||
OVERWRITE_MODE: str = "size"
|
OCR_HOST: str = "https://movie-pilot.org"
|
||||||
# 大内存模式
|
# 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目
|
||||||
BIG_MEMORY_MODE: bool = False
|
MP_SERVER_HOST: str = "https://movie-pilot.org"
|
||||||
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
||||||
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"
|
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"
|
||||||
|
# 插件安装数据共享
|
||||||
|
PLUGIN_STATISTIC_SHARE: bool = True
|
||||||
|
# 是否开启插件热加载
|
||||||
|
PLUGIN_AUTO_RELOAD: bool = False
|
||||||
# Github token,提高请求api限流阈值 ghp_****
|
# Github token,提高请求api限流阈值 ghp_****
|
||||||
GITHUB_TOKEN: Optional[str] = None
|
GITHUB_TOKEN: Optional[str] = None
|
||||||
# Github代理服务器,格式:https://mirror.ghproxy.com/
|
# Github代理服务器,格式:https://mirror.ghproxy.com/
|
||||||
GITHUB_PROXY: Optional[str] = ''
|
GITHUB_PROXY: Optional[str] = ''
|
||||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
# pip镜像站点,格式:https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
AUTO_UPDATE_RESOURCE: bool = True
|
PIP_PROXY: Optional[str] = ''
|
||||||
# 元数据识别缓存过期时间(小时)
|
# 指定的仓库Github token,多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****
|
||||||
META_CACHE_EXPIRE: int = 0
|
REPO_GITHUB_TOKEN: Optional[str] = None
|
||||||
# 是否启用DOH解析域名
|
# 大内存模式
|
||||||
DOH_ENABLE: bool = True
|
BIG_MEMORY_MODE: bool = False
|
||||||
# 搜索多个名称
|
# 全局图片缓存,将媒体图片缓存到本地
|
||||||
SEARCH_MULTIPLE_NAME: bool = False
|
GLOBAL_IMAGE_CACHE: bool = False
|
||||||
# 订阅数据共享
|
# 允许的图片缓存域名
|
||||||
SUBSCRIBE_STATISTIC_SHARE: bool = True
|
SECURITY_IMAGE_DOMAINS: List[str] = ["image.tmdb.org", "static-mdb.v.geilijiasu.com", "doubanio.com", "lain.bgm.tv",
|
||||||
# 插件安装数据共享
|
"raw.githubusercontent.com", "github.com"]
|
||||||
PLUGIN_STATISTIC_SHARE: bool = True
|
# 允许的图片文件后缀格式
|
||||||
# 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目
|
SECURITY_IMAGE_SUFFIXES: List[str] = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"]
|
||||||
MP_SERVER_HOST: str = "https://movie-pilot.org"
|
|
||||||
|
|
||||||
# 【已弃用】刮削入库的媒体文件
|
|
||||||
SCRAP_METADATA: bool = True
|
|
||||||
# 【已弃用】下载保存目录,容器内映射路径需要一致
|
|
||||||
DOWNLOAD_PATH: Optional[str] = None
|
|
||||||
# 【已弃用】电影下载保存目录,容器内映射路径需要一致
|
|
||||||
DOWNLOAD_MOVIE_PATH: Optional[str] = None
|
|
||||||
# 【已弃用】电视剧下载保存目录,容器内映射路径需要一致
|
|
||||||
DOWNLOAD_TV_PATH: Optional[str] = None
|
|
||||||
# 【已弃用】动漫下载保存目录,容器内映射路径需要一致
|
|
||||||
DOWNLOAD_ANIME_PATH: Optional[str] = None
|
|
||||||
# 【已弃用】下载目录二级分类
|
|
||||||
DOWNLOAD_CATEGORY: bool = False
|
|
||||||
# 【已弃用】媒体库目录,多个目录使用,分隔
|
|
||||||
LIBRARY_PATH: Optional[str] = None
|
|
||||||
# 【已弃用】电影媒体库目录名
|
|
||||||
LIBRARY_MOVIE_NAME: str = "电影"
|
|
||||||
# 【已弃用】电视剧媒体库目录名
|
|
||||||
LIBRARY_TV_NAME: str = "电视剧"
|
|
||||||
# 【已弃用】动漫媒体库目录名,不设置时使用电视剧目录
|
|
||||||
LIBRARY_ANIME_NAME: Optional[str] = None
|
|
||||||
# 【已弃用】二级分类
|
|
||||||
LIBRARY_CATEGORY: bool = True
|
|
||||||
|
|
||||||
@validator("SUBSCRIBE_RSS_INTERVAL",
|
class Settings(BaseSettings, ConfigModel):
|
||||||
"COOKIECLOUD_INTERVAL",
|
"""
|
||||||
"MEDIASERVER_SYNC_INTERVAL",
|
系统配置类
|
||||||
"META_CACHE_EXPIRE",
|
"""
|
||||||
pre=True, always=True)
|
|
||||||
def convert_int(cls, value):
|
class Config:
|
||||||
if not value:
|
case_sensitive = True
|
||||||
return 0
|
env_file = SystemUtils.get_env_path()
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
# 初始化配置目录及子目录
|
||||||
|
for path in [self.CONFIG_PATH, self.TEMP_PATH, self.LOG_PATH, self.COOKIE_PATH]:
|
||||||
|
if not path.exists():
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
# 如果是二进制程序,确保配置文件存在
|
||||||
|
if SystemUtils.is_frozen():
|
||||||
|
app_env_path = self.CONFIG_PATH / "app.env"
|
||||||
|
if not app_env_path.exists():
|
||||||
|
SystemUtils.copy(self.INNER_CONFIG_PATH / "app.env", app_env_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_api_token(value: Any, original_value: Any) -> Tuple[Any, bool]:
|
||||||
|
"""
|
||||||
|
校验 API_TOKEN
|
||||||
|
"""
|
||||||
|
if isinstance(value, (list, dict, set)):
|
||||||
|
value = copy.deepcopy(value)
|
||||||
|
value = value.strip() if isinstance(value, str) else None
|
||||||
|
if not value or len(value) < 16:
|
||||||
|
new_token = secrets.token_urlsafe(16)
|
||||||
|
if not value:
|
||||||
|
logger.info(f"'API_TOKEN' 未设置,已随机生成新的【API_TOKEN】{new_token}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"'API_TOKEN' 长度不足 16 个字符,存在安全隐患,已随机生成新的【API_TOKEN】{new_token}")
|
||||||
|
return new_token, True
|
||||||
|
return value, str(value) != str(original_value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generic_type_converter(value: Any, original_value: Any, expected_type: Type, default: Any, field_name: str,
|
||||||
|
raise_exception: bool = False) -> Tuple[Any, bool]:
|
||||||
|
"""
|
||||||
|
通用类型转换函数,根据预期类型转换值。如果转换失败,返回默认值
|
||||||
|
"""
|
||||||
|
if isinstance(value, (list, dict, set)):
|
||||||
|
value = copy.deepcopy(value)
|
||||||
|
# 如果 value 是 None,仍需要检查与 original_value 是否不一致
|
||||||
|
if value is None:
|
||||||
|
return default, str(value) != str(original_value)
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return int(value)
|
if expected_type is bool:
|
||||||
except (ValueError, TypeError):
|
if isinstance(value, bool):
|
||||||
raise ValueError(f"{value} 格式错误,不是有效数字!")
|
return value, str(value).lower() != str(original_value).lower()
|
||||||
|
if isinstance(value, str):
|
||||||
|
value_clean = value.lower()
|
||||||
|
bool_map = {
|
||||||
|
"false": False, "no": False, "0": False, "off": False,
|
||||||
|
"true": True, "yes": True, "1": True, "on": True
|
||||||
|
}
|
||||||
|
if value_clean in bool_map:
|
||||||
|
converted = bool_map[value_clean]
|
||||||
|
return converted, str(converted).lower() != str(original_value).lower()
|
||||||
|
elif isinstance(value, (int, float)):
|
||||||
|
converted = bool(value)
|
||||||
|
return converted, str(converted).lower() != str(original_value).lower()
|
||||||
|
return default, True
|
||||||
|
elif expected_type is int:
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value, str(value) != str(original_value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
converted = int(value)
|
||||||
|
return converted, str(converted) != str(original_value)
|
||||||
|
elif expected_type is float:
|
||||||
|
if isinstance(value, float):
|
||||||
|
return value, str(value) != str(original_value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
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(",")
|
||||||
|
# 可根据需要添加更多类型处理
|
||||||
|
else:
|
||||||
|
return value, str(value) != str(original_value)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
if raise_exception:
|
||||||
|
raise ValueError(f"配置项 '{field_name}' 的值 '{value}' 无法转换成正确的类型") from e
|
||||||
|
logger.error(
|
||||||
|
f"配置项 '{field_name}' 的值 '{value}' 无法转换成正确的类型,使用默认值 '{default}',错误信息: {e}")
|
||||||
|
return default, True
|
||||||
|
|
||||||
|
@validator('*', pre=True, always=True)
|
||||||
|
def generic_type_validator(cls, value: Any, field):
|
||||||
|
"""
|
||||||
|
通用校验器,尝试将配置值转换为期望的类型
|
||||||
|
"""
|
||||||
|
if field.name == "API_TOKEN":
|
||||||
|
converted_value, needs_update = cls.validate_api_token(value, value)
|
||||||
|
else:
|
||||||
|
converted_value, needs_update = cls.generic_type_converter(value, value, field.type_, field.default,
|
||||||
|
field.name)
|
||||||
|
if needs_update:
|
||||||
|
cls.update_env_config(field, value, converted_value)
|
||||||
|
return converted_value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_env_config(field: Any, original_value: Any, converted_value: Any) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
更新 env 配置
|
||||||
|
"""
|
||||||
|
message = None
|
||||||
|
is_converted = original_value is not None and str(original_value) != str(converted_value)
|
||||||
|
if is_converted:
|
||||||
|
message = f"配置项 '{field.name}' 的值 '{original_value}' 无效,已替换为 '{converted_value}'"
|
||||||
|
logger.warning(message)
|
||||||
|
|
||||||
|
if field.name in os.environ:
|
||||||
|
if is_converted:
|
||||||
|
message = f"配置项 '{field.name}' 已在环境变量中设置,请手动更新以保持一致性"
|
||||||
|
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 "")
|
||||||
|
if is_converted:
|
||||||
|
logger.info(f"配置项 '{field.name}' 已自动修正并写入到 'app.env' 文件")
|
||||||
|
return True, message
|
||||||
|
|
||||||
|
def update_setting(self, key: str, value: Any) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
更新单个配置项
|
||||||
|
"""
|
||||||
|
if not hasattr(self, key):
|
||||||
|
return False, f"配置项 '{key}' 不存在"
|
||||||
|
|
||||||
|
try:
|
||||||
|
field = self.__fields__[key]
|
||||||
|
original_value = getattr(self, key)
|
||||||
|
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 进行更新
|
||||||
|
if needs_update or str(value) != str(converted_value):
|
||||||
|
success, message = self.update_env_config(field, original_value, converted_value)
|
||||||
|
# 仅成功更新配置时,才更新内存
|
||||||
|
if success:
|
||||||
|
setattr(self, key, converted_value)
|
||||||
|
return success, message
|
||||||
|
return True, ""
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def update_settings(self, env: Dict[str, Any]) -> Dict[str, Tuple[bool, str]]:
|
||||||
|
"""
|
||||||
|
更新多个配置项
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
for k, v in env.items():
|
||||||
|
results[k] = self.update_setting(k, v)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@property
|
||||||
|
def VERSION_FLAG(self) -> str:
|
||||||
|
"""
|
||||||
|
版本标识,用来区分重大版本,为空则为v1,不允许外部修改
|
||||||
|
"""
|
||||||
|
return "v2"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def INNER_CONFIG_PATH(self):
|
def INNER_CONFIG_PATH(self):
|
||||||
@@ -295,6 +411,10 @@ class Settings(BaseSettings):
|
|||||||
def TEMP_PATH(self):
|
def TEMP_PATH(self):
|
||||||
return self.CONFIG_PATH / "temp"
|
return self.CONFIG_PATH / "temp"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def CACHE_PATH(self):
|
||||||
|
return self.CONFIG_PATH / "cache"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ROOT_PATH(self):
|
def ROOT_PATH(self):
|
||||||
return Path(__file__).parents[2]
|
return Path(__file__).parents[2]
|
||||||
@@ -358,23 +478,36 @@ class Settings(BaseSettings):
|
|||||||
}
|
}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@property
|
def REPO_GITHUB_HEADERS(self, repo: str = None):
|
||||||
def DEFAULT_DOWNLOADER(self):
|
|
||||||
"""
|
"""
|
||||||
默认下载器
|
Github指定的仓库请求头
|
||||||
|
:param repo: 指定的仓库名称,格式为 "user/repo"。如果为空,或者没有找到指定仓库请求头,则返回默认的请求头信息
|
||||||
|
:return: Github请求头
|
||||||
"""
|
"""
|
||||||
if not self.DOWNLOADER:
|
# 如果没有传入指定的仓库名称,或没有配置指定的仓库Token,则返回默认的请求头信息
|
||||||
return None
|
if not repo or not self.REPO_GITHUB_TOKEN:
|
||||||
return next((d for d in settings.DOWNLOADER.split(",") if d), None)
|
return self.GITHUB_HEADERS
|
||||||
|
headers = {}
|
||||||
@property
|
# 格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****
|
||||||
def DOWNLOADERS(self):
|
token_pairs = self.REPO_GITHUB_TOKEN.split(",")
|
||||||
"""
|
for token_pair in token_pairs:
|
||||||
下载器列表
|
try:
|
||||||
"""
|
parts = token_pair.split(":")
|
||||||
if not self.DOWNLOADER:
|
if len(parts) != 2:
|
||||||
return []
|
print(f"无效的令牌格式: {token_pair}")
|
||||||
return [d for d in settings.DOWNLOADER.split(",") if d]
|
continue
|
||||||
|
repo_info = parts[0].strip()
|
||||||
|
token = parts[1].strip()
|
||||||
|
if not repo_info or not token:
|
||||||
|
print(f"无效的令牌或仓库信息: {token_pair}")
|
||||||
|
continue
|
||||||
|
headers[repo_info] = {
|
||||||
|
"Authorization": f"Bearer {token}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"处理令牌对 '{token_pair}' 时出错: {e}")
|
||||||
|
# 如果传入了指定的仓库名称,则返回该仓库的请求头信息,否则返回默认请求头
|
||||||
|
return headers.get(repo, self.GITHUB_HEADERS)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def VAPID(self):
|
def VAPID(self):
|
||||||
@@ -387,33 +520,7 @@ class Settings(BaseSettings):
|
|||||||
def MP_DOMAIN(self, url: str = None):
|
def MP_DOMAIN(self, url: str = None):
|
||||||
if not self.APP_DOMAIN:
|
if not self.APP_DOMAIN:
|
||||||
return None
|
return None
|
||||||
domain = self.APP_DOMAIN.rstrip("/")
|
return UrlUtils.combine_url(host=self.APP_DOMAIN, path=url)
|
||||||
if not domain.startswith("http"):
|
|
||||||
domain = "http://" + domain
|
|
||||||
if not url:
|
|
||||||
return domain
|
|
||||||
return domain + "/" + url.lstrip("/")
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
with self.CONFIG_PATH as p:
|
|
||||||
if not p.exists():
|
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
|
||||||
if SystemUtils.is_frozen():
|
|
||||||
if not (p / "app.env").exists():
|
|
||||||
SystemUtils.copy(self.INNER_CONFIG_PATH / "app.env", p / "app.env")
|
|
||||||
with self.TEMP_PATH as p:
|
|
||||||
if not p.exists():
|
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
|
||||||
with self.LOG_PATH as p:
|
|
||||||
if not p.exists():
|
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
|
||||||
with self.COOKIE_PATH as p:
|
|
||||||
if not p.exists():
|
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
case_sensitive = True
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalVar(object):
|
class GlobalVar(object):
|
||||||
@@ -431,6 +538,7 @@ class GlobalVar(object):
|
|||||||
"""
|
"""
|
||||||
self.STOP_EVENT.set()
|
self.STOP_EVENT.set()
|
||||||
|
|
||||||
|
@property
|
||||||
def is_system_stopped(self):
|
def is_system_stopped(self):
|
||||||
"""
|
"""
|
||||||
是否停止
|
是否停止
|
||||||
@@ -451,10 +559,7 @@ class GlobalVar(object):
|
|||||||
|
|
||||||
|
|
||||||
# 实例化配置
|
# 实例化配置
|
||||||
settings = Settings(
|
settings = Settings()
|
||||||
_env_file=Settings().CONFIG_PATH / "app.env",
|
|
||||||
_env_file_encoding="utf-8"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 全局标识
|
# 全局标识
|
||||||
global_vars = GlobalVar()
|
global_vars = GlobalVar()
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ class MediaInfo:
|
|||||||
title: str = None
|
title: str = None
|
||||||
# 英文标题
|
# 英文标题
|
||||||
en_title: str = None
|
en_title: str = None
|
||||||
|
# 香港标题
|
||||||
|
hk_title: str = None
|
||||||
|
# 台湾标题
|
||||||
|
tw_title: str = None
|
||||||
# 新加坡标题
|
# 新加坡标题
|
||||||
sg_title: str = None
|
sg_title: str = None
|
||||||
# 年份
|
# 年份
|
||||||
@@ -347,10 +351,10 @@ class MediaInfo:
|
|||||||
return [], []
|
return [], []
|
||||||
directors = []
|
directors = []
|
||||||
actors = []
|
actors = []
|
||||||
for cast in _credits.get("cast"):
|
for cast in _credits.get("cast") or []:
|
||||||
if cast.get("known_for_department") == "Acting":
|
if cast.get("known_for_department") == "Acting":
|
||||||
actors.append(cast)
|
actors.append(cast)
|
||||||
for crew in _credits.get("crew"):
|
for crew in _credits.get("crew") or []:
|
||||||
if crew.get("job") in ["Director", "Writer", "Editor", "Producer"]:
|
if crew.get("job") in ["Director", "Writer", "Editor", "Producer"]:
|
||||||
directors.append(crew)
|
directors.append(crew)
|
||||||
return directors, actors
|
return directors, actors
|
||||||
@@ -386,6 +390,10 @@ class MediaInfo:
|
|||||||
self.original_language = info.get('original_language')
|
self.original_language = info.get('original_language')
|
||||||
# 英文标题
|
# 英文标题
|
||||||
self.en_title = info.get('en_title')
|
self.en_title = info.get('en_title')
|
||||||
|
# 香港标题
|
||||||
|
self.hk_title = info.get('hk_title')
|
||||||
|
# 台湾标题
|
||||||
|
self.tw_title = info.get('tw_title')
|
||||||
# 新加坡标题
|
# 新加坡标题
|
||||||
self.sg_title = info.get('sg_title')
|
self.sg_title = info.get('sg_title')
|
||||||
if self.type == MediaType.MOVIE:
|
if self.type == MediaType.MOVIE:
|
||||||
|
|||||||
@@ -1,123 +1,523 @@
|
|||||||
from queue import Queue, Empty
|
import copy
|
||||||
from typing import Dict, Any
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import random
|
||||||
|
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 app.helper.message import MessageHelper
|
||||||
|
from app.helper.thread import ThreadHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
from app.schemas.event import ChainEventData
|
||||||
|
from app.schemas.types import ChainEventType, EventType
|
||||||
|
from app.utils.limit import ExponentialBackoffRateLimiter
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.schemas.types import EventType
|
|
||||||
|
DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级
|
||||||
|
MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数
|
||||||
|
INITIAL_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 1 # 事件队列空闲时的初始超时时间(秒)
|
||||||
|
MAX_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 5 # 事件队列空闲时的最大超时时间(秒)
|
||||||
|
|
||||||
|
|
||||||
|
class Event:
|
||||||
|
"""
|
||||||
|
事件类,封装事件的基本信息
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, event_type: Union[EventType, ChainEventType],
|
||||||
|
event_data: Optional[Union[Dict, ChainEventData]] = None,
|
||||||
|
priority: int = DEFAULT_EVENT_PRIORITY):
|
||||||
|
"""
|
||||||
|
:param event_type: 事件的类型,支持 EventType 或 ChainEventType
|
||||||
|
:param event_data: 可选,事件携带的数据,默认为空字典
|
||||||
|
:param priority: 可选,事件的优先级,默认为 10
|
||||||
|
"""
|
||||||
|
self.event_id = str(uuid.uuid4()) # 事件ID
|
||||||
|
self.event_type = event_type # 事件类型
|
||||||
|
self.event_data = event_data or {} # 事件数据
|
||||||
|
self.priority = priority # 事件优先级
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""
|
||||||
|
重写 __repr__ 方法,用于返回事件的详细信息,包括事件类型、事件ID和优先级
|
||||||
|
"""
|
||||||
|
event_kind = Event.get_event_kind(self.event_type)
|
||||||
|
return f"<{event_kind}: {self.event_type.value}, ID: {self.event_id}, Priority: {self.priority}>"
|
||||||
|
|
||||||
|
def __lt__(self, other):
|
||||||
|
"""
|
||||||
|
定义事件对象的比较规则,基于优先级比较
|
||||||
|
优先级小的事件会被认为“更小”,优先级高的事件将被认为“更大”
|
||||||
|
"""
|
||||||
|
return self.priority < other.priority
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_event_kind(event_type: Union[EventType, ChainEventType]) -> str:
|
||||||
|
"""
|
||||||
|
根据事件类型判断事件是广播事件还是链式事件
|
||||||
|
:param event_type: 事件类型,支持 EventType 或 ChainEventType
|
||||||
|
:return: 返回 Broadcast Event 或 Chain Event
|
||||||
|
"""
|
||||||
|
return "Broadcast Event" if isinstance(event_type, EventType) else "Chain Event"
|
||||||
|
|
||||||
|
|
||||||
class EventManager(metaclass=Singleton):
|
class EventManager(metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
事件管理器
|
EventManager 负责管理和调度广播事件和链式事件,包括订阅、发送和处理事件
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 退出事件
|
||||||
|
__event = threading.Event()
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# 事件队列
|
self.__messagehelper = MessageHelper()
|
||||||
self._eventQueue = Queue()
|
self.__executor = ThreadHelper() # 动态线程池,用于消费事件
|
||||||
# 事件响应函数字典
|
self.__consumer_threads = [] # 用于保存启动的事件消费者线程
|
||||||
self._handlers: Dict[str, Dict[str, Any]] = {}
|
self.__event_queue = PriorityQueue() # 优先级队列
|
||||||
# 已禁用的事件响应
|
self.__broadcast_subscribers: Dict[EventType, Dict[str, Callable]] = {} # 广播事件的订阅者
|
||||||
self._disabled_handlers = []
|
self.__chain_subscribers: Dict[ChainEventType, Dict[str, tuple[int, Callable]]] = {} # 链式事件的订阅者
|
||||||
|
self.__disabled_handlers = set() # 禁用的事件处理器集合
|
||||||
|
self.__disabled_classes = set() # 禁用的事件处理器类集合
|
||||||
|
self.__lock = threading.Lock() # 线程锁
|
||||||
|
|
||||||
def get_event(self):
|
def start(self):
|
||||||
"""
|
"""
|
||||||
获取事件
|
开始广播事件处理线程
|
||||||
"""
|
"""
|
||||||
|
# 启动消费者线程用于处理广播事件
|
||||||
|
self.__event.set()
|
||||||
|
for _ in range(MIN_EVENT_CONSUMER_THREADS):
|
||||||
|
thread = threading.Thread(target=self.__broadcast_consumer_loop, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
self.__consumer_threads.append(thread) # 将线程对象保存到列表中
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""
|
||||||
|
停止广播事件处理线程
|
||||||
|
"""
|
||||||
|
logger.info("正在停止事件处理...")
|
||||||
|
self.__event.clear() # 停止广播事件处理
|
||||||
try:
|
try:
|
||||||
event = self._eventQueue.get(block=True, timeout=1)
|
# 通过遍历保存的线程来等待它们完成
|
||||||
handlers = self._handlers.get(event.event_type) or {}
|
for consumer_thread in self.__consumer_threads:
|
||||||
if handlers:
|
consumer_thread.join()
|
||||||
# 去除掉被禁用的事件响应
|
logger.info("事件处理停止完成")
|
||||||
handlerList = [handler for handler in handlers.values()
|
except Exception as e:
|
||||||
if handler.__qualname__.split(".")[0] not in self._disabled_handlers]
|
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
|
||||||
return event, handlerList
|
|
||||||
return event, []
|
|
||||||
except Empty:
|
|
||||||
return None, []
|
|
||||||
|
|
||||||
def check(self, etype: EventType):
|
def check(self, etype: Union[EventType, ChainEventType]) -> bool:
|
||||||
"""
|
"""
|
||||||
检查事件是否存在响应,去除掉被禁用的事件响应
|
检查是否有启用的事件处理器可以响应某个事件类型
|
||||||
|
:param etype: 事件类型 (EventType 或 ChainEventType)
|
||||||
|
:return: 返回是否存在可用的处理器
|
||||||
"""
|
"""
|
||||||
if etype.value not in self._handlers:
|
if isinstance(etype, ChainEventType):
|
||||||
return False
|
handlers = self.__chain_subscribers.get(etype, {})
|
||||||
handlers = self._handlers.get(etype.value)
|
return any(
|
||||||
return any([handler for handler in handlers.values()
|
self.__is_handler_enabled(handler)
|
||||||
if handler.__qualname__.split(".")[0] not in self._disabled_handlers])
|
for _, handler in handlers.values()
|
||||||
|
)
|
||||||
def add_event_listener(self, etype: EventType, handler: type):
|
|
||||||
"""
|
|
||||||
注册事件处理
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
handlers = self._handlers[etype.value]
|
|
||||||
except KeyError:
|
|
||||||
handlers = {}
|
|
||||||
self._handlers[etype.value] = handlers
|
|
||||||
if handler.__qualname__ in handlers:
|
|
||||||
handlers.pop(handler.__qualname__)
|
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Event Registed:{etype.value} - {handler.__qualname__}")
|
handlers = self.__broadcast_subscribers.get(etype, {})
|
||||||
handlers[handler.__qualname__] = handler
|
return any(
|
||||||
|
self.__is_handler_enabled(handler)
|
||||||
|
for handler in handlers.values()
|
||||||
|
)
|
||||||
|
|
||||||
def disable_events_hander(self, class_name: str):
|
def send_event(self, etype: Union[EventType, ChainEventType], data: Optional[Union[Dict, ChainEventData]] = None,
|
||||||
|
priority: int = DEFAULT_EVENT_PRIORITY) -> Optional[Event]:
|
||||||
"""
|
"""
|
||||||
标记对应类事件处理为不可用
|
发送事件,根据事件类型决定是广播事件还是链式事件
|
||||||
|
:param etype: 事件类型 (EventType 或 ChainEventType)
|
||||||
|
:param data: 可选,事件数据
|
||||||
|
:param priority: 广播事件的优先级,默认为 10
|
||||||
|
:return: 如果是链式事件,返回处理后的事件数据;否则返回 None
|
||||||
"""
|
"""
|
||||||
if class_name not in self._disabled_handlers:
|
event = Event(etype, data, priority)
|
||||||
self._disabled_handlers.append(class_name)
|
if isinstance(etype, EventType):
|
||||||
logger.debug(f"Event Disabled:{class_name}")
|
self.__trigger_broadcast_event(event)
|
||||||
|
elif isinstance(etype, ChainEventType):
|
||||||
|
return self.__trigger_chain_event(event)
|
||||||
|
else:
|
||||||
|
logger.error(f"Unknown event type: {etype}")
|
||||||
|
|
||||||
def enable_events_hander(self, class_name: str):
|
def add_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable,
|
||||||
|
priority: int = DEFAULT_EVENT_PRIORITY):
|
||||||
"""
|
"""
|
||||||
标记对应类事件处理为可用
|
注册事件处理器,将处理器添加到对应的事件订阅列表中
|
||||||
|
:param event_type: 事件类型 (EventType 或 ChainEventType)
|
||||||
|
:param handler: 处理器
|
||||||
|
:param priority: 可选,链式事件的优先级,默认为 10;广播事件不需要优先级
|
||||||
"""
|
"""
|
||||||
if class_name in self._disabled_handlers:
|
with self.__lock:
|
||||||
self._disabled_handlers.remove(class_name)
|
handler_identifier = self.__get_handler_identifier(handler)
|
||||||
logger.debug(f"Event Enabled:{class_name}")
|
|
||||||
|
|
||||||
def send_event(self, etype: EventType, data: dict = None):
|
if isinstance(event_type, ChainEventType):
|
||||||
"""
|
# 链式事件,按优先级排序
|
||||||
发送事件
|
if event_type not in self.__chain_subscribers:
|
||||||
"""
|
self.__chain_subscribers[event_type] = {}
|
||||||
if etype not in EventType:
|
handlers = self.__chain_subscribers[event_type]
|
||||||
return
|
if handler_identifier in handlers:
|
||||||
event = Event(etype.value)
|
handlers.pop(handler_identifier)
|
||||||
event.event_data = data or {}
|
else:
|
||||||
logger.debug(f"发送事件:{etype.value} - {event.event_data}")
|
logger.debug(
|
||||||
self._eventQueue.put(event)
|
f"Subscribed to chain event: {event_type.value}, "
|
||||||
|
f"Priority: {priority} - {handler_identifier}")
|
||||||
def register(self, etype: [EventType, list]):
|
handlers[handler_identifier] = (priority, handler)
|
||||||
"""
|
# 根据优先级排序
|
||||||
事件注册
|
self.__chain_subscribers[event_type] = dict(
|
||||||
:param etype: 事件类型
|
sorted(self.__chain_subscribers[event_type].items(), key=lambda x: x[1][0])
|
||||||
"""
|
)
|
||||||
|
|
||||||
def decorator(f):
|
|
||||||
if isinstance(etype, list):
|
|
||||||
for et in etype:
|
|
||||||
self.add_event_listener(et, f)
|
|
||||||
elif type(etype) == type(EventType):
|
|
||||||
for et in etype.__members__.values():
|
|
||||||
self.add_event_listener(et, f)
|
|
||||||
else:
|
else:
|
||||||
self.add_event_listener(etype, f)
|
# 广播事件
|
||||||
|
if event_type not in self.__broadcast_subscribers:
|
||||||
|
self.__broadcast_subscribers[event_type] = {}
|
||||||
|
handlers = self.__broadcast_subscribers[event_type]
|
||||||
|
if handler_identifier in handlers:
|
||||||
|
handlers.pop(handler_identifier)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Subscribed to broadcast event: {event_type.value} - {handler_identifier}")
|
||||||
|
handlers[handler_identifier] = handler
|
||||||
|
|
||||||
|
def remove_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable):
|
||||||
|
"""
|
||||||
|
移除事件处理器,将处理器从对应事件的订阅列表中删除
|
||||||
|
:param event_type: 事件类型 (EventType 或 ChainEventType)
|
||||||
|
:param handler: 要移除的处理器
|
||||||
|
"""
|
||||||
|
with self.__lock:
|
||||||
|
handler_identifier = self.__get_handler_identifier(handler)
|
||||||
|
|
||||||
|
if isinstance(event_type, ChainEventType) and event_type in self.__chain_subscribers:
|
||||||
|
self.__chain_subscribers[event_type].pop(handler_identifier, None)
|
||||||
|
logger.debug(f"Unsubscribed from chain event: {event_type.value} - {handler_identifier}")
|
||||||
|
elif event_type in self.__broadcast_subscribers:
|
||||||
|
self.__broadcast_subscribers[event_type].pop(handler_identifier, None)
|
||||||
|
logger.debug(f"Unsubscribed from broadcast event: {event_type.value} - {handler_identifier}")
|
||||||
|
|
||||||
|
def disable_event_handler(self, target: Union[Callable, type]):
|
||||||
|
"""
|
||||||
|
禁用指定的事件处理器或事件处理器类
|
||||||
|
:param target: 处理器函数或类
|
||||||
|
"""
|
||||||
|
identifier = self.__get_handler_identifier(target)
|
||||||
|
if identifier in self.__disabled_handlers or identifier in self.__disabled_classes:
|
||||||
|
return
|
||||||
|
if isinstance(target, type):
|
||||||
|
self.__disabled_classes.add(identifier)
|
||||||
|
logger.debug(f"Disabled event handler class - {identifier}")
|
||||||
|
else:
|
||||||
|
self.__disabled_handlers.add(identifier)
|
||||||
|
logger.debug(f"Disabled event handler - {identifier}")
|
||||||
|
|
||||||
|
def enable_event_handler(self, target: Union[Callable, type]):
|
||||||
|
"""
|
||||||
|
启用指定的事件处理器或事件处理器类
|
||||||
|
:param target: 处理器函数或类
|
||||||
|
"""
|
||||||
|
identifier = self.__get_handler_identifier(target)
|
||||||
|
if isinstance(target, type):
|
||||||
|
self.__disabled_classes.discard(identifier)
|
||||||
|
logger.debug(f"Enabled event handler class - {identifier}")
|
||||||
|
else:
|
||||||
|
self.__disabled_handlers.discard(identifier)
|
||||||
|
logger.debug(f"Enabled event handler - {identifier}")
|
||||||
|
|
||||||
|
def visualize_handlers(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
可视化所有事件处理器,包括是否被禁用的状态
|
||||||
|
:return: 处理器列表,包含事件类型、处理器标识符、优先级(如果有)和状态
|
||||||
|
"""
|
||||||
|
handler_info = []
|
||||||
|
# 统一处理广播事件和链式事件
|
||||||
|
for event_type, subscribers in {**self.__broadcast_subscribers, **self.__chain_subscribers}.items():
|
||||||
|
for handler_data in subscribers:
|
||||||
|
if isinstance(subscribers, dict):
|
||||||
|
priority, handler = handler_data
|
||||||
|
else:
|
||||||
|
priority = None
|
||||||
|
handler = handler_data
|
||||||
|
# 获取处理器的唯一标识符
|
||||||
|
handler_id = self.__get_handler_identifier(handler)
|
||||||
|
# 检查处理器的启用状态
|
||||||
|
status = "enabled" if self.__is_handler_enabled(handler) else "disabled"
|
||||||
|
# 构建处理器信息字典
|
||||||
|
handler_dict = {
|
||||||
|
"event_type": event_type.value,
|
||||||
|
"handler_identifier": handler_id,
|
||||||
|
"status": status
|
||||||
|
}
|
||||||
|
if priority is not None:
|
||||||
|
handler_dict["priority"] = priority
|
||||||
|
handler_info.append(handler_dict)
|
||||||
|
return handler_info
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@lru_cache(maxsize=1000)
|
||||||
|
def __get_handler_identifier(cls, target: Union[Callable, type]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取处理器或处理器类的唯一标识符,包括模块名和类名/方法名
|
||||||
|
:param target: 处理器函数或类
|
||||||
|
:return: 唯一标识符
|
||||||
|
"""
|
||||||
|
# 统一使用 inspect.getmodule 来获取模块名
|
||||||
|
module = inspect.getmodule(target)
|
||||||
|
module_name = module.__name__ if module else "unknown_module"
|
||||||
|
|
||||||
|
# 使用 __qualname__ 获取目标的限定名
|
||||||
|
qualname = target.__qualname__
|
||||||
|
return f"{module_name}.{qualname}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@lru_cache(maxsize=1000)
|
||||||
|
def __get_class_from_callable(cls, handler: Callable) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取可调用对象所属类的唯一标识符
|
||||||
|
:param handler: 可调用对象(函数、方法等)
|
||||||
|
:return: 类的唯一标识符
|
||||||
|
"""
|
||||||
|
# 对于绑定方法,通过 __self__.__class__ 获取类
|
||||||
|
if inspect.ismethod(handler) and hasattr(handler, "__self__"):
|
||||||
|
return cls.__get_handler_identifier(handler.__self__.__class__)
|
||||||
|
|
||||||
|
# 对于类实例(实现了 __call__ 方法)
|
||||||
|
if not inspect.isfunction(handler) and hasattr(handler, "__call__"):
|
||||||
|
handler_cls = handler.__class__
|
||||||
|
return cls.__get_handler_identifier(handler_cls)
|
||||||
|
|
||||||
|
# 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息
|
||||||
|
qualname_parts = handler.__qualname__.split(".")
|
||||||
|
if len(qualname_parts) > 1:
|
||||||
|
class_name = ".".join(qualname_parts[:-1])
|
||||||
|
module = inspect.getmodule(handler)
|
||||||
|
module_name = module.__name__ if module else "unknown_module"
|
||||||
|
return f"{module_name}.{class_name}"
|
||||||
|
|
||||||
|
def __is_handler_enabled(self, handler: Callable) -> bool:
|
||||||
|
"""
|
||||||
|
检查处理器是否已启用(没有被禁用)
|
||||||
|
:param handler: 处理器函数
|
||||||
|
:return: 如果处理器启用则返回 True,否则返回 False
|
||||||
|
"""
|
||||||
|
# 获取处理器的唯一标识符
|
||||||
|
handler_id = self.__get_handler_identifier(handler)
|
||||||
|
|
||||||
|
# 获取处理器所属类的唯一标识符
|
||||||
|
class_id = self.__get_class_from_callable(handler)
|
||||||
|
|
||||||
|
# 检查处理器或类是否被禁用,只要其中之一被禁用则返回 False
|
||||||
|
if handler_id in self.__disabled_handlers or (class_id is not None and class_id in self.__disabled_classes):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __trigger_chain_event(self, event: Event) -> Optional[Event]:
|
||||||
|
"""
|
||||||
|
触发链式事件,按顺序调用订阅的处理器,并记录处理耗时
|
||||||
|
"""
|
||||||
|
logger.debug(f"Triggering synchronous chain event: {event}")
|
||||||
|
dispatch = self.__dispatch_chain_event(event)
|
||||||
|
return event if dispatch else None
|
||||||
|
|
||||||
|
def __trigger_broadcast_event(self, event: Event):
|
||||||
|
"""
|
||||||
|
触发广播事件,将事件插入到优先级队列中
|
||||||
|
:param event: 要处理的事件对象
|
||||||
|
"""
|
||||||
|
logger.debug(f"Triggering broadcast event: {event}")
|
||||||
|
self.__event_queue.put((event.priority, event))
|
||||||
|
|
||||||
|
def __dispatch_chain_event(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
|
||||||
|
self.__log_event_lifecycle(event, "Started")
|
||||||
|
for handler_id, (priority, handler) in handlers.items():
|
||||||
|
start_time = time.time()
|
||||||
|
self.__safe_invoke_handler(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):
|
||||||
|
"""
|
||||||
|
异步方式调度广播事件,通过线程池逐个调用事件处理器
|
||||||
|
:param event: 要调度的事件对象
|
||||||
|
"""
|
||||||
|
handlers = self.__broadcast_subscribers.get(event.event_type, {})
|
||||||
|
if not handlers:
|
||||||
|
logger.debug(f"No handlers found for broadcast event: {event}")
|
||||||
|
return
|
||||||
|
for handler_id, handler in handlers.items():
|
||||||
|
self.__executor.submit(self.__safe_invoke_handler, handler, event)
|
||||||
|
|
||||||
|
def __safe_invoke_handler(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
|
||||||
|
|
||||||
|
# 根据事件类型判断是否需要深复制
|
||||||
|
is_broadcast_event = isinstance(event.event_type, EventType)
|
||||||
|
event_to_process = copy.deepcopy(event) if is_broadcast_event else event
|
||||||
|
|
||||||
|
names = handler.__qualname__.split(".")
|
||||||
|
class_name, method_name = names[0], names[1]
|
||||||
|
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
except Exception as e:
|
||||||
|
self.__handle_event_error(event, handler, e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_class_instance(class_name: str):
|
||||||
|
"""
|
||||||
|
根据类名获取类实例,首先检查全局变量中是否存在该类,如果不存在则尝试动态导入模块。
|
||||||
|
:param class_name: 类的名称
|
||||||
|
:return: 类的实例
|
||||||
|
"""
|
||||||
|
# 检查类是否在全局变量中
|
||||||
|
if class_name in globals():
|
||||||
|
try:
|
||||||
|
class_obj = globals()[class_name]()
|
||||||
|
return class_obj
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"事件处理出错:创建全局类实例出错:{str(e)} - {traceback.format_exc()}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 如果类不在全局变量中,尝试动态导入模块并创建实例
|
||||||
|
try:
|
||||||
|
# 导入模块,除了插件,只有chain能响应事件
|
||||||
|
if not class_name.endswith("Chain"):
|
||||||
|
logger.debug(f"事件处理出错:无效的 Chain 类名: {class_name},类名必须以 'Chain' 结尾")
|
||||||
|
return None
|
||||||
|
module_name = f"app.chain.{class_name[:-5].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()}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __broadcast_consumer_loop(self):
|
||||||
|
"""
|
||||||
|
持续从队列中提取事件的后台广播消费者线程
|
||||||
|
"""
|
||||||
|
jitter_factor = 0.1
|
||||||
|
rate_limiter = ExponentialBackoffRateLimiter(base_wait=INITIAL_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS,
|
||||||
|
max_wait=MAX_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS,
|
||||||
|
backoff_factor=2.0,
|
||||||
|
source="BroadcastConsumer",
|
||||||
|
enable_logging=False)
|
||||||
|
while self.__event.is_set():
|
||||||
|
try:
|
||||||
|
priority, event = self.__event_queue.get(timeout=rate_limiter.current_wait)
|
||||||
|
rate_limiter.reset()
|
||||||
|
self.__dispatch_broadcast_event(event)
|
||||||
|
except Empty:
|
||||||
|
rate_limiter.current_wait = rate_limiter.current_wait * random.uniform(1, 1 + jitter_factor)
|
||||||
|
rate_limiter.trigger_limit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __log_event_lifecycle(event: Event, stage: str):
|
||||||
|
"""
|
||||||
|
记录事件的生命周期日志
|
||||||
|
"""
|
||||||
|
logger.debug(f"{stage} - {event}")
|
||||||
|
|
||||||
|
def __handle_event_error(self, event: Event, handler: Callable, e: Exception):
|
||||||
|
"""
|
||||||
|
全局错误处理器,用于处理事件处理中的异常
|
||||||
|
"""
|
||||||
|
logger.error(f"事件处理出错:{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")
|
||||||
|
self.send_event(
|
||||||
|
EventType.SystemError,
|
||||||
|
{
|
||||||
|
"type": "event",
|
||||||
|
"event_type": event.event_type,
|
||||||
|
"event_handle": f"{class_name}.{method_name}",
|
||||||
|
"error": str(e),
|
||||||
|
"traceback": traceback.format_exc()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def register(self, etype: Union[EventType, ChainEventType, List[Union[EventType, ChainEventType]], type]):
|
||||||
|
"""
|
||||||
|
事件注册装饰器,用于将函数注册为事件的处理器
|
||||||
|
:param etype:
|
||||||
|
- 单个事件类型成员 (如 EventType.MetadataScrape, ChainEventType.PluginAction)
|
||||||
|
- 事件类型类 (EventType, ChainEventType)
|
||||||
|
- 或事件类型成员的列表
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(f: Callable):
|
||||||
|
# 将输入的事件类型统一转换为列表格式
|
||||||
|
if isinstance(etype, list):
|
||||||
|
event_list = etype # 传入的已经是列表,直接使用
|
||||||
|
else:
|
||||||
|
event_list = [etype] # 不是列表则包裹成单一元素的列表
|
||||||
|
|
||||||
|
# 遍历列表,处理每个事件类型
|
||||||
|
for event in event_list:
|
||||||
|
if isinstance(event, (EventType, ChainEventType)):
|
||||||
|
self.add_event_listener(event, f)
|
||||||
|
elif isinstance(event, type) and issubclass(event, (EventType, ChainEventType)):
|
||||||
|
# 如果是 EventType 或 ChainEventType 类,提取该类中的所有成员
|
||||||
|
for et in event.__members__.values():
|
||||||
|
self.add_event_listener(et, f)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"无效的事件类型: {event}")
|
||||||
|
|
||||||
return f
|
return f
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
class Event(object):
|
# 全局实例定义
|
||||||
"""
|
|
||||||
事件对象
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, event_type=None):
|
|
||||||
# 事件类型
|
|
||||||
self.event_type = event_type
|
|
||||||
# 字典用于保存具体的事件数据
|
|
||||||
self.event_data = {}
|
|
||||||
|
|
||||||
|
|
||||||
# 实例引用,用于注册事件
|
|
||||||
eventmanager = EventManager()
|
eventmanager = EventManager()
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ class MetaAnime(MetaBase):
|
|||||||
_, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)
|
_, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)
|
||||||
if self.cn_name:
|
if self.cn_name:
|
||||||
self.cn_name = re.sub(r'%s' % self._name_nostring_re, '', self.cn_name, flags=re.IGNORECASE).strip()
|
self.cn_name = re.sub(r'%s' % self._name_nostring_re, '', self.cn_name, flags=re.IGNORECASE).strip()
|
||||||
self.cn_name = zhconv.convert(self.cn_name, "zh-hans")
|
|
||||||
if self.en_name:
|
if self.en_name:
|
||||||
self.en_name = re.sub(r'%s' % self._name_nostring_re, '', self.en_name, flags=re.IGNORECASE).strip().title()
|
self.en_name = re.sub(r'%s' % self._name_nostring_re, '', self.en_name, flags=re.IGNORECASE).strip().title()
|
||||||
self._name = StringUtils.str_title(self.en_name)
|
self._name = StringUtils.str_title(self.en_name)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class MetaVideo(MetaBase):
|
|||||||
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
|
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
|
||||||
_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}$)"
|
_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})$"
|
_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$"
|
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$|^REPACK$"
|
||||||
_effect_re = r"^REMUX$|^UHD$|^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
|
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$"
|
||||||
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
|
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
|
||||||
_name_no_begin_re = r"^[\[【].+?[\]】]"
|
_name_no_begin_re = r"^[\[【].+?[\]】]"
|
||||||
_name_no_chinese_re = r".*版|.*字幕"
|
_name_no_chinese_re = r".*版|.*字幕"
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
|
|||||||
"ultrahd": [],
|
"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 )'],
|
'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )'],
|
||||||
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', '(?:Lilith|NC)-Raws', '织梦字幕组']
|
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', 'SweetSub', 'MingY',
|
||||||
|
'(?:Lilith|NC)-Raws', '织梦字幕组', '枫叶字幕组', '猎户手抄部', '喵萌奶茶屋', '漫猫字幕社',
|
||||||
|
'霜庭云花Sub', '北宇治字幕组', '氢气烤肉架', '云歌字幕组', '萌樱字幕组', '极影字幕社',
|
||||||
|
'悠哈璃羽字幕社',
|
||||||
|
'❀拨雪寻春❀', '沸羊羊(?:制作|字幕组)', '(?:桜|樱)都字幕组']
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class WordsMatcher(metaclass=Singleton):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.systemconfig = SystemConfigOper()
|
self.systemconfig = SystemConfigOper()
|
||||||
|
|
||||||
def prepare(self, title: str) -> Tuple[str, List[str]]:
|
def prepare(self, title: str, custom_words: List[str] = None) -> Tuple[str, List[str]]:
|
||||||
"""
|
"""
|
||||||
预处理标题,支持三种格式
|
预处理标题,支持三种格式
|
||||||
1:屏蔽词
|
1:屏蔽词
|
||||||
@@ -23,7 +23,7 @@ class WordsMatcher(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
appley_words = []
|
appley_words = []
|
||||||
# 读取自定义识别词
|
# 读取自定义识别词
|
||||||
words: List[str] = self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []
|
words: List[str] = custom_words or self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []
|
||||||
for word in words:
|
for word in words:
|
||||||
if not word or word.startswith("#"):
|
if not word or word.startswith("#"):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Tuple
|
from typing import Tuple, List
|
||||||
|
|
||||||
import regex as re
|
import regex as re
|
||||||
|
|
||||||
@@ -10,17 +10,18 @@ from app.log import logger
|
|||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
def MetaInfo(title: str, subtitle: str = None, custom_words: List[str] = None) -> MetaBase:
|
||||||
"""
|
"""
|
||||||
根据标题和副标题识别元数据
|
根据标题和副标题识别元数据
|
||||||
:param title: 标题、种子名、文件名
|
:param title: 标题、种子名、文件名
|
||||||
:param subtitle: 副标题、描述
|
:param subtitle: 副标题、描述
|
||||||
|
:param custom_words: 自定义识别词列表
|
||||||
:return: MetaAnime、MetaVideo
|
:return: MetaAnime、MetaVideo
|
||||||
"""
|
"""
|
||||||
# 原标题
|
# 原标题
|
||||||
org_title = title
|
org_title = title
|
||||||
# 预处理标题
|
# 预处理标题
|
||||||
title, apply_words = WordsMatcher().prepare(title)
|
title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words)
|
||||||
# 获取标题中媒体信息
|
# 获取标题中媒体信息
|
||||||
title, metainfo = find_metainfo(title)
|
title, metainfo = find_metainfo(title)
|
||||||
# 判断是否处理文件
|
# 判断是否处理文件
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import traceback
|
|||||||
from typing import Generator, Optional, Tuple, Any
|
from typing import Generator, Optional, Tuple, Any
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.event import eventmanager
|
||||||
from app.helper.module import ModuleHelper
|
from app.helper.module import ModuleHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
from app.schemas.types import EventType, ModuleType
|
||||||
from app.utils.object import ObjectUtils
|
from app.utils.object import ObjectUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ class ModuleManager(metaclass=Singleton):
|
|||||||
logger.info(f"Moudle Stoped:{module_id}")
|
logger.info(f"Moudle Stoped:{module_id}")
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"Stop Moudle Error:{module_id},{str(err)} - {traceback.format_exc()}", exc_info=True)
|
logger.error(f"Stop Moudle Error:{module_id},{str(err)} - {traceback.format_exc()}", exc_info=True)
|
||||||
logger.info("模块停止完成")
|
logger.info("所有模块停止完成")
|
||||||
|
|
||||||
def reload(self):
|
def reload(self):
|
||||||
"""
|
"""
|
||||||
@@ -67,17 +69,21 @@ class ModuleManager(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
self.stop()
|
self.stop()
|
||||||
self.load_modules()
|
self.load_modules()
|
||||||
|
eventmanager.send_event(etype=EventType.ModuleReload, data={})
|
||||||
|
|
||||||
def test(self, modleid: str) -> Tuple[bool, str]:
|
def test(self, modleid: str) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
测试模块
|
测试模块
|
||||||
"""
|
"""
|
||||||
if modleid not in self._running_modules:
|
if modleid not in self._running_modules:
|
||||||
return False, "模块未加载,请检查参数设置"
|
return False, ""
|
||||||
module = self._running_modules[modleid]
|
module = self._running_modules[modleid]
|
||||||
if hasattr(module, "test") \
|
if hasattr(module, "test") \
|
||||||
and ObjectUtils.check_method(getattr(module, "test")):
|
and ObjectUtils.check_method(getattr(module, "test")):
|
||||||
return module.test()
|
result = module.test()
|
||||||
|
if not result:
|
||||||
|
return False, ""
|
||||||
|
return result
|
||||||
return True, "模块不支持测试"
|
return True, "模块不支持测试"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -118,6 +124,17 @@ class ModuleManager(metaclass=Singleton):
|
|||||||
and ObjectUtils.check_method(getattr(module, method)):
|
and ObjectUtils.check_method(getattr(module, method)):
|
||||||
yield module
|
yield module
|
||||||
|
|
||||||
|
def get_running_type_modules(self, module_type: ModuleType) -> Generator:
|
||||||
|
"""
|
||||||
|
获取指定类型的模块列表
|
||||||
|
"""
|
||||||
|
if not self._running_modules:
|
||||||
|
return []
|
||||||
|
for _, module in self._running_modules.items():
|
||||||
|
if hasattr(module, 'get_type') \
|
||||||
|
and module.get_type() == module_type:
|
||||||
|
yield module
|
||||||
|
|
||||||
def get_module(self, module_id: str) -> Any:
|
def get_module(self, module_id: str) -> Any:
|
||||||
"""
|
"""
|
||||||
根据模块id获取模块
|
根据模块id获取模块
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import concurrent
|
import concurrent
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
import importlib.util
|
||||||
import inspect
|
import inspect
|
||||||
import threading
|
import os
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
|
||||||
|
|
||||||
from watchdog.events import FileSystemEventHandler
|
from watchdog.events import FileSystemEventHandler
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
@@ -19,7 +21,9 @@ from app.helper.module import ModuleHelper
|
|||||||
from app.helper.plugin import PluginHelper
|
from app.helper.plugin import PluginHelper
|
||||||
from app.helper.sites import SitesHelper
|
from app.helper.sites import SitesHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import EventType, SystemConfigKey
|
||||||
|
from app.utils.crypto import RSAUtils
|
||||||
|
from app.utils.limit import rate_limit_window
|
||||||
from app.utils.object import ObjectUtils
|
from app.utils.object import ObjectUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
@@ -27,14 +31,6 @@ from app.utils.system import SystemUtils
|
|||||||
|
|
||||||
|
|
||||||
class PluginMonitorHandler(FileSystemEventHandler):
|
class PluginMonitorHandler(FileSystemEventHandler):
|
||||||
# 计时器
|
|
||||||
__reload_timer = None
|
|
||||||
# 防抖时间间隔
|
|
||||||
__debounce_interval = 0.5
|
|
||||||
# 最近一次修改时间
|
|
||||||
__last_modified = 0
|
|
||||||
# 修改间隔
|
|
||||||
__timeout = 2
|
|
||||||
|
|
||||||
def on_modified(self, event):
|
def on_modified(self, event):
|
||||||
"""
|
"""
|
||||||
@@ -47,10 +43,6 @@ class PluginMonitorHandler(FileSystemEventHandler):
|
|||||||
if not event_path.name.endswith(".py") or "pycache" in event_path.parts:
|
if not event_path.name.endswith(".py") or "pycache" in event_path.parts:
|
||||||
return
|
return
|
||||||
|
|
||||||
current_time = time.time()
|
|
||||||
if current_time - self.__last_modified < self.__timeout:
|
|
||||||
return
|
|
||||||
self.__last_modified = current_time
|
|
||||||
# 读取插件根目录下的__init__.py文件,读取class XXXX(_PluginBase)的类名
|
# 读取插件根目录下的__init__.py文件,读取class XXXX(_PluginBase)的类名
|
||||||
try:
|
try:
|
||||||
plugins_root = settings.ROOT_PATH / "app" / "plugins"
|
plugins_root = settings.ROOT_PATH / "app" / "plugins"
|
||||||
@@ -72,15 +64,12 @@ class PluginMonitorHandler(FileSystemEventHandler):
|
|||||||
if line.startswith("class") and "(_PluginBase)" in line:
|
if line.startswith("class") and "(_PluginBase)" in line:
|
||||||
pid = line.split("class ")[1].split("(_PluginBase)")[0].strip()
|
pid = line.split("class ")[1].split("(_PluginBase)")[0].strip()
|
||||||
if pid:
|
if pid:
|
||||||
# 防抖处理,通过计时器延迟加载
|
self.__reload_plugin(pid)
|
||||||
if self.__reload_timer:
|
|
||||||
self.__reload_timer.cancel()
|
|
||||||
self.__reload_timer = threading.Timer(self.__debounce_interval, self.__reload_plugin, [pid])
|
|
||||||
self.__reload_timer.start()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"插件文件修改后重载出错:{str(e)}")
|
logger.error(f"插件文件修改后重载出错:{str(e)}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@rate_limit_window(max_calls=1, window_seconds=2, source="PluginMonitor", enable_logging=False)
|
||||||
def __reload_plugin(pid):
|
def __reload_plugin(pid):
|
||||||
"""
|
"""
|
||||||
重新加载插件
|
重新加载插件
|
||||||
@@ -158,17 +147,18 @@ class PluginManager(metaclass=Singleton):
|
|||||||
if pid and plugin_id != pid:
|
if pid and plugin_id != pid:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
# 如果插件具有认证级别且当前认证级别不足,则不进行实例化
|
# 判断插件是否满足认证要求,如不满足则不进行实例化
|
||||||
if hasattr(plugin, "auth_level"):
|
if not self.__set_and_check_auth_level(plugin=plugin):
|
||||||
plugin.auth_level = plugin.auth_level
|
# 如果是插件热更新实例,这里则进行替换
|
||||||
if self.siteshelper.auth_level < plugin.auth_level:
|
if plugin_id in self._plugins:
|
||||||
continue
|
self._plugins[plugin_id] = plugin
|
||||||
|
continue
|
||||||
# 存储Class
|
# 存储Class
|
||||||
self._plugins[plugin_id] = plugin
|
self._plugins[plugin_id] = plugin
|
||||||
# 未安装的不加载
|
# 未安装的不加载
|
||||||
if plugin_id not in installed_plugins:
|
if plugin_id not in installed_plugins:
|
||||||
# 设置事件状态为不可用
|
# 设置事件状态为不可用
|
||||||
eventmanager.disable_events_hander(plugin_id)
|
eventmanager.disable_event_handler(plugin)
|
||||||
continue
|
continue
|
||||||
# 生成实例
|
# 生成实例
|
||||||
plugin_obj = plugin()
|
plugin_obj = plugin()
|
||||||
@@ -179,9 +169,9 @@ class PluginManager(metaclass=Singleton):
|
|||||||
logger.info(f"加载插件:{plugin_id} 版本:{plugin_obj.plugin_version}")
|
logger.info(f"加载插件:{plugin_id} 版本:{plugin_obj.plugin_version}")
|
||||||
# 启用的插件才设置事件注册状态可用
|
# 启用的插件才设置事件注册状态可用
|
||||||
if plugin_obj.get_state():
|
if plugin_obj.get_state():
|
||||||
eventmanager.enable_events_hander(plugin_id)
|
eventmanager.enable_event_handler(plugin)
|
||||||
else:
|
else:
|
||||||
eventmanager.disable_events_hander(plugin_id)
|
eventmanager.disable_event_handler(plugin)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
|
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
|
||||||
|
|
||||||
@@ -191,15 +181,18 @@ class PluginManager(metaclass=Singleton):
|
|||||||
:param plugin_id: 插件ID
|
:param plugin_id: 插件ID
|
||||||
:param conf: 插件配置
|
:param conf: 插件配置
|
||||||
"""
|
"""
|
||||||
if not self._running_plugins.get(plugin_id):
|
plugin = self._running_plugins.get(plugin_id)
|
||||||
|
if not plugin:
|
||||||
return
|
return
|
||||||
self._running_plugins[plugin_id].init_plugin(conf)
|
# 初始化插件
|
||||||
if self._running_plugins[plugin_id].get_state():
|
plugin.init_plugin(conf)
|
||||||
# 设置启用的插件事件注册状态可用
|
# 检查插件状态并启用/禁用事件处理器
|
||||||
eventmanager.enable_events_hander(plugin_id)
|
if plugin.get_state():
|
||||||
|
# 启用插件类的事件处理器
|
||||||
|
eventmanager.enable_event_handler(type(plugin))
|
||||||
else:
|
else:
|
||||||
# 设置事件状态为不可用
|
# 禁用插件类的事件处理器
|
||||||
eventmanager.disable_events_hander(plugin_id)
|
eventmanager.disable_event_handler(type(plugin))
|
||||||
|
|
||||||
def stop(self, pid: str = None):
|
def stop(self, pid: str = None):
|
||||||
"""
|
"""
|
||||||
@@ -214,14 +207,13 @@ class PluginManager(metaclass=Singleton):
|
|||||||
for plugin_id, plugin in self._running_plugins.items():
|
for plugin_id, plugin in self._running_plugins.items():
|
||||||
if pid and plugin_id != pid:
|
if pid and plugin_id != pid:
|
||||||
continue
|
continue
|
||||||
|
eventmanager.disable_event_handler(type(plugin))
|
||||||
self.__stop_plugin(plugin)
|
self.__stop_plugin(plugin)
|
||||||
# 清空对像
|
# 清空对像
|
||||||
if pid:
|
if pid:
|
||||||
# 清空指定插件
|
# 清空指定插件
|
||||||
if pid in self._running_plugins:
|
if pid in self._running_plugins:
|
||||||
self._running_plugins.pop(pid)
|
self._running_plugins.pop(pid)
|
||||||
if pid in self._plugins:
|
|
||||||
self._plugins.pop(pid)
|
|
||||||
else:
|
else:
|
||||||
# 清空
|
# 清空
|
||||||
self._plugins = {}
|
self._plugins = {}
|
||||||
@@ -278,35 +270,86 @@ class PluginManager(metaclass=Singleton):
|
|||||||
self.stop(plugin_id)
|
self.stop(plugin_id)
|
||||||
# 重新加载
|
# 重新加载
|
||||||
self.start(plugin_id)
|
self.start(plugin_id)
|
||||||
|
# 广播事件
|
||||||
|
eventmanager.send_event(EventType.PluginReload, data={"plugin_id": plugin_id})
|
||||||
|
|
||||||
def install_online_plugin(self):
|
def sync(self) -> List[str]:
|
||||||
"""
|
"""
|
||||||
安装本地不存在的在线插件
|
安装本地不存在的在线插件
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def install_plugin(plugin):
|
||||||
|
start_time = time.time()
|
||||||
|
state, msg = self.pluginhelper.install(pid=plugin.id, repo_url=plugin.repo_url, force_install=True)
|
||||||
|
elapsed_time = time.time() - start_time
|
||||||
|
if state:
|
||||||
|
logger.info(
|
||||||
|
f"插件 {plugin.plugin_name} 安装成功,版本:{plugin.plugin_version},耗时:{elapsed_time:.2f} 秒")
|
||||||
|
sync_plugins.append(plugin.id)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"插件 {plugin.plugin_name} v{plugin.plugin_version} 安装失败:{msg},耗时:{elapsed_time:.2f} 秒")
|
||||||
|
failed_plugins.append(plugin.id)
|
||||||
|
|
||||||
if SystemUtils.is_frozen():
|
if SystemUtils.is_frozen():
|
||||||
return
|
return []
|
||||||
logger.info("开始安装第三方插件...")
|
|
||||||
# 已安装插件
|
# 获取已安装插件列表
|
||||||
install_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
install_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||||
# 在线插件
|
# 获取在线插件列表
|
||||||
online_plugins = self.get_online_plugins()
|
online_plugins = self.get_online_plugins()
|
||||||
if not online_plugins:
|
# 确定需要安装的插件
|
||||||
logger.error("未获取到第三方插件")
|
plugins_to_install = [
|
||||||
return
|
plugin for plugin in online_plugins
|
||||||
# 支持更新的插件自动更新
|
if plugin.id in install_plugins and not self.is_plugin_exists(plugin.id)
|
||||||
for plugin in online_plugins:
|
]
|
||||||
# 只处理已安装的插件
|
|
||||||
if plugin.id in install_plugins and not self.is_plugin_exists(plugin.id):
|
if not plugins_to_install:
|
||||||
# 下载安装
|
return []
|
||||||
state, msg = self.pluginhelper.install(pid=plugin.id,
|
logger.info("开始安装第三方插件...")
|
||||||
repo_url=plugin.repo_url)
|
sync_plugins = []
|
||||||
# 安装失败
|
failed_plugins = []
|
||||||
if not state:
|
|
||||||
logger.error(
|
# 使用 ThreadPoolExecutor 进行并发安装
|
||||||
f"插件 {plugin.plugin_name} v{plugin.plugin_version} 安装失败:{msg}")
|
total_start_time = time.time()
|
||||||
continue
|
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
logger.info(f"插件 {plugin.plugin_name} 安装成功,版本:{plugin.plugin_version}")
|
futures = {
|
||||||
logger.info("第三方插件安装完成")
|
executor.submit(install_plugin, plugin): plugin
|
||||||
|
for plugin in plugins_to_install
|
||||||
|
}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
plugin = futures[future]
|
||||||
|
try:
|
||||||
|
future.result()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"插件 {plugin.plugin_name} 安装过程中出现异常: {exc}")
|
||||||
|
|
||||||
|
total_elapsed_time = time.time() - total_start_time
|
||||||
|
logger.info(
|
||||||
|
f"第三方插件安装完成,成功:{len(sync_plugins)} 个,"
|
||||||
|
f"失败:{len(failed_plugins)} 个,总耗时:{total_elapsed_time:.2f} 秒"
|
||||||
|
)
|
||||||
|
return sync_plugins
|
||||||
|
|
||||||
|
def install_plugin_missing_dependencies(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
安装插件中缺失或不兼容的依赖项
|
||||||
|
"""
|
||||||
|
# 第一步:获取需要安装的依赖项列表
|
||||||
|
missing_dependencies = self.pluginhelper.find_missing_dependencies()
|
||||||
|
if not missing_dependencies:
|
||||||
|
return missing_dependencies
|
||||||
|
logger.debug(f"检测到缺失的依赖项: {missing_dependencies}")
|
||||||
|
logger.info(f"开始安装缺失的依赖项,共 {len(missing_dependencies)} 个...")
|
||||||
|
# 第二步:安装依赖项并返回结果
|
||||||
|
total_start_time = time.time()
|
||||||
|
success, message = self.pluginhelper.install_dependencies(missing_dependencies)
|
||||||
|
total_elapsed_time = time.time() - total_start_time
|
||||||
|
if success:
|
||||||
|
logger.info(f"已完成 {len(missing_dependencies)} 个依赖项安装,总耗时:{total_elapsed_time:.2f} 秒")
|
||||||
|
else:
|
||||||
|
logger.warning(f"存在缺失依赖项安装失败,请尝试手动安装,总耗时:{total_elapsed_time:.2f} 秒")
|
||||||
|
return missing_dependencies
|
||||||
|
|
||||||
def get_plugin_config(self, pid: str) -> dict:
|
def get_plugin_config(self, pid: str) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -374,7 +417,7 @@ class PluginManager(metaclass=Singleton):
|
|||||||
return plugin.get_page() or []
|
return plugin.get_page() or []
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_plugin_dashboard(self, pid: str, key: str, **kwargs) -> Optional[schemas.PluginDashboard]:
|
def get_plugin_dashboard(self, pid: str, key: str = None, **kwargs) -> Optional[schemas.PluginDashboard]:
|
||||||
"""
|
"""
|
||||||
获取插件仪表盘
|
获取插件仪表盘
|
||||||
:param pid: 插件ID
|
:param pid: 插件ID
|
||||||
@@ -412,27 +455,42 @@ class PluginManager(metaclass=Singleton):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_plugin_commands(self) -> List[Dict[str, Any]]:
|
def get_plugin_state(self, pid: str) -> bool:
|
||||||
|
"""
|
||||||
|
获取插件状态
|
||||||
|
:param pid: 插件ID
|
||||||
|
"""
|
||||||
|
plugin = self._running_plugins.get(pid)
|
||||||
|
return plugin.get_state() if plugin else False
|
||||||
|
|
||||||
|
def get_plugin_commands(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取插件命令
|
获取插件命令
|
||||||
[{
|
[{
|
||||||
"cmd": "/xx",
|
"cmd": "/xx",
|
||||||
"event": EventType.xx,
|
"event": EventType.xx,
|
||||||
"desc": "xxxx",
|
"desc": "xxxx",
|
||||||
"data": {}
|
"data": {},
|
||||||
|
"pid": "",
|
||||||
}]
|
}]
|
||||||
"""
|
"""
|
||||||
ret_commands = []
|
ret_commands = []
|
||||||
for _, plugin in self._running_plugins.items():
|
for plugin_id, plugin in self._running_plugins.items():
|
||||||
if hasattr(plugin, "get_command") \
|
if pid and pid != plugin_id:
|
||||||
and ObjectUtils.check_method(plugin.get_command):
|
continue
|
||||||
|
if hasattr(plugin, "get_command") and ObjectUtils.check_method(plugin.get_command):
|
||||||
try:
|
try:
|
||||||
ret_commands += plugin.get_command() or []
|
if not plugin.get_state():
|
||||||
|
continue
|
||||||
|
commands = plugin.get_command() or []
|
||||||
|
for command in commands:
|
||||||
|
command["pid"] = plugin_id
|
||||||
|
ret_commands.extend(commands)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取插件命令出错:{str(e)}")
|
logger.error(f"获取插件命令出错:{str(e)}")
|
||||||
return ret_commands
|
return ret_commands
|
||||||
|
|
||||||
def get_plugin_apis(self, plugin_id: str = None) -> List[Dict[str, Any]]:
|
def get_plugin_apis(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取插件API
|
获取插件API
|
||||||
[{
|
[{
|
||||||
@@ -440,25 +498,27 @@ class PluginManager(metaclass=Singleton):
|
|||||||
"endpoint": self.xxx,
|
"endpoint": self.xxx,
|
||||||
"methods": ["GET", "POST"],
|
"methods": ["GET", "POST"],
|
||||||
"summary": "API名称",
|
"summary": "API名称",
|
||||||
"description": "API说明"
|
"description": "API说明",
|
||||||
|
"allow_anonymous": false
|
||||||
}]
|
}]
|
||||||
"""
|
"""
|
||||||
ret_apis = []
|
ret_apis = []
|
||||||
for pid, plugin in self._running_plugins.items():
|
for plugin_id, plugin in self._running_plugins.items():
|
||||||
if plugin_id and pid != plugin_id:
|
if pid and pid != plugin_id:
|
||||||
continue
|
continue
|
||||||
if hasattr(plugin, "get_api") \
|
if hasattr(plugin, "get_api") and ObjectUtils.check_method(plugin.get_api):
|
||||||
and ObjectUtils.check_method(plugin.get_api):
|
|
||||||
try:
|
try:
|
||||||
|
if not plugin.get_state():
|
||||||
|
continue
|
||||||
apis = plugin.get_api() or []
|
apis = plugin.get_api() or []
|
||||||
for api in apis:
|
for api in apis:
|
||||||
api["path"] = f"/{pid}{api['path']}"
|
api["path"] = f"/{plugin_id}{api['path']}"
|
||||||
ret_apis.extend(apis)
|
ret_apis.extend(apis)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取插件 {pid} API出错:{str(e)}")
|
logger.error(f"获取插件 {plugin_id} API出错:{str(e)}")
|
||||||
return ret_apis
|
return ret_apis
|
||||||
|
|
||||||
def get_plugin_services(self) -> List[Dict[str, Any]]:
|
def get_plugin_services(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取插件服务
|
获取插件服务
|
||||||
[{
|
[{
|
||||||
@@ -466,19 +526,21 @@ class PluginManager(metaclass=Singleton):
|
|||||||
"name": "服务名称",
|
"name": "服务名称",
|
||||||
"trigger": "触发器:cron、interval、date、CronTrigger.from_crontab()",
|
"trigger": "触发器:cron、interval、date、CronTrigger.from_crontab()",
|
||||||
"func": self.xxx,
|
"func": self.xxx,
|
||||||
"kwagrs": {} # 定时器参数
|
"kwargs": {} # 定时器参数
|
||||||
}]
|
}]
|
||||||
"""
|
"""
|
||||||
ret_services = []
|
ret_services = []
|
||||||
for pid, plugin in self._running_plugins.items():
|
for plugin_id, plugin in self._running_plugins.items():
|
||||||
if hasattr(plugin, "get_service") \
|
if pid and pid != plugin_id:
|
||||||
and ObjectUtils.check_method(plugin.get_service):
|
continue
|
||||||
|
if hasattr(plugin, "get_service") and ObjectUtils.check_method(plugin.get_service):
|
||||||
try:
|
try:
|
||||||
services = plugin.get_service()
|
if not plugin.get_state():
|
||||||
if services:
|
continue
|
||||||
ret_services.extend(services)
|
services = plugin.get_service() or []
|
||||||
|
ret_services.extend(services)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取插件 {pid} 服务出错:{str(e)}")
|
logger.error(f"获取插件 {plugin_id} 服务出错:{str(e)}")
|
||||||
return ret_services
|
return ret_services
|
||||||
|
|
||||||
def get_plugin_dashboard_meta(self):
|
def get_plugin_dashboard_meta(self):
|
||||||
@@ -555,121 +617,59 @@ class PluginManager(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
获取所有在线插件信息
|
获取所有在线插件信息
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __get_plugin_info(market: str) -> Optional[List[schemas.Plugin]]:
|
|
||||||
"""
|
|
||||||
获取插件信息
|
|
||||||
"""
|
|
||||||
online_plugins = self.pluginhelper.get_plugins(market) or {}
|
|
||||||
if not online_plugins:
|
|
||||||
logger.warn(f"获取插件库失败:{market}")
|
|
||||||
return
|
|
||||||
ret_plugins = []
|
|
||||||
add_time = len(online_plugins)
|
|
||||||
for pid, plugin_info in online_plugins.items():
|
|
||||||
# 运行状插件
|
|
||||||
plugin_obj = self._running_plugins.get(pid)
|
|
||||||
# 非运行态插件
|
|
||||||
plugin_static = self._plugins.get(pid)
|
|
||||||
# 基本属性
|
|
||||||
plugin = schemas.Plugin()
|
|
||||||
# ID
|
|
||||||
plugin.id = pid
|
|
||||||
# 安装状态
|
|
||||||
if pid in installed_apps and plugin_static:
|
|
||||||
plugin.installed = True
|
|
||||||
else:
|
|
||||||
plugin.installed = False
|
|
||||||
# 是否有新版本
|
|
||||||
plugin.has_update = False
|
|
||||||
if plugin_static:
|
|
||||||
installed_version = getattr(plugin_static, "plugin_version")
|
|
||||||
if StringUtils.compare_version(installed_version, plugin_info.get("version")) < 0:
|
|
||||||
# 需要更新
|
|
||||||
plugin.has_update = True
|
|
||||||
# 运行状态
|
|
||||||
if plugin_obj and hasattr(plugin_obj, "get_state"):
|
|
||||||
try:
|
|
||||||
state = plugin_obj.get_state()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
|
|
||||||
state = False
|
|
||||||
plugin.state = state
|
|
||||||
else:
|
|
||||||
plugin.state = False
|
|
||||||
# 是否有详情页面
|
|
||||||
plugin.has_page = False
|
|
||||||
if plugin_obj and hasattr(plugin_obj, "get_page"):
|
|
||||||
if ObjectUtils.check_method(plugin_obj.get_page):
|
|
||||||
plugin.has_page = True
|
|
||||||
# 权限
|
|
||||||
if plugin_info.get("level"):
|
|
||||||
plugin.auth_level = plugin_info.get("level")
|
|
||||||
if self.siteshelper.auth_level < plugin.auth_level:
|
|
||||||
continue
|
|
||||||
# 名称
|
|
||||||
if plugin_info.get("name"):
|
|
||||||
plugin.plugin_name = plugin_info.get("name")
|
|
||||||
# 描述
|
|
||||||
if plugin_info.get("description"):
|
|
||||||
plugin.plugin_desc = plugin_info.get("description")
|
|
||||||
# 版本
|
|
||||||
if plugin_info.get("version"):
|
|
||||||
plugin.plugin_version = plugin_info.get("version")
|
|
||||||
# 图标
|
|
||||||
if plugin_info.get("icon"):
|
|
||||||
plugin.plugin_icon = plugin_info.get("icon")
|
|
||||||
# 标签
|
|
||||||
if plugin_info.get("labels"):
|
|
||||||
plugin.plugin_label = plugin_info.get("labels")
|
|
||||||
# 作者
|
|
||||||
if plugin_info.get("author"):
|
|
||||||
plugin.plugin_author = plugin_info.get("author")
|
|
||||||
# 更新历史
|
|
||||||
if plugin_info.get("history"):
|
|
||||||
plugin.history = plugin_info.get("history")
|
|
||||||
# 仓库链接
|
|
||||||
plugin.repo_url = market
|
|
||||||
# 本地标志
|
|
||||||
plugin.is_local = False
|
|
||||||
# 添加顺序
|
|
||||||
plugin.add_time = add_time
|
|
||||||
# 汇总
|
|
||||||
ret_plugins.append(plugin)
|
|
||||||
add_time -= 1
|
|
||||||
|
|
||||||
return ret_plugins
|
|
||||||
|
|
||||||
if not settings.PLUGIN_MARKET:
|
if not settings.PLUGIN_MARKET:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 返回值
|
# 返回值
|
||||||
all_plugins = []
|
all_plugins = []
|
||||||
# 已安装插件
|
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
|
||||||
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
higher_version_plugins = []
|
||||||
|
# 用于存储 v1 版本插件
|
||||||
|
base_version_plugins = []
|
||||||
|
|
||||||
# 使用多线程获取线上插件
|
# 使用多线程获取线上插件
|
||||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
futures = []
|
futures_to_version = {}
|
||||||
for m in settings.PLUGIN_MARKET.split(","):
|
for m in settings.PLUGIN_MARKET.split(","):
|
||||||
if not m:
|
if not m:
|
||||||
continue
|
continue
|
||||||
futures.append(executor.submit(__get_plugin_info, m))
|
# 提交任务获取 v1 版本插件,存储 future 到 version 的映射
|
||||||
for future in concurrent.futures.as_completed(futures):
|
base_future = executor.submit(self.get_plugins_from_market, m, None)
|
||||||
|
futures_to_version[base_future] = "base_version"
|
||||||
|
|
||||||
|
# 提交任务获取高版本插件(如 v2、v3),存储 future 到 version 的映射
|
||||||
|
if settings.VERSION_FLAG:
|
||||||
|
higher_version_future = executor.submit(self.get_plugins_from_market, m, settings.VERSION_FLAG)
|
||||||
|
futures_to_version[higher_version_future] = "higher_version"
|
||||||
|
|
||||||
|
# 按照完成顺序处理结果
|
||||||
|
for future in concurrent.futures.as_completed(futures_to_version):
|
||||||
plugins = future.result()
|
plugins = future.result()
|
||||||
|
version = futures_to_version[future]
|
||||||
|
|
||||||
if plugins:
|
if plugins:
|
||||||
all_plugins.extend(plugins)
|
if version == "higher_version":
|
||||||
|
higher_version_plugins.extend(plugins) # 收集高版本插件
|
||||||
|
else:
|
||||||
|
base_version_plugins.extend(plugins) # 收集 v1 版本插件
|
||||||
|
|
||||||
|
# 优先处理高版本插件
|
||||||
|
all_plugins.extend(higher_version_plugins)
|
||||||
|
# 将未出现在高版本插件列表中的 v1 插件加入 all_plugins
|
||||||
|
higher_plugin_ids = {f"{p.id}{p.plugin_version}" for p in higher_version_plugins}
|
||||||
|
all_plugins.extend([p for p in base_version_plugins if f"{p.id}{p.plugin_version}" not in higher_plugin_ids])
|
||||||
# 去重
|
# 去重
|
||||||
all_plugins = list({f"{p.id}{p.plugin_version}": p for p in all_plugins}.values())
|
all_plugins = list({f"{p.id}{p.plugin_version}": p for p in all_plugins}.values())
|
||||||
# 所有插件按repo在设置中的顺序排序
|
# 所有插件按 repo 在设置中的顺序排序
|
||||||
all_plugins.sort(
|
all_plugins.sort(
|
||||||
key=lambda x: settings.PLUGIN_MARKET.split(",").index(x.repo_url) if x.repo_url else 0
|
key=lambda x: settings.PLUGIN_MARKET.split(",").index(x.repo_url) if x.repo_url else 0
|
||||||
)
|
)
|
||||||
# 相同ID的插件保留版本号最大版本
|
# 相同 ID 的插件保留版本号最大的版本
|
||||||
max_versions = {}
|
max_versions = {}
|
||||||
for p in all_plugins:
|
for p in all_plugins:
|
||||||
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, max_versions[p.id]) > 0:
|
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, max_versions[p.id]) > 0:
|
||||||
max_versions[p.id] = p.plugin_version
|
max_versions[p.id] = p.plugin_version
|
||||||
result = [p for p in all_plugins if
|
result = [p for p in all_plugins if p.plugin_version == max_versions[p.id]]
|
||||||
p.plugin_version == max_versions[p.id]]
|
|
||||||
logger.info(f"共获取到 {len(result)} 个线上插件")
|
logger.info(f"共获取到 {len(result)} 个线上插件")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -709,11 +709,12 @@ class PluginManager(metaclass=Singleton):
|
|||||||
plugin.has_page = True
|
plugin.has_page = True
|
||||||
else:
|
else:
|
||||||
plugin.has_page = False
|
plugin.has_page = False
|
||||||
|
# 公钥
|
||||||
|
if hasattr(plugin_class, "plugin_public_key"):
|
||||||
|
plugin.plugin_public_key = plugin_class.plugin_public_key
|
||||||
# 权限
|
# 权限
|
||||||
if hasattr(plugin_class, "auth_level"):
|
if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_class):
|
||||||
plugin.auth_level = plugin_class.auth_level
|
continue
|
||||||
if self.siteshelper.auth_level < plugin.auth_level:
|
|
||||||
continue
|
|
||||||
# 名称
|
# 名称
|
||||||
if hasattr(plugin_class, "plugin_name"):
|
if hasattr(plugin_class, "plugin_name"):
|
||||||
plugin.plugin_name = plugin_class.plugin_name
|
plugin.plugin_name = plugin_class.plugin_name
|
||||||
@@ -748,10 +749,170 @@ class PluginManager(metaclass=Singleton):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def is_plugin_exists(pid: str) -> bool:
|
def is_plugin_exists(pid: str) -> bool:
|
||||||
"""
|
"""
|
||||||
判断插件是否在本地文件系统存在
|
判断插件是否在本地包中存在
|
||||||
:param pid: 插件ID
|
:param pid: 插件ID
|
||||||
"""
|
"""
|
||||||
if not pid:
|
if not pid:
|
||||||
return False
|
return False
|
||||||
plugin_dir = settings.ROOT_PATH / "app" / "plugins" / pid.lower()
|
try:
|
||||||
return plugin_dir.exists()
|
# 构建包名
|
||||||
|
package_name = f"app.plugins.{pid.lower()}"
|
||||||
|
# 检查包是否存在
|
||||||
|
spec = importlib.util.find_spec(package_name)
|
||||||
|
package_exists = spec is not None and spec.origin is not None
|
||||||
|
logger.debug(f"{pid} exists: {package_exists}")
|
||||||
|
return package_exists
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"获取插件是否在本地包中存在失败,{e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_plugins_from_market(self, market: str, package_version: str = None) -> Optional[List[schemas.Plugin]]:
|
||||||
|
"""
|
||||||
|
从指定的市场获取插件信息
|
||||||
|
:param market: 市场的 URL 或标识
|
||||||
|
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||||
|
:return: 返回插件的列表,若获取失败返回 []
|
||||||
|
"""
|
||||||
|
if not market:
|
||||||
|
return []
|
||||||
|
# 已安装插件
|
||||||
|
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||||
|
# 获取在线插件
|
||||||
|
online_plugins = self.pluginhelper.get_plugins(market, package_version) or {}
|
||||||
|
if not online_plugins:
|
||||||
|
if not package_version:
|
||||||
|
logger.warning(f"获取插件库失败:{market},请检查 GitHub 网络连接")
|
||||||
|
return []
|
||||||
|
ret_plugins = []
|
||||||
|
add_time = len(online_plugins)
|
||||||
|
for pid, plugin_info in online_plugins.items():
|
||||||
|
# 如 package_version 为空,则需要判断插件是否兼容当前版本
|
||||||
|
if not package_version:
|
||||||
|
if plugin_info.get(settings.VERSION_FLAG) is not True:
|
||||||
|
# 插件当前版本不兼容
|
||||||
|
continue
|
||||||
|
# 运行状插件
|
||||||
|
plugin_obj = self._running_plugins.get(pid)
|
||||||
|
# 非运行态插件
|
||||||
|
plugin_static = self._plugins.get(pid)
|
||||||
|
# 基本属性
|
||||||
|
plugin = schemas.Plugin()
|
||||||
|
# ID
|
||||||
|
plugin.id = pid
|
||||||
|
# 安装状态
|
||||||
|
if pid in installed_apps and plugin_static:
|
||||||
|
plugin.installed = True
|
||||||
|
else:
|
||||||
|
plugin.installed = False
|
||||||
|
# 是否有新版本
|
||||||
|
plugin.has_update = False
|
||||||
|
if plugin_static:
|
||||||
|
installed_version = getattr(plugin_static, "plugin_version")
|
||||||
|
if StringUtils.compare_version(installed_version, plugin_info.get("version")) < 0:
|
||||||
|
# 需要更新
|
||||||
|
plugin.has_update = True
|
||||||
|
# 运行状态
|
||||||
|
if plugin_obj and hasattr(plugin_obj, "get_state"):
|
||||||
|
try:
|
||||||
|
state = plugin_obj.get_state()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
|
||||||
|
state = False
|
||||||
|
plugin.state = state
|
||||||
|
else:
|
||||||
|
plugin.state = False
|
||||||
|
# 是否有详情页面
|
||||||
|
plugin.has_page = False
|
||||||
|
if plugin_obj and hasattr(plugin_obj, "get_page"):
|
||||||
|
if ObjectUtils.check_method(plugin_obj.get_page):
|
||||||
|
plugin.has_page = True
|
||||||
|
# 公钥
|
||||||
|
if plugin_info.get("key"):
|
||||||
|
plugin.plugin_public_key = plugin_info.get("key")
|
||||||
|
# 权限
|
||||||
|
if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_info):
|
||||||
|
continue
|
||||||
|
# 名称
|
||||||
|
if plugin_info.get("name"):
|
||||||
|
plugin.plugin_name = plugin_info.get("name")
|
||||||
|
# 描述
|
||||||
|
if plugin_info.get("description"):
|
||||||
|
plugin.plugin_desc = plugin_info.get("description")
|
||||||
|
# 版本
|
||||||
|
if plugin_info.get("version"):
|
||||||
|
plugin.plugin_version = plugin_info.get("version")
|
||||||
|
# 图标
|
||||||
|
if plugin_info.get("icon"):
|
||||||
|
plugin.plugin_icon = plugin_info.get("icon")
|
||||||
|
# 标签
|
||||||
|
if plugin_info.get("labels"):
|
||||||
|
plugin.plugin_label = plugin_info.get("labels")
|
||||||
|
# 作者
|
||||||
|
if plugin_info.get("author"):
|
||||||
|
plugin.plugin_author = plugin_info.get("author")
|
||||||
|
# 更新历史
|
||||||
|
if plugin_info.get("history"):
|
||||||
|
plugin.history = plugin_info.get("history")
|
||||||
|
# 仓库链接
|
||||||
|
plugin.repo_url = market
|
||||||
|
# 本地标志
|
||||||
|
plugin.is_local = False
|
||||||
|
# 添加顺序
|
||||||
|
plugin.add_time = add_time
|
||||||
|
# 汇总
|
||||||
|
ret_plugins.append(plugin)
|
||||||
|
add_time -= 1
|
||||||
|
|
||||||
|
return ret_plugins
|
||||||
|
|
||||||
|
def __set_and_check_auth_level(self, plugin: Union[schemas.Plugin, Type[Any]],
|
||||||
|
source: Optional[Union[dict, Type[Any]]] = None) -> bool:
|
||||||
|
"""
|
||||||
|
设置并检查插件的认证级别
|
||||||
|
:param plugin: 插件对象或包含 auth_level 属性的对象
|
||||||
|
:param source: 可选的字典对象或类对象,可能包含 "level" 或 "auth_level" 键
|
||||||
|
:return: 如果插件的认证级别有效且当前环境的认证级别满足要求,返回 True,否则返回 False
|
||||||
|
"""
|
||||||
|
# 检查并赋值 source 中的 level 或 auth_level
|
||||||
|
if source:
|
||||||
|
if isinstance(source, dict) and "level" in source:
|
||||||
|
plugin.auth_level = source.get("level")
|
||||||
|
elif hasattr(source, "auth_level"):
|
||||||
|
plugin.auth_level = source.auth_level
|
||||||
|
# 如果 source 为空且 plugin 本身没有 auth_level,直接返回 True
|
||||||
|
elif not hasattr(plugin, "auth_level"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# auth_level 级别说明
|
||||||
|
# 1 - 所有用户可见
|
||||||
|
# 2 - 站点认证用户可见
|
||||||
|
# 3 - 站点&密钥认证可见
|
||||||
|
# 99 - 站点&特殊密钥认证可见
|
||||||
|
# 如果当前站点认证级别大于 1 且插件级别为 99,并存在插件公钥,说明为特殊密钥认证,通过密钥匹配进行认证
|
||||||
|
if self.siteshelper.auth_level > 1 and plugin.auth_level == 99 and hasattr(plugin, "plugin_public_key"):
|
||||||
|
plugin_id = plugin.id if isinstance(plugin, schemas.Plugin) else plugin.__name__
|
||||||
|
public_key = plugin.plugin_public_key
|
||||||
|
if public_key:
|
||||||
|
private_key = PluginManager.__get_plugin_private_key(plugin_id)
|
||||||
|
verify = RSAUtils.verify_rsa_keys(public_key=public_key, private_key=private_key)
|
||||||
|
return verify
|
||||||
|
# 如果当前站点认证级别小于插件级别,则返回 False
|
||||||
|
if self.siteshelper.auth_level < plugin.auth_level:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_plugin_private_key(plugin_id: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
根据插件标识获取对应的私钥
|
||||||
|
:param plugin_id: 插件标识
|
||||||
|
:return: 对应的插件私钥,如果未找到则返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 将插件标识转换为大写并构建环境变量名称
|
||||||
|
env_var_name = f"PLUGIN_{plugin_id.upper()}_PRIVATE_KEY"
|
||||||
|
private_key = os.environ.get(env_var_name)
|
||||||
|
return private_key
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"获取插件 {plugin_id} 的私钥时发生错误:{e}")
|
||||||
|
return None
|
||||||
|
|||||||
@@ -5,55 +5,165 @@ import json
|
|||||||
import os
|
import os
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Union, Optional, Annotated
|
from typing import Any, Union, Annotated, Optional
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
from Crypto.Util.Padding import pad
|
from Crypto.Util.Padding import pad
|
||||||
from fastapi import HTTPException, status, Depends, Header
|
from cryptography.fernet import Fernet
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi import HTTPException, status, Security, Request, Response
|
||||||
|
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader, APIKeyQuery, APIKeyCookie
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
# Token认证
|
# OAuth2PasswordBearer 用于 JWT Token 认证
|
||||||
reusable_oauth2 = OAuth2PasswordBearer(
|
oauth2_scheme = OAuth2PasswordBearer(
|
||||||
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
|
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# RESOURCE TOKEN 通过 Cookie 认证
|
||||||
|
resource_token_cookie = APIKeyCookie(name=settings.PROJECT_NAME, auto_error=False, scheme_name="resource_token_cookie")
|
||||||
|
|
||||||
|
# API TOKEN 通过 QUERY 认证
|
||||||
|
api_token_query = APIKeyQuery(name="token", auto_error=False, scheme_name="api_token_query")
|
||||||
|
|
||||||
|
# API KEY 通过 Header 认证
|
||||||
|
api_key_header = APIKeyHeader(name="X-API-KEY", auto_error=False, scheme_name="api_key_header")
|
||||||
|
|
||||||
|
# API KEY 通过 QUERY 认证
|
||||||
|
api_key_query = APIKeyQuery(name="apikey", auto_error=False, scheme_name="api_key_query")
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(
|
def create_access_token(
|
||||||
userid: Union[str, Any], username: str, super_user: bool = False,
|
userid: Union[str, Any],
|
||||||
expires_delta: timedelta = None, level: int = 1
|
username: str,
|
||||||
|
super_user: bool = False,
|
||||||
|
expires_delta: Optional[timedelta] = None,
|
||||||
|
level: int = 1,
|
||||||
|
purpose: Optional[str] = "authentication"
|
||||||
) -> str:
|
) -> str:
|
||||||
if expires_delta:
|
"""
|
||||||
|
创建 JWT 访问令牌,包含用户 ID、用户名、是否为超级用户以及权限等级
|
||||||
|
:param userid: 用户的唯一标识符,通常是字符串或整数
|
||||||
|
:param username: 用户名,用于标识用户的账户名
|
||||||
|
:param super_user: 是否为超级用户,默认值为 False
|
||||||
|
:param expires_delta: 令牌的有效期时长,如果不提供则根据用途使用默认过期时间
|
||||||
|
:param level: 用户的权限级别,默认为 1
|
||||||
|
:param purpose: 令牌的用途,"authentication" 或 "resource"
|
||||||
|
:return: 编码后的 JWT 令牌字符串
|
||||||
|
:raises ValueError: 如果 expires_delta 为负数
|
||||||
|
"""
|
||||||
|
if purpose == "resource":
|
||||||
|
default_expire = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||||
|
secret_key = settings.RESOURCE_SECRET_KEY
|
||||||
|
else:
|
||||||
|
default_expire = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
secret_key = settings.SECRET_KEY
|
||||||
|
|
||||||
|
if expires_delta is not None:
|
||||||
|
if expires_delta.total_seconds() <= 0:
|
||||||
|
raise ValueError("过期时间必须为正数")
|
||||||
expire = datetime.utcnow() + expires_delta
|
expire = datetime.utcnow() + expires_delta
|
||||||
else:
|
else:
|
||||||
expire = datetime.utcnow() + timedelta(
|
expire = datetime.utcnow() + default_expire
|
||||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
|
||||||
)
|
|
||||||
to_encode = {
|
to_encode = {
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
|
"iat": datetime.utcnow(),
|
||||||
"sub": str(userid),
|
"sub": str(userid),
|
||||||
"username": username,
|
"username": username,
|
||||||
"super_user": super_user,
|
"super_user": super_user,
|
||||||
"level": level
|
"level": level,
|
||||||
|
"purpose": purpose
|
||||||
}
|
}
|
||||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
|
||||||
|
encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM)
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
def verify_token(token: str = Depends(reusable_oauth2)) -> schemas.TokenPayload:
|
def __set_or_refresh_resource_token_cookie(request: Request, response: Response, payload: schemas.TokenPayload):
|
||||||
|
"""
|
||||||
|
设置资源令牌 Cookie
|
||||||
|
:param request: 包含请求相关的上下文数据
|
||||||
|
:param response: 用于在服务器响应时设置 Cookie
|
||||||
|
:param payload: 已通过身份验证的 TokenPayload 对象
|
||||||
|
"""
|
||||||
|
resource_token = request.cookies.get(settings.PROJECT_NAME)
|
||||||
|
|
||||||
|
if resource_token:
|
||||||
|
# 检查令牌剩余时间
|
||||||
|
try:
|
||||||
|
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()
|
||||||
|
# 根据剩余时长提前刷新令牌
|
||||||
|
if remaining_time < timedelta(seconds=(settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3)):
|
||||||
|
raise jwt.ExpiredSignatureError
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
logger.debug(f"Token error occurred. refreshing token")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Unexpected error occurred while decoding token: {e}")
|
||||||
|
else:
|
||||||
|
# 如果令牌有效且没有即将过期,则不需要刷新
|
||||||
|
return
|
||||||
|
|
||||||
|
# 创建新的资源访问令牌
|
||||||
|
resource_token_expires = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||||
|
resource_token = create_access_token(
|
||||||
|
userid=payload.sub,
|
||||||
|
username=payload.username,
|
||||||
|
super_user=payload.super_user,
|
||||||
|
expires_delta=resource_token_expires,
|
||||||
|
level=payload.level,
|
||||||
|
purpose="resource"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 设置会话级别的 HttpOnly Cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key=settings.PROJECT_NAME,
|
||||||
|
value=resource_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=request.url.scheme == "https", # 根据当前请求的协议设置 secure 属性
|
||||||
|
samesite="lax" # 不同浏览器对 "Strict" 的处理可能不同,设置 SameSite 为 "Lax",以平衡安全性和兼容性
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def __verify_token(token: str, purpose: str = "authentication") -> schemas.TokenPayload:
|
||||||
|
"""
|
||||||
|
使用 JWT Token 进行身份认证并解析 Token 的内容
|
||||||
|
:param token: JWT 令牌
|
||||||
|
:param purpose: 期望的令牌用途,默认为 "authentication"
|
||||||
|
:return: 包含用户身份信息的 Token 负载数据
|
||||||
|
:raises HTTPException: 如果令牌无效或用途不匹配
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
if purpose == "resource":
|
||||||
|
secret_key = settings.RESOURCE_SECRET_KEY
|
||||||
|
else:
|
||||||
|
secret_key = settings.SECRET_KEY
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"{purpose} token not found"
|
||||||
|
)
|
||||||
|
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token, settings.SECRET_KEY, algorithms=[ALGORITHM]
|
token, secret_key, algorithms=[ALGORITHM]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
token_payload = schemas.TokenPayload(**payload)
|
||||||
|
|
||||||
|
if token_payload.purpose != purpose:
|
||||||
|
raise jwt.InvalidTokenError("令牌用途不匹配")
|
||||||
|
|
||||||
return schemas.TokenPayload(**payload)
|
return schemas.TokenPayload(**payload)
|
||||||
except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError):
|
except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -62,54 +172,98 @@ def verify_token(token: str = Depends(reusable_oauth2)) -> schemas.TokenPayload:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def __get_token(token: str = None) -> str:
|
def verify_token(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
token: str = Security(oauth2_scheme)
|
||||||
|
) -> schemas.TokenPayload:
|
||||||
"""
|
"""
|
||||||
从请求URL中获取token
|
验证 JWT 令牌并自动处理 resource_token 写入
|
||||||
|
:param request: 请求对象,用于访问 Cookie 和请求信息
|
||||||
|
:param response: 响应对象,用于设置 Cookie
|
||||||
|
:param token: 从 Authorization 头部获取的 JWT 令牌
|
||||||
|
:return: 解析后的 TokenPayload
|
||||||
|
:raises HTTPException: 如果令牌无效或用途不匹配
|
||||||
"""
|
"""
|
||||||
return token
|
# 验证并解析 JWT 认证令牌
|
||||||
|
payload = __verify_token(token=token, purpose="authentication")
|
||||||
|
|
||||||
|
# 如果没有 resource_token,生成并写入到 Cookie
|
||||||
|
__set_or_refresh_resource_token_cookie(request, response, payload)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def __get_apikey(apikey: str = None, x_api_key: Annotated[str | None, Header()] = None) -> str:
|
def verify_resource_token(
|
||||||
|
resource_token: str = Security(resource_token_cookie)
|
||||||
|
) -> schemas.TokenPayload:
|
||||||
"""
|
"""
|
||||||
从请求URL中获取apikey
|
验证资源访问令牌(从 Cookie 中获取)
|
||||||
|
:param resource_token: 从 Cookie 中获取的资源访问令牌
|
||||||
|
:return: 解析后的 TokenPayload
|
||||||
|
:raises HTTPException: 如果资源访问令牌无效
|
||||||
"""
|
"""
|
||||||
return apikey or x_api_key
|
# 验证并解析资源访问令牌
|
||||||
|
return __verify_token(token=resource_token, purpose="resource")
|
||||||
|
|
||||||
|
|
||||||
def verify_apitoken(token: str = Depends(__get_token)) -> str:
|
def __get_api_token(
|
||||||
|
token_query: Annotated[str | None, Security(api_token_query)] = None
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
通过依赖项使用token进行身份认证
|
从 URL 查询参数中获取 API Token
|
||||||
|
:param token_query: 从 URL 中的 `token` 查询参数获取 API Token
|
||||||
|
:return: 返回获取到的 API Token,若无则返回 None
|
||||||
"""
|
"""
|
||||||
if token != settings.API_TOKEN:
|
return token_query
|
||||||
|
|
||||||
|
|
||||||
|
def __get_api_key(
|
||||||
|
key_query: Annotated[str | None, Security(api_key_query)] = None,
|
||||||
|
key_header: Annotated[str | None, Security(api_key_header)] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
从 URL 查询参数或请求头部获取 API Key,优先使用 URL 参数
|
||||||
|
:param key_query: URL 中的 `apikey` 查询参数
|
||||||
|
:param key_header: 请求头中的 `X-API-KEY` 参数
|
||||||
|
:return: 返回从 URL 或请求头中获取的 API Key,若无则返回 None
|
||||||
|
"""
|
||||||
|
return key_query or key_header
|
||||||
|
|
||||||
|
|
||||||
|
def __verify_key(key: str, expected_key: str, key_type: str) -> str:
|
||||||
|
"""
|
||||||
|
通用的 API Key 或 Token 验证函数
|
||||||
|
:param key: 从请求中获取的 API Key 或 Token
|
||||||
|
:param expected_key: 系统配置中的期望值,用于验证的 API Key 或 Token
|
||||||
|
:param key_type: 键的类型(例如 "API_KEY" 或 "API_TOKEN"),用于错误消息
|
||||||
|
:return: 返回校验通过的 API Key 或 Token
|
||||||
|
:raises HTTPException: 如果校验不通过,抛出 401 错误
|
||||||
|
"""
|
||||||
|
if key != expected_key:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="token校验不通过"
|
detail=f"{key_type} 校验不通过"
|
||||||
)
|
)
|
||||||
return token
|
return key
|
||||||
|
|
||||||
|
|
||||||
def verify_apikey(apikey: str = Depends(__get_apikey)) -> str:
|
def verify_apitoken(token: str = Security(__get_api_token)) -> str:
|
||||||
"""
|
"""
|
||||||
通过依赖项使用apikey进行身份认证
|
使用 API Token 进行身份认证
|
||||||
|
:param token: API Token,从 URL 查询参数中获取
|
||||||
|
:return: 返回校验通过的 API Token
|
||||||
"""
|
"""
|
||||||
if apikey != settings.API_TOKEN:
|
return __verify_key(token, settings.API_TOKEN, "API_TOKEN")
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="apikey校验不通过"
|
|
||||||
)
|
|
||||||
return apikey
|
|
||||||
|
|
||||||
|
|
||||||
def verify_uri_token(token: str = Depends(__get_token)) -> str:
|
def verify_apikey(apikey: str = Security(__get_api_key)) -> str:
|
||||||
"""
|
"""
|
||||||
通过依赖项使用token进行身份认证
|
使用 API Key 进行身份认证
|
||||||
|
:param apikey: API Key,从 URL 查询参数或请求头中获取
|
||||||
|
:return: 返回校验通过的 API Key
|
||||||
"""
|
"""
|
||||||
if not verify_token(token):
|
return __verify_key(apikey, settings.API_TOKEN, "API_KEY")
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="token校验不通过"
|
|
||||||
)
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
from typing import Any, Self, List
|
from typing import Any, Generator, List, Optional, Self, Tuple
|
||||||
from typing import Tuple, Optional, Generator
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine, QueuePool
|
from sqlalchemy import NullPool, QueuePool, and_, create_engine, inspect
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy.orm import Session, as_declarative, declared_attr, scoped_session, sessionmaker
|
||||||
from sqlalchemy.orm import declared_attr
|
|
||||||
from sqlalchemy.orm import sessionmaker, Session, scoped_session, as_declarative
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
# 数据库引擎
|
# 根据池类型设置 poolclass 和相关参数
|
||||||
Engine = create_engine(f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool
|
||||||
pool_pre_ping=True,
|
kwargs = {
|
||||||
echo=False,
|
"url": f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
||||||
poolclass=QueuePool,
|
"pool_pre_ping": settings.DB_POOL_PRE_PING,
|
||||||
pool_size=1024,
|
"echo": settings.DB_ECHO,
|
||||||
pool_recycle=3600,
|
"poolclass": pool_class,
|
||||||
pool_timeout=180,
|
"pool_recycle": settings.DB_POOL_RECYCLE,
|
||||||
max_overflow=10,
|
"connect_args": {
|
||||||
connect_args={"timeout": 60})
|
# "check_same_thread": False,
|
||||||
|
"timeout": settings.DB_TIMEOUT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
|
||||||
|
if pool_class == QueuePool:
|
||||||
|
kwargs.update({
|
||||||
|
"pool_size": settings.DB_POOL_SIZE,
|
||||||
|
"pool_timeout": settings.DB_POOL_TIMEOUT,
|
||||||
|
"max_overflow": settings.DB_MAX_OVERFLOW
|
||||||
|
})
|
||||||
|
# 创建数据库引擎
|
||||||
|
Engine = create_engine(**kwargs)
|
||||||
|
|
||||||
# 会话工厂
|
# 会话工厂
|
||||||
SessionFactory = sessionmaker(bind=Engine)
|
SessionFactory = sessionmaker(bind=Engine)
|
||||||
|
|
||||||
@@ -39,6 +49,13 @@ def get_db() -> Generator:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def close_database():
|
||||||
|
"""
|
||||||
|
关闭所有数据库连接
|
||||||
|
"""
|
||||||
|
Engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
|
def get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
|
||||||
"""
|
"""
|
||||||
从参数中获取数据库Session对象
|
从参数中获取数据库Session对象
|
||||||
@@ -163,7 +180,7 @@ class Base:
|
|||||||
@classmethod
|
@classmethod
|
||||||
@db_update
|
@db_update
|
||||||
def delete(cls, db: Session, rid):
|
def delete(cls, db: Session, rid):
|
||||||
db.query(cls).filter(cls.id == rid).delete()
|
db.query(cls).filter(and_(cls.id == rid)).delete()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@db_update
|
@db_update
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ class DownloadHistoryOper(DbOper):
|
|||||||
"""
|
"""
|
||||||
return DownloadHistory.get_by_hash(self._db, download_hash)
|
return DownloadHistory.get_by_hash(self._db, download_hash)
|
||||||
|
|
||||||
|
def get_by_mediaid(self, tmdbid: int, doubanid: str) -> List[DownloadHistory]:
|
||||||
|
"""
|
||||||
|
按媒体ID查询下载记录
|
||||||
|
:param tmdbid: tmdbid
|
||||||
|
:param doubanid: doubanid
|
||||||
|
"""
|
||||||
|
return DownloadHistory.get_by_mediaid(self._db, tmdbid=tmdbid, doubanid=doubanid)
|
||||||
|
|
||||||
def add(self, **kwargs):
|
def add(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
新增下载历史
|
新增下载历史
|
||||||
@@ -139,3 +147,15 @@ class DownloadHistoryOper(DbOper):
|
|||||||
return DownloadHistory.list_by_type(db=self._db,
|
return DownloadHistory.list_by_type(db=self._db,
|
||||||
mtype=mtype,
|
mtype=mtype,
|
||||||
days=days)
|
days=days)
|
||||||
|
|
||||||
|
def delete_history(self, historyid):
|
||||||
|
"""
|
||||||
|
删除下载记录
|
||||||
|
"""
|
||||||
|
DownloadHistory.delete(self._db, historyid)
|
||||||
|
|
||||||
|
def delete_downloadfile(self, downloadfileid):
|
||||||
|
"""
|
||||||
|
删除下载文件记录
|
||||||
|
"""
|
||||||
|
DownloadFiles.delete(self._db, downloadfileid)
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import random
|
|
||||||
import string
|
|
||||||
|
|
||||||
from alembic.command import upgrade
|
from alembic.command import upgrade
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import get_password_hash
|
from app.db import Engine, Base
|
||||||
from app.db import Engine, SessionFactory, Base
|
|
||||||
from app.db.models import *
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -19,27 +14,6 @@ def init_db():
|
|||||||
Base.metadata.create_all(bind=Engine)
|
Base.metadata.create_all(bind=Engine)
|
||||||
|
|
||||||
|
|
||||||
def init_super_user():
|
|
||||||
"""
|
|
||||||
初始化超级管理员
|
|
||||||
"""
|
|
||||||
# 初始化超级管理员
|
|
||||||
with SessionFactory() as db:
|
|
||||||
_user = User.get_by_name(db=db, name=settings.SUPERUSER)
|
|
||||||
if not _user:
|
|
||||||
# 定义包含数字、大小写字母的字符集合
|
|
||||||
characters = string.ascii_letters + string.digits
|
|
||||||
# 生成随机密码
|
|
||||||
random_password = ''.join(random.choice(characters) for _ in range(16))
|
|
||||||
logger.info(f"【超级管理员初始密码】{random_password} 请登录系统后在设定中修改。 注:该密码只会显示一次,请注意保存。")
|
|
||||||
_user = User(
|
|
||||||
name=settings.SUPERUSER,
|
|
||||||
hashed_password=get_password_hash(random_password),
|
|
||||||
is_superuser=True,
|
|
||||||
)
|
|
||||||
_user.create(db)
|
|
||||||
|
|
||||||
|
|
||||||
def update_db():
|
def update_db():
|
||||||
"""
|
"""
|
||||||
更新数据库
|
更新数据库
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -19,6 +18,8 @@ class MediaServerOper(DbOper):
|
|||||||
"""
|
"""
|
||||||
新增媒体服务器数据
|
新增媒体服务器数据
|
||||||
"""
|
"""
|
||||||
|
# MediaServerItem中没有的属性剔除
|
||||||
|
kwargs = {k: v for k, v in kwargs.items() if hasattr(MediaServerItem, k)}
|
||||||
item = MediaServerItem(**kwargs)
|
item = MediaServerItem(**kwargs)
|
||||||
if not item.get_by_itemid(self._db, kwargs.get("item_id")):
|
if not item.get_by_itemid(self._db, kwargs.get("item_id")):
|
||||||
item.create(self._db)
|
item.create(self._db)
|
||||||
@@ -52,7 +53,7 @@ class MediaServerOper(DbOper):
|
|||||||
# 判断季是否存在
|
# 判断季是否存在
|
||||||
if not item.seasoninfo:
|
if not item.seasoninfo:
|
||||||
return None
|
return None
|
||||||
seasoninfo = json.loads(item.seasoninfo) or {}
|
seasoninfo = item.seasoninfo or {}
|
||||||
if kwargs.get("season") not in seasoninfo.keys():
|
if kwargs.get("season") not in seasoninfo.keys():
|
||||||
return None
|
return None
|
||||||
return item
|
return item
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
@@ -19,6 +18,7 @@ class MessageOper(DbOper):
|
|||||||
|
|
||||||
def add(self,
|
def add(self,
|
||||||
channel: MessageChannel = None,
|
channel: MessageChannel = None,
|
||||||
|
source: str = None,
|
||||||
mtype: NotificationType = None,
|
mtype: NotificationType = None,
|
||||||
title: str = None,
|
title: str = None,
|
||||||
text: str = None,
|
text: str = None,
|
||||||
@@ -31,6 +31,7 @@ class MessageOper(DbOper):
|
|||||||
"""
|
"""
|
||||||
新增媒体服务器数据
|
新增媒体服务器数据
|
||||||
:param channel: 消息渠道
|
:param channel: 消息渠道
|
||||||
|
:param source: 来源
|
||||||
:param mtype: 消息类型
|
:param mtype: 消息类型
|
||||||
:param title: 标题
|
:param title: 标题
|
||||||
:param text: 文本内容
|
:param text: 文本内容
|
||||||
@@ -42,6 +43,7 @@ class MessageOper(DbOper):
|
|||||||
"""
|
"""
|
||||||
kwargs.update({
|
kwargs.update({
|
||||||
"channel": channel.value if channel else '',
|
"channel": channel.value if channel else '',
|
||||||
|
"source": source,
|
||||||
"mtype": mtype.value if mtype else '',
|
"mtype": mtype.value if mtype else '',
|
||||||
"title": title,
|
"title": title,
|
||||||
"text": text,
|
"text": text,
|
||||||
@@ -50,8 +52,14 @@ class MessageOper(DbOper):
|
|||||||
"userid": userid,
|
"userid": userid,
|
||||||
"action": action,
|
"action": action,
|
||||||
"reg_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
"reg_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||||
"note": json.dumps(note) if note else ''
|
"note": note or {}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 从kwargs中去掉Message中没有的字段
|
||||||
|
for k in list(kwargs.keys()):
|
||||||
|
if k not in Message.__table__.columns.keys():
|
||||||
|
kwargs.pop(k)
|
||||||
|
|
||||||
Message(**kwargs).create(self._db)
|
Message(**kwargs).create(self._db)
|
||||||
|
|
||||||
def list_by_page(self, page: int = 1, count: int = 30) -> Optional[str]:
|
def list_by_page(self, page: int = 1, count: int = 30) -> Optional[str]:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Sequence
|
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, db_update, Base
|
from app.db import db_query, db_update, Base
|
||||||
@@ -46,13 +46,21 @@ class DownloadHistory(Base):
|
|||||||
# 创建时间
|
# 创建时间
|
||||||
date = Column(String)
|
date = Column(String)
|
||||||
# 附加信息
|
# 附加信息
|
||||||
note = Column(String)
|
note = Column(JSON)
|
||||||
|
# 自定义媒体类别
|
||||||
|
media_category = Column(String)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def get_by_hash(db: Session, download_hash: str):
|
def get_by_hash(db: Session, download_hash: str):
|
||||||
return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).first()
|
return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_by_mediaid(db: Session, tmdbid: int, doubanid: str):
|
||||||
|
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||||
|
DownloadHistory.doubanid == doubanid).all()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def list_by_page(db: Session, page: int = 1, count: int = 30):
|
def list_by_page(db: Session, page: int = 1, count: int = 30):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Sequence
|
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, db_update, Base
|
from app.db import db_query, db_update, Base
|
||||||
@@ -35,9 +35,9 @@ class MediaServerItem(Base):
|
|||||||
# 路径
|
# 路径
|
||||||
path = Column(String)
|
path = Column(String)
|
||||||
# 季集
|
# 季集
|
||||||
seasoninfo = Column(String)
|
seasoninfo = Column(JSON, default=dict)
|
||||||
# 备注
|
# 备注
|
||||||
note = Column(String)
|
note = Column(JSON)
|
||||||
# 同步时间
|
# 同步时间
|
||||||
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Sequence
|
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, Base
|
from app.db import db_query, Base
|
||||||
@@ -11,6 +11,8 @@ class Message(Base):
|
|||||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
# 消息渠道
|
# 消息渠道
|
||||||
channel = Column(String)
|
channel = Column(String)
|
||||||
|
# 消息来源
|
||||||
|
source = Column(String)
|
||||||
# 消息类型
|
# 消息类型
|
||||||
mtype = Column(String)
|
mtype = Column(String)
|
||||||
# 标题
|
# 标题
|
||||||
@@ -28,7 +30,7 @@ class Message(Base):
|
|||||||
# 消息方向:0-接收息,1-发送消息
|
# 消息方向:0-接收息,1-发送消息
|
||||||
action = Column(Integer)
|
action = Column(Integer)
|
||||||
# 附件json
|
# 附件json
|
||||||
note = Column(String)
|
note = Column(JSON)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Sequence
|
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, db_update, Base
|
from app.db import db_query, db_update, Base
|
||||||
@@ -11,7 +11,7 @@ class PluginData(Base):
|
|||||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
plugin_id = Column(String, nullable=False, index=True)
|
plugin_id = Column(String, nullable=False, index=True)
|
||||||
key = Column(String, index=True, nullable=False)
|
key = Column(String, index=True, nullable=False)
|
||||||
value = Column(String)
|
value = Column(JSON)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
from sqlalchemy import Boolean, Column, Integer, String, Sequence, JSON
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, db_update, Base
|
from app.db import db_query, db_update, Base
|
||||||
@@ -38,7 +38,7 @@ class Site(Base):
|
|||||||
# 是否公开站点
|
# 是否公开站点
|
||||||
public = Column(Integer)
|
public = Column(Integer)
|
||||||
# 附加信息
|
# 附加信息
|
||||||
note = Column(String)
|
note = Column(JSON)
|
||||||
# 流控单位周期
|
# 流控单位周期
|
||||||
limit_interval = Column(Integer, default=0)
|
limit_interval = Column(Integer, default=0)
|
||||||
# 流控次数
|
# 流控次数
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Sequence
|
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, db_update, Base
|
from app.db import db_query, db_update, Base
|
||||||
@@ -24,7 +24,7 @@ class SiteStatistic(Base):
|
|||||||
# 最后访问时间
|
# 最后访问时间
|
||||||
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
# 耗时记录 Json
|
# 耗时记录 Json
|
||||||
note = Column(String)
|
note = Column(JSON)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
|
|||||||
93
app/db/models/siteuserdata.py
Normal file
93
app/db/models/siteuserdata.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON, func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import db_query, Base
|
||||||
|
|
||||||
|
|
||||||
|
class SiteUserData(Base):
|
||||||
|
"""
|
||||||
|
站点数据表
|
||||||
|
"""
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
# 站点域名
|
||||||
|
domain = Column(String, index=True)
|
||||||
|
# 站点名称
|
||||||
|
name = Column(String)
|
||||||
|
# 用户名
|
||||||
|
username = Column(String)
|
||||||
|
# 用户ID
|
||||||
|
userid = Column(Integer)
|
||||||
|
# 用户等级
|
||||||
|
user_level = Column(String)
|
||||||
|
# 加入时间
|
||||||
|
join_at = Column(String)
|
||||||
|
# 积分
|
||||||
|
bonus = Column(Float, default=0)
|
||||||
|
# 上传量
|
||||||
|
upload = Column(Float, default=0)
|
||||||
|
# 下载量
|
||||||
|
download = Column(Float, default=0)
|
||||||
|
# 分享率
|
||||||
|
ratio = Column(Float, default=0)
|
||||||
|
# 做种数
|
||||||
|
seeding = Column(Float, default=0)
|
||||||
|
# 下载数
|
||||||
|
leeching = Column(Float, default=0)
|
||||||
|
# 做种体积
|
||||||
|
seeding_size = Column(Float, default=0)
|
||||||
|
# 下载体积
|
||||||
|
leeching_size = Column(Float, default=0)
|
||||||
|
# 做种人数, 种子大小 JSON
|
||||||
|
seeding_info = Column(JSON, default=dict)
|
||||||
|
# 未读消息
|
||||||
|
message_unread = Column(Integer, default=0)
|
||||||
|
# 未读消息内容 JSON
|
||||||
|
message_unread_contents = Column(JSON, default=list)
|
||||||
|
# 错误信息
|
||||||
|
err_msg = Column(String)
|
||||||
|
# 更新日期
|
||||||
|
updated_day = Column(String, index=True, default=datetime.now().strftime('%Y-%m-%d'))
|
||||||
|
# 更新时间
|
||||||
|
updated_time = Column(String, default=datetime.now().strftime('%H:%M:%S'))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_by_domain(db: Session, domain: str, workdate: str = None, worktime: str = None):
|
||||||
|
if workdate and worktime:
|
||||||
|
return db.query(SiteUserData).filter(SiteUserData.domain == domain,
|
||||||
|
SiteUserData.updated_day == workdate,
|
||||||
|
SiteUserData.updated_time == worktime).all()
|
||||||
|
elif workdate:
|
||||||
|
return db.query(SiteUserData).filter(SiteUserData.domain == domain,
|
||||||
|
SiteUserData.updated_day == workdate).all()
|
||||||
|
return db.query(SiteUserData).filter(SiteUserData.domain == domain).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_by_date(db: Session, date: str):
|
||||||
|
return db.query(SiteUserData).filter(SiteUserData.updated_day == date).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_latest(db: Session):
|
||||||
|
"""
|
||||||
|
获取各站点最新一天的数据
|
||||||
|
"""
|
||||||
|
subquery = (
|
||||||
|
db.query(
|
||||||
|
SiteUserData.domain,
|
||||||
|
func.max(SiteUserData.updated_day).label('latest_update_day')
|
||||||
|
)
|
||||||
|
.group_by(SiteUserData.domain)
|
||||||
|
.filter(SiteUserData.err_msg == None)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 主查询:按 domain 和 updated_day 获取最新的记录
|
||||||
|
return db.query(SiteUserData).join(
|
||||||
|
subquery,
|
||||||
|
(SiteUserData.domain == subquery.c.domain) &
|
||||||
|
(SiteUserData.updated_day == subquery.c.latest_update_day)
|
||||||
|
).order_by(SiteUserData.updated_time.desc()).all()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Sequence, Float
|
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, db_update, Base
|
from app.db import db_query, db_update, Base
|
||||||
@@ -53,7 +53,7 @@ class Subscribe(Base):
|
|||||||
# 缺失集数
|
# 缺失集数
|
||||||
lack_episode = Column(Integer)
|
lack_episode = Column(Integer)
|
||||||
# 附加信息
|
# 附加信息
|
||||||
note = Column(String)
|
note = Column(JSON)
|
||||||
# 状态:N-新建, R-订阅中
|
# 状态:N-新建, R-订阅中
|
||||||
state = Column(String, nullable=False, index=True, default='N')
|
state = Column(String, nullable=False, index=True, default='N')
|
||||||
# 最后更新时间
|
# 最后更新时间
|
||||||
@@ -63,7 +63,7 @@ class Subscribe(Base):
|
|||||||
# 订阅用户
|
# 订阅用户
|
||||||
username = Column(String)
|
username = Column(String)
|
||||||
# 订阅站点
|
# 订阅站点
|
||||||
sites = Column(String)
|
sites = Column(JSON, default=list)
|
||||||
# 是否洗版
|
# 是否洗版
|
||||||
best_version = Column(Integer, default=0)
|
best_version = Column(Integer, default=0)
|
||||||
# 当前优先级
|
# 当前优先级
|
||||||
@@ -74,6 +74,12 @@ class Subscribe(Base):
|
|||||||
search_imdbid = Column(Integer, default=0)
|
search_imdbid = Column(Integer, default=0)
|
||||||
# 是否手动修改过总集数 0否 1是
|
# 是否手动修改过总集数 0否 1是
|
||||||
manual_total_episode = Column(Integer, default=0)
|
manual_total_episode = Column(Integer, default=0)
|
||||||
|
# 自定义识别词
|
||||||
|
custom_words = Column(String)
|
||||||
|
# 自定义媒体类别
|
||||||
|
media_category = Column(String)
|
||||||
|
# 过滤规则组
|
||||||
|
filter_groups = Column(JSON, default=list)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Sequence, Float
|
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, Base
|
from app.db import db_query, Base
|
||||||
@@ -53,13 +53,19 @@ class SubscribeHistory(Base):
|
|||||||
# 订阅用户
|
# 订阅用户
|
||||||
username = Column(String)
|
username = Column(String)
|
||||||
# 订阅站点
|
# 订阅站点
|
||||||
sites = Column(String)
|
sites = Column(JSON)
|
||||||
# 是否洗版
|
# 是否洗版
|
||||||
best_version = Column(Integer, default=0)
|
best_version = Column(Integer, default=0)
|
||||||
# 保存路径
|
# 保存路径
|
||||||
save_path = Column(String)
|
save_path = Column(String)
|
||||||
# 是否使用 imdbid 搜索
|
# 是否使用 imdbid 搜索
|
||||||
search_imdbid = Column(Integer, default=0)
|
search_imdbid = Column(Integer, default=0)
|
||||||
|
# 自定义识别词
|
||||||
|
custom_words = Column(String)
|
||||||
|
# 自定义媒体类别
|
||||||
|
media_category = Column(String)
|
||||||
|
# 过滤规则组
|
||||||
|
filter_groups = Column(JSON, default=list)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Sequence
|
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, db_update, Base
|
from app.db import db_query, db_update, Base
|
||||||
@@ -12,7 +12,7 @@ class SystemConfig(Base):
|
|||||||
# 主键
|
# 主键
|
||||||
key = Column(String, index=True)
|
key = Column(String, index=True)
|
||||||
# 值
|
# 值
|
||||||
value = Column(String, nullable=True)
|
value = Column(JSON)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Sequence, Boolean, func, or_
|
from sqlalchemy import Column, Integer, String, Sequence, Boolean, func, or_, JSON
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, db_update, Base
|
from app.db import db_query, db_update, Base
|
||||||
@@ -11,10 +11,18 @@ class TransferHistory(Base):
|
|||||||
转移历史记录
|
转移历史记录
|
||||||
"""
|
"""
|
||||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
# 源目录
|
# 源路径
|
||||||
src = Column(String, index=True)
|
src = Column(String, index=True)
|
||||||
# 目标目录
|
# 源存储
|
||||||
|
src_storage = Column(String)
|
||||||
|
# 源文件项
|
||||||
|
src_fileitem = Column(JSON, default=dict)
|
||||||
|
# 目标路径
|
||||||
dest = Column(String)
|
dest = Column(String)
|
||||||
|
# 目标存储
|
||||||
|
dest_storage = Column(String)
|
||||||
|
# 目标文件项
|
||||||
|
dest_fileitem = Column(JSON, default=dict)
|
||||||
# 转移模式 move/copy/link...
|
# 转移模式 move/copy/link...
|
||||||
mode = Column(String)
|
mode = Column(String)
|
||||||
# 类型 电影/电视剧
|
# 类型 电影/电视剧
|
||||||
@@ -44,7 +52,7 @@ class TransferHistory(Base):
|
|||||||
# 时间
|
# 时间
|
||||||
date = Column(String, index=True)
|
date = Column(String, index=True)
|
||||||
# 文件清单,以JSON存储
|
# 文件清单,以JSON存储
|
||||||
files = Column(String)
|
files = Column(JSON, default=list)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
@@ -57,6 +65,7 @@ class TransferHistory(Base):
|
|||||||
).offset((page - 1) * count).limit(count).all()
|
).offset((page - 1) * count).limit(count).all()
|
||||||
else:
|
else:
|
||||||
result = db.query(TransferHistory).filter(or_(
|
result = db.query(TransferHistory).filter(or_(
|
||||||
|
TransferHistory.title.like(f'%{title}%'),
|
||||||
TransferHistory.src.like(f'%{title}%'),
|
TransferHistory.src.like(f'%{title}%'),
|
||||||
TransferHistory.dest.like(f'%{title}%'),
|
TransferHistory.dest.like(f'%{title}%'),
|
||||||
)).order_by(
|
)).order_by(
|
||||||
@@ -86,8 +95,12 @@ class TransferHistory(Base):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def get_by_src(db: Session, src: str):
|
def get_by_src(db: Session, src: str, storage: str = None):
|
||||||
return db.query(TransferHistory).filter(TransferHistory.src == src).first()
|
if storage:
|
||||||
|
return db.query(TransferHistory).filter(TransferHistory.src == src,
|
||||||
|
TransferHistory.src_storage == storage).first()
|
||||||
|
else:
|
||||||
|
return db.query(TransferHistory).filter(TransferHistory.src == src).first()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
@@ -128,6 +141,7 @@ class TransferHistory(Base):
|
|||||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.status == status).first()[0]
|
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.status == status).first()[0]
|
||||||
else:
|
else:
|
||||||
return db.query(func.count(TransferHistory.id)).filter(or_(
|
return db.query(func.count(TransferHistory.id)).filter(or_(
|
||||||
|
TransferHistory.title.like(f'%{title}%'),
|
||||||
TransferHistory.src.like(f'%{title}%'),
|
TransferHistory.src.like(f'%{title}%'),
|
||||||
TransferHistory.dest.like(f'%{title}%')
|
TransferHistory.dest.like(f'%{title}%')
|
||||||
)).first()[0]
|
)).first()[0]
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
from typing import Tuple, Optional
|
from sqlalchemy import Boolean, Column, Integer, JSON, Sequence, String
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.security import verify_password
|
from app.db import Base, db_query, db_update
|
||||||
from app.db import db_query, db_update, Base
|
|
||||||
from app.schemas import User
|
|
||||||
from app.utils.otp import OtpUtils
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@@ -15,9 +10,9 @@ class User(Base):
|
|||||||
"""
|
"""
|
||||||
# ID
|
# ID
|
||||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
# 用户名
|
# 用户名,唯一值
|
||||||
name = Column(String, index=True, nullable=False)
|
name = Column(String, index=True, nullable=False)
|
||||||
# 邮箱,未启用
|
# 邮箱
|
||||||
email = Column(String)
|
email = Column(String)
|
||||||
# 加密后密码
|
# 加密后密码
|
||||||
hashed_password = Column(String)
|
hashed_password = Column(String)
|
||||||
@@ -31,25 +26,21 @@ class User(Base):
|
|||||||
is_otp = Column(Boolean(), default=False)
|
is_otp = Column(Boolean(), default=False)
|
||||||
# otp秘钥
|
# otp秘钥
|
||||||
otp_secret = Column(String, default=None)
|
otp_secret = Column(String, default=None)
|
||||||
|
# 用户权限 json
|
||||||
@staticmethod
|
permissions = Column(JSON, default=dict)
|
||||||
@db_query
|
# 用户个性化设置 json
|
||||||
def authenticate(db: Session, name: str, password: str, otp_password: str) -> Tuple[bool, Optional[User]]:
|
settings = Column(JSON, default=dict)
|
||||||
user = db.query(User).filter(User.name == name).first()
|
|
||||||
if not user:
|
|
||||||
return False, None
|
|
||||||
if not verify_password(password, str(user.hashed_password)):
|
|
||||||
return False, user
|
|
||||||
if user.is_otp:
|
|
||||||
if not otp_password or not OtpUtils.check(user.otp_secret, otp_password):
|
|
||||||
return False, user
|
|
||||||
return True, user
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def get_by_name(db: Session, name: str):
|
def get_by_name(db: Session, name: str):
|
||||||
return db.query(User).filter(User.name == name).first()
|
return db.query(User).filter(User.name == name).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_by_id(db: Session, user_id: int):
|
||||||
|
return db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
@db_update
|
@db_update
|
||||||
def delete_by_name(self, db: Session, name: str):
|
def delete_by_name(self, db: Session, name: str):
|
||||||
user = self.get_by_name(db, name)
|
user = self.get_by_name(db, name)
|
||||||
@@ -57,6 +48,13 @@ class User(Base):
|
|||||||
user.delete(db, user.id)
|
user.delete(db, user.id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@db_update
|
||||||
|
def delete_by_id(self, db: Session, user_id: int):
|
||||||
|
user = self.get_by_id(db, user_id)
|
||||||
|
if user:
|
||||||
|
user.delete(db, user.id)
|
||||||
|
return True
|
||||||
|
|
||||||
@db_update
|
@db_update
|
||||||
def update_otp_by_name(self, db: Session, name: str, otp: bool, secret: str):
|
def update_otp_by_name(self, db: Session, name: str, otp: bool, secret: str):
|
||||||
user = self.get_by_name(db, name)
|
user = self.get_by_name(db, name)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Sequence, UniqueConstraint, Index
|
from sqlalchemy import Column, Integer, String, Sequence, UniqueConstraint, Index, JSON
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import db_query, db_update, Base
|
from app.db import db_query, db_update, Base
|
||||||
@@ -14,7 +14,7 @@ class UserConfig(Base):
|
|||||||
# 配置键
|
# 配置键
|
||||||
key = Column(String)
|
key = Column(String)
|
||||||
# 值
|
# 值
|
||||||
value = Column(String, nullable=True)
|
value = Column(JSON)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# 用户名和配置键联合唯一
|
# 用户名和配置键联合唯一
|
||||||
|
|||||||
69
app/db/models/userrequest.py
Normal file
69
app/db/models/userrequest.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Sequence, Float
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import db_query, Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserRequest(Base):
|
||||||
|
"""
|
||||||
|
用户请求表
|
||||||
|
"""
|
||||||
|
# ID
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
# 申请用户
|
||||||
|
req_user = Column(String, index=True, nullable=False)
|
||||||
|
# 申请时间
|
||||||
|
req_time = Column(String)
|
||||||
|
# 申请备注
|
||||||
|
req_remark = Column(String)
|
||||||
|
# 审批用户
|
||||||
|
app_user = Column(String, index=True, nullable=False)
|
||||||
|
# 审批时间
|
||||||
|
app_time = Column(String)
|
||||||
|
# 审批状态 0-待审批 1-通过 2-拒绝
|
||||||
|
app_status = Column(Integer, default=0)
|
||||||
|
# 类型
|
||||||
|
type = Column(String)
|
||||||
|
# 标题
|
||||||
|
title = Column(String)
|
||||||
|
# 年份
|
||||||
|
year = Column(String)
|
||||||
|
# 媒体ID
|
||||||
|
tmdbid = Column(Integer)
|
||||||
|
imdbid = Column(String)
|
||||||
|
tvdbid = Column(Integer)
|
||||||
|
doubanid = Column(String)
|
||||||
|
bangumiid = Column(Integer)
|
||||||
|
# 季号
|
||||||
|
season = Column(Integer)
|
||||||
|
# 海报
|
||||||
|
poster = Column(String)
|
||||||
|
# 背景图
|
||||||
|
backdrop = Column(String)
|
||||||
|
# 评分,float
|
||||||
|
vote = Column(Float)
|
||||||
|
# 简介
|
||||||
|
description = Column(String)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_by_req_user(db: Session, req_user: str, status: int = None):
|
||||||
|
if status:
|
||||||
|
return db.query(UserRequest).filter(UserRequest.req_user == req_user,
|
||||||
|
UserRequest.app_status == status).all()
|
||||||
|
else:
|
||||||
|
return db.query(UserRequest).filter(UserRequest.req_user == req_user).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_by_app_user(db: Session, app_user: str, status: int = None):
|
||||||
|
if status:
|
||||||
|
return db.query(UserRequest).filter(UserRequest.app_user == app_user,
|
||||||
|
UserRequest.app_status == status).all()
|
||||||
|
else:
|
||||||
|
return db.query(UserRequest).filter(UserRequest.app_user == app_user).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_by_status(db: Session, status: int):
|
||||||
|
return db.query(UserRequest).filter(UserRequest.app_status == status).all()
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import json
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.db import DbOper
|
from app.db import DbOper
|
||||||
from app.db.models.plugindata import PluginData
|
from app.db.models.plugindata import PluginData
|
||||||
from app.utils.object import ObjectUtils
|
|
||||||
|
|
||||||
|
|
||||||
class PluginDataOper(DbOper):
|
class PluginDataOper(DbOper):
|
||||||
@@ -18,8 +16,6 @@ class PluginDataOper(DbOper):
|
|||||||
:param key: 数据key
|
:param key: 数据key
|
||||||
:param value: 数据值
|
:param value: 数据值
|
||||||
"""
|
"""
|
||||||
if ObjectUtils.is_obj(value):
|
|
||||||
value = json.dumps(value)
|
|
||||||
plugin = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
|
plugin = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
|
||||||
if plugin:
|
if plugin:
|
||||||
plugin.update(self._db, {
|
plugin.update(self._db, {
|
||||||
@@ -38,8 +34,6 @@ class PluginDataOper(DbOper):
|
|||||||
data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
|
data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
|
||||||
if not data:
|
if not data:
|
||||||
return None
|
return None
|
||||||
if ObjectUtils.is_obj(data.value):
|
|
||||||
return json.loads(data.value)
|
|
||||||
return data.value
|
return data.value
|
||||||
else:
|
else:
|
||||||
return PluginData.get_plugin_data(self._db, plugin_id)
|
return PluginData.get_plugin_data(self._db, plugin_id)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
from typing import Tuple, List
|
from datetime import datetime
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
from app.db import DbOper
|
from app.db import DbOper
|
||||||
|
from app.db.models import SiteIcon
|
||||||
from app.db.models.site import Site
|
from app.db.models.site import Site
|
||||||
|
from app.db.models.sitestatistic import SiteStatistic
|
||||||
|
from app.db.models.siteuserdata import SiteUserData
|
||||||
|
|
||||||
|
|
||||||
class SiteOper(DbOper):
|
class SiteOper(DbOper):
|
||||||
@@ -98,3 +102,130 @@ class SiteOper(DbOper):
|
|||||||
"rss": rss
|
"rss": rss
|
||||||
})
|
})
|
||||||
return True, "更新站点RSS地址成功"
|
return True, "更新站点RSS地址成功"
|
||||||
|
|
||||||
|
def update_userdata(self, domain: str, name: str, payload: dict) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
更新站点用户数据
|
||||||
|
"""
|
||||||
|
# 当前系统日期
|
||||||
|
current_day = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
current_time = datetime.now().strftime('%H:%M:%S')
|
||||||
|
payload.update({
|
||||||
|
"domain": domain,
|
||||||
|
"name": name,
|
||||||
|
"updated_day": current_day,
|
||||||
|
"updated_time": current_time
|
||||||
|
})
|
||||||
|
# 按站点+天判断是否存在数据
|
||||||
|
siteuserdatas = SiteUserData.get_by_domain(self._db, domain=domain, workdate=current_day)
|
||||||
|
if siteuserdatas:
|
||||||
|
# 存在则更新
|
||||||
|
siteuserdatas[0].update(self._db, payload)
|
||||||
|
else:
|
||||||
|
# 不存在则插入
|
||||||
|
SiteUserData(**payload).create(self._db)
|
||||||
|
return True, "更新站点用户数据成功"
|
||||||
|
|
||||||
|
def get_userdata(self) -> List[SiteUserData]:
|
||||||
|
"""
|
||||||
|
获取站点用户数据
|
||||||
|
"""
|
||||||
|
return SiteUserData.list(self._db)
|
||||||
|
|
||||||
|
def get_userdata_by_domain(self, domain: str, workdate: str = None) -> List[SiteUserData]:
|
||||||
|
"""
|
||||||
|
获取站点用户数据
|
||||||
|
"""
|
||||||
|
return SiteUserData.get_by_domain(self._db, domain=domain, workdate=workdate)
|
||||||
|
|
||||||
|
def get_userdata_by_date(self, date: str) -> List[SiteUserData]:
|
||||||
|
"""
|
||||||
|
获取站点用户数据
|
||||||
|
"""
|
||||||
|
return SiteUserData.get_by_date(self._db, date)
|
||||||
|
|
||||||
|
def get_userdata_latest(self) -> List[SiteUserData]:
|
||||||
|
"""
|
||||||
|
获取站点最新数据
|
||||||
|
"""
|
||||||
|
return SiteUserData.get_latest(self._db)
|
||||||
|
|
||||||
|
def get_icon_by_domain(self, domain: str) -> SiteIcon:
|
||||||
|
"""
|
||||||
|
按域名获取站点图标
|
||||||
|
"""
|
||||||
|
return SiteIcon.get_by_domain(self._db, domain)
|
||||||
|
|
||||||
|
def update_icon(self, name: str, domain: str, icon_url: str, icon_base64: str) -> bool:
|
||||||
|
"""
|
||||||
|
更新站点图标
|
||||||
|
"""
|
||||||
|
icon_base64 = f"data:image/ico;base64,{icon_base64}" if icon_base64 else ""
|
||||||
|
siteicon = self.get_icon_by_domain(domain)
|
||||||
|
if not siteicon:
|
||||||
|
SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64).create(self._db)
|
||||||
|
elif icon_base64:
|
||||||
|
siteicon.update(self._db, {
|
||||||
|
"url": icon_url,
|
||||||
|
"base64": icon_base64
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def success(self, domain: str, seconds: int = None):
|
||||||
|
"""
|
||||||
|
站点访问成功
|
||||||
|
"""
|
||||||
|
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
sta = SiteStatistic.get_by_domain(self._db, domain)
|
||||||
|
if sta:
|
||||||
|
avg_seconds, note = None, {}
|
||||||
|
if seconds is not None:
|
||||||
|
note: dict = sta.note or {}
|
||||||
|
note[lst_date] = seconds or 1
|
||||||
|
avg_times = len(note.keys())
|
||||||
|
if avg_times > 10:
|
||||||
|
note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10])
|
||||||
|
avg_seconds = sum([v for v in note.values()]) // avg_times
|
||||||
|
sta.update(self._db, {
|
||||||
|
"success": sta.success + 1,
|
||||||
|
"seconds": avg_seconds or sta.seconds,
|
||||||
|
"lst_state": 0,
|
||||||
|
"lst_mod_date": lst_date,
|
||||||
|
"note": note or sta.note
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
note = {}
|
||||||
|
if seconds is not None:
|
||||||
|
note = {
|
||||||
|
lst_date: seconds or 1
|
||||||
|
}
|
||||||
|
SiteStatistic(
|
||||||
|
domain=domain,
|
||||||
|
success=1,
|
||||||
|
fail=0,
|
||||||
|
seconds=seconds or 1,
|
||||||
|
lst_state=0,
|
||||||
|
lst_mod_date=lst_date,
|
||||||
|
note=note
|
||||||
|
).create(self._db)
|
||||||
|
|
||||||
|
def fail(self, domain: str):
|
||||||
|
"""
|
||||||
|
站点访问失败
|
||||||
|
"""
|
||||||
|
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
sta = SiteStatistic.get_by_domain(self._db, domain)
|
||||||
|
if sta:
|
||||||
|
sta.update(self._db, {
|
||||||
|
"fail": sta.fail + 1,
|
||||||
|
"lst_state": 1,
|
||||||
|
"lst_mod_date": lst_date
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
SiteStatistic(
|
||||||
|
domain=domain,
|
||||||
|
success=0,
|
||||||
|
fail=1,
|
||||||
|
lst_state=1,
|
||||||
|
lst_mod_date=lst_date
|
||||||
|
).create(self._db)
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from app.db import DbOper
|
|
||||||
from app.db.models.siteicon import SiteIcon
|
|
||||||
|
|
||||||
|
|
||||||
class SiteIconOper(DbOper):
|
|
||||||
"""
|
|
||||||
站点管理
|
|
||||||
"""
|
|
||||||
|
|
||||||
def list(self) -> List[SiteIcon]:
|
|
||||||
"""
|
|
||||||
获取站点图标列表
|
|
||||||
"""
|
|
||||||
return SiteIcon.list(self._db)
|
|
||||||
|
|
||||||
def get_by_domain(self, domain: str) -> SiteIcon:
|
|
||||||
"""
|
|
||||||
按域名获取站点图标
|
|
||||||
"""
|
|
||||||
return SiteIcon.get_by_domain(self._db, domain)
|
|
||||||
|
|
||||||
def update_icon(self, name: str, domain: str, icon_url: str, icon_base64: str) -> bool:
|
|
||||||
"""
|
|
||||||
更新站点图标
|
|
||||||
"""
|
|
||||||
icon_base64 = f"data:image/ico;base64,{icon_base64}" if icon_base64 else ""
|
|
||||||
siteicon = self.get_by_domain(domain)
|
|
||||||
if not siteicon:
|
|
||||||
SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64).create(self._db)
|
|
||||||
elif icon_base64:
|
|
||||||
siteicon.update(self._db, {
|
|
||||||
"url": icon_url,
|
|
||||||
"base64": icon_base64
|
|
||||||
})
|
|
||||||
return True
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.db import DbOper
|
|
||||||
from app.db.models.sitestatistic import SiteStatistic
|
|
||||||
|
|
||||||
|
|
||||||
class SiteStatisticOper(DbOper):
|
|
||||||
"""
|
|
||||||
站点统计管理
|
|
||||||
"""
|
|
||||||
|
|
||||||
def success(self, domain: str, seconds: int = None):
|
|
||||||
"""
|
|
||||||
站点访问成功
|
|
||||||
"""
|
|
||||||
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
sta = SiteStatistic.get_by_domain(self._db, domain)
|
|
||||||
if sta:
|
|
||||||
avg_seconds, note = None, {}
|
|
||||||
if seconds is not None:
|
|
||||||
note: dict = json.loads(sta.note or "{}")
|
|
||||||
note[lst_date] = seconds or 1
|
|
||||||
avg_times = len(note.keys())
|
|
||||||
if avg_times > 10:
|
|
||||||
note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10])
|
|
||||||
avg_seconds = sum([v for v in note.values()]) // avg_times
|
|
||||||
sta.update(self._db, {
|
|
||||||
"success": sta.success + 1,
|
|
||||||
"seconds": avg_seconds or sta.seconds,
|
|
||||||
"lst_state": 0,
|
|
||||||
"lst_mod_date": lst_date,
|
|
||||||
"note": json.dumps(note) if note else sta.note
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
note = {}
|
|
||||||
if seconds is not None:
|
|
||||||
note = {
|
|
||||||
lst_date: seconds or 1
|
|
||||||
}
|
|
||||||
SiteStatistic(
|
|
||||||
domain=domain,
|
|
||||||
success=1,
|
|
||||||
fail=0,
|
|
||||||
seconds=seconds or 1,
|
|
||||||
lst_state=0,
|
|
||||||
lst_mod_date=lst_date,
|
|
||||||
note=json.dumps(note)
|
|
||||||
).create(self._db)
|
|
||||||
|
|
||||||
def fail(self, domain: str):
|
|
||||||
"""
|
|
||||||
站点访问失败
|
|
||||||
"""
|
|
||||||
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
sta = SiteStatistic.get_by_domain(self._db, domain)
|
|
||||||
if sta:
|
|
||||||
sta.update(self._db, {
|
|
||||||
"fail": sta.fail + 1,
|
|
||||||
"lst_state": 1,
|
|
||||||
"lst_mod_date": lst_date
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
SiteStatistic(
|
|
||||||
domain=domain,
|
|
||||||
success=0,
|
|
||||||
fail=1,
|
|
||||||
lst_state=1,
|
|
||||||
lst_mod_date=lst_date
|
|
||||||
).create(self._db)
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from typing import Tuple, List
|
from typing import Tuple, List
|
||||||
|
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.db import DbOper
|
from app.db import DbOper
|
||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
|
from app.db.models.subscribehistory import SubscribeHistory
|
||||||
|
|
||||||
|
|
||||||
class SubscribeOper(DbOper):
|
class SubscribeOper(DbOper):
|
||||||
@@ -21,9 +21,6 @@ class SubscribeOper(DbOper):
|
|||||||
doubanid=mediainfo.douban_id,
|
doubanid=mediainfo.douban_id,
|
||||||
season=kwargs.get('season'))
|
season=kwargs.get('season'))
|
||||||
if not subscribe:
|
if not subscribe:
|
||||||
if kwargs.get("sites") and not isinstance(kwargs.get("sites"), str):
|
|
||||||
kwargs["sites"] = json.dumps(kwargs.get("sites"))
|
|
||||||
|
|
||||||
subscribe = Subscribe(name=mediainfo.title,
|
subscribe = Subscribe(name=mediainfo.title,
|
||||||
year=mediainfo.year,
|
year=mediainfo.year,
|
||||||
type=mediainfo.type.value,
|
type=mediainfo.type.value,
|
||||||
@@ -106,3 +103,17 @@ class SubscribeOper(DbOper):
|
|||||||
获取指定类型的订阅
|
获取指定类型的订阅
|
||||||
"""
|
"""
|
||||||
return Subscribe.list_by_type(self._db, mtype=mtype, days=days)
|
return Subscribe.list_by_type(self._db, mtype=mtype, days=days)
|
||||||
|
|
||||||
|
def add_history(self, **kwargs):
|
||||||
|
"""
|
||||||
|
新增订阅
|
||||||
|
"""
|
||||||
|
# 去除kwargs中 SubscribeHistory 没有的字段
|
||||||
|
kwargs = {k: v for k, v in kwargs.items() if hasattr(SubscribeHistory, k)}
|
||||||
|
# 更新完成订阅时间
|
||||||
|
kwargs.update({"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())})
|
||||||
|
# 去掉主键
|
||||||
|
if "id" in kwargs:
|
||||||
|
kwargs.pop("id")
|
||||||
|
subscribe = SubscribeHistory(**kwargs)
|
||||||
|
subscribe.create(self._db)
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
from app.db import DbOper
|
|
||||||
from app.db.models.subscribehistory import SubscribeHistory
|
|
||||||
|
|
||||||
|
|
||||||
class SubscribeHistoryOper(DbOper):
|
|
||||||
"""
|
|
||||||
订阅历史管理
|
|
||||||
"""
|
|
||||||
|
|
||||||
def add(self, **kwargs):
|
|
||||||
"""
|
|
||||||
新增订阅
|
|
||||||
"""
|
|
||||||
# 去除kwargs中 SubscribeHistory 没有的字段
|
|
||||||
kwargs = {k: v for k, v in kwargs.items() if hasattr(SubscribeHistory, k)}
|
|
||||||
# 更新完成订阅时间
|
|
||||||
kwargs.update({"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())})
|
|
||||||
# 去掉主键
|
|
||||||
if "id" in kwargs:
|
|
||||||
kwargs.pop("id")
|
|
||||||
subscribe = SubscribeHistory(**kwargs)
|
|
||||||
subscribe.create(self._db)
|
|
||||||
|
|
||||||
def list_by_type(self, mtype: str, page: int = 1, count: int = 30) -> SubscribeHistory:
|
|
||||||
"""
|
|
||||||
获取指定类型的订阅
|
|
||||||
"""
|
|
||||||
return SubscribeHistory.list_by_type(self._db, mtype=mtype, page=page, count=count)
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import json
|
|
||||||
from typing import Any, Union
|
from typing import Any, Union
|
||||||
|
|
||||||
from app.db import DbOper
|
from app.db import DbOper
|
||||||
from app.db.models.systemconfig import SystemConfig
|
from app.db.models.systemconfig import SystemConfig
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
from app.utils.object import ObjectUtils
|
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
|
|
||||||
@@ -18,10 +16,7 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
for item in SystemConfig.list(self._db):
|
for item in SystemConfig.list(self._db):
|
||||||
if ObjectUtils.is_obj(item.value):
|
self.__SYSTEMCONF[item.key] = item.value
|
||||||
self.__SYSTEMCONF[item.key] = json.loads(item.value)
|
|
||||||
else:
|
|
||||||
self.__SYSTEMCONF[item.key] = item.value
|
|
||||||
|
|
||||||
def set(self, key: Union[str, SystemConfigKey], value: Any):
|
def set(self, key: Union[str, SystemConfigKey], value: Any):
|
||||||
"""
|
"""
|
||||||
@@ -31,11 +26,6 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
|||||||
key = key.value
|
key = key.value
|
||||||
# 更新内存
|
# 更新内存
|
||||||
self.__SYSTEMCONF[key] = value
|
self.__SYSTEMCONF[key] = value
|
||||||
# 写入数据库
|
|
||||||
if ObjectUtils.is_obj(value):
|
|
||||||
value = json.dumps(value)
|
|
||||||
elif value is None:
|
|
||||||
value = ''
|
|
||||||
conf = SystemConfig.get_by_key(self._db, key)
|
conf = SystemConfig.get_by_key(self._db, key)
|
||||||
if conf:
|
if conf:
|
||||||
if value:
|
if value:
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.db import DbOper
|
from app.db import DbOper
|
||||||
from app.db.models.transferhistory import TransferHistory
|
from app.db.models.transferhistory import TransferHistory
|
||||||
from app.schemas import TransferInfo
|
from app.schemas import TransferInfo, FileItem
|
||||||
|
|
||||||
|
|
||||||
class TransferHistoryOper(DbOper):
|
class TransferHistoryOper(DbOper):
|
||||||
@@ -29,12 +27,13 @@ class TransferHistoryOper(DbOper):
|
|||||||
"""
|
"""
|
||||||
return TransferHistory.list_by_title(self._db, title)
|
return TransferHistory.list_by_title(self._db, title)
|
||||||
|
|
||||||
def get_by_src(self, src: str) -> TransferHistory:
|
def get_by_src(self, src: str, storage: str = None) -> TransferHistory:
|
||||||
"""
|
"""
|
||||||
按源查询转移记录
|
按源查询转移记录
|
||||||
:param src: 数据key
|
:param src: 数据key
|
||||||
|
:param storage: 存储类型
|
||||||
"""
|
"""
|
||||||
return TransferHistory.get_by_src(self._db, src)
|
return TransferHistory.get_by_src(self._db, src, storage)
|
||||||
|
|
||||||
def get_by_dest(self, dest: str) -> TransferHistory:
|
def get_by_dest(self, dest: str) -> TransferHistory:
|
||||||
"""
|
"""
|
||||||
@@ -119,15 +118,19 @@ class TransferHistoryOper(DbOper):
|
|||||||
"""
|
"""
|
||||||
TransferHistory.update_download_hash(self._db, historyid, download_hash)
|
TransferHistory.update_download_hash(self._db, historyid, download_hash)
|
||||||
|
|
||||||
def add_success(self, src_path: Path, mode: str, meta: MetaBase,
|
def add_success(self, fileitem: FileItem, mode: str, meta: MetaBase,
|
||||||
mediainfo: MediaInfo, transferinfo: TransferInfo,
|
mediainfo: MediaInfo, transferinfo: TransferInfo,
|
||||||
download_hash: str = None):
|
download_hash: str = None):
|
||||||
"""
|
"""
|
||||||
新增转移成功历史记录
|
新增转移成功历史记录
|
||||||
"""
|
"""
|
||||||
self.add_force(
|
self.add_force(
|
||||||
src=str(src_path),
|
src=fileitem.path,
|
||||||
dest=str(transferinfo.target_path or ''),
|
src_storage=fileitem.storage,
|
||||||
|
src_fileitem=fileitem.dict(),
|
||||||
|
dest=transferinfo.target_item.path if transferinfo.target_item else None,
|
||||||
|
dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None,
|
||||||
|
dest_fileitem=transferinfo.target_item.dict() if transferinfo.target_item else None,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
type=mediainfo.type.value,
|
type=mediainfo.type.value,
|
||||||
category=mediainfo.category,
|
category=mediainfo.category,
|
||||||
@@ -142,18 +145,22 @@ class TransferHistoryOper(DbOper):
|
|||||||
image=mediainfo.get_poster_image(),
|
image=mediainfo.get_poster_image(),
|
||||||
download_hash=download_hash,
|
download_hash=download_hash,
|
||||||
status=1,
|
status=1,
|
||||||
files=json.dumps(transferinfo.file_list)
|
files=transferinfo.file_list
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_fail(self, src_path: Path, mode: str, meta: MetaBase, mediainfo: MediaInfo = None,
|
def add_fail(self, fileitem: FileItem, mode: str, meta: MetaBase, mediainfo: MediaInfo = None,
|
||||||
transferinfo: TransferInfo = None, download_hash: str = None):
|
transferinfo: TransferInfo = None, download_hash: str = None):
|
||||||
"""
|
"""
|
||||||
新增转移失败历史记录
|
新增转移失败历史记录
|
||||||
"""
|
"""
|
||||||
if mediainfo and transferinfo:
|
if mediainfo and transferinfo:
|
||||||
his = self.add_force(
|
his = self.add_force(
|
||||||
src=str(src_path),
|
src=fileitem.path,
|
||||||
dest=str(transferinfo.target_path or ''),
|
src_storage=fileitem.storage,
|
||||||
|
src_fileitem=fileitem.dict(),
|
||||||
|
dest=transferinfo.target_item.path if transferinfo.target_item else None,
|
||||||
|
dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None,
|
||||||
|
dest_fileitem=transferinfo.target_item.dict() if transferinfo.target_item else None,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
type=mediainfo.type.value,
|
type=mediainfo.type.value,
|
||||||
category=mediainfo.category,
|
category=mediainfo.category,
|
||||||
@@ -169,13 +176,15 @@ class TransferHistoryOper(DbOper):
|
|||||||
download_hash=download_hash,
|
download_hash=download_hash,
|
||||||
status=0,
|
status=0,
|
||||||
errmsg=transferinfo.message or '未知错误',
|
errmsg=transferinfo.message or '未知错误',
|
||||||
files=json.dumps(transferinfo.file_list)
|
files=transferinfo.file_list
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
his = self.add_force(
|
his = self.add_force(
|
||||||
title=meta.name,
|
title=meta.name,
|
||||||
year=meta.year,
|
year=meta.year,
|
||||||
src=str(src_path),
|
src=fileitem.path,
|
||||||
|
src_storage=fileitem.storage,
|
||||||
|
src_fileitem=fileitem.dict(),
|
||||||
mode=mode,
|
mode=mode,
|
||||||
seasons=meta.season,
|
seasons=meta.season,
|
||||||
episodes=meta.episode,
|
episodes=meta.episode,
|
||||||
|
|||||||
113
app/db/user_oper.py
Normal file
113
app/db/user_oper.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.core.security import verify_token
|
||||||
|
from app.db import DbOper, get_db
|
||||||
|
from app.db.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
token_data: schemas.TokenPayload = Depends(verify_token)
|
||||||
|
) -> User:
|
||||||
|
"""
|
||||||
|
获取当前用户
|
||||||
|
"""
|
||||||
|
user = User.get(db, rid=token_data.sub)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=403, detail="用户不存在")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_active_user(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> User:
|
||||||
|
"""
|
||||||
|
获取当前激活用户
|
||||||
|
"""
|
||||||
|
if not current_user.is_active:
|
||||||
|
raise HTTPException(status_code=403, detail="用户未激活")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_active_superuser(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> User:
|
||||||
|
"""
|
||||||
|
获取当前激活超级管理员
|
||||||
|
"""
|
||||||
|
if not current_user.is_superuser:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="用户权限不足"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
class UserOper(DbOper):
|
||||||
|
"""
|
||||||
|
用户管理
|
||||||
|
"""
|
||||||
|
|
||||||
|
def add(self, **kwargs):
|
||||||
|
"""
|
||||||
|
新增用户
|
||||||
|
"""
|
||||||
|
user = User(**kwargs)
|
||||||
|
user.create(self._db)
|
||||||
|
|
||||||
|
def get_by_name(self, name: str) -> User:
|
||||||
|
"""
|
||||||
|
根据用户名获取用户
|
||||||
|
"""
|
||||||
|
return User.get_by_name(self._db, name)
|
||||||
|
|
||||||
|
def get_permissions(self, name: str) -> dict:
|
||||||
|
"""
|
||||||
|
获取用户权限
|
||||||
|
{
|
||||||
|
"admin": "管理员",
|
||||||
|
"usermanage": "用户管理",
|
||||||
|
"dashboard": "仪表板",
|
||||||
|
"ranking": "推荐榜单",
|
||||||
|
"resource": {
|
||||||
|
"search": "搜索站点资源",
|
||||||
|
"download": "下载站点资源",
|
||||||
|
},
|
||||||
|
"subscribe": {
|
||||||
|
"request": "提交订阅请求",
|
||||||
|
"autopass": "订阅请求自动批准"
|
||||||
|
"approve": "审批订阅请求",
|
||||||
|
"calendar": "查看订阅日历",
|
||||||
|
"manage": "管理所有订阅"
|
||||||
|
},
|
||||||
|
"downloading": {
|
||||||
|
"view": "查看正在下载任务",
|
||||||
|
"manager": "管理正在下载任务"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
user = User.get_by_name(self._db, name)
|
||||||
|
if user:
|
||||||
|
return user.permissions or {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_settings(self, name: str) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
获取用户个性化设置,返回None表示用户不存在
|
||||||
|
"""
|
||||||
|
user = User.get_by_name(self._db, name)
|
||||||
|
if user:
|
||||||
|
return user.settings or {}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_setting(self, name: str, key: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取用户个性化设置
|
||||||
|
"""
|
||||||
|
settings = self.get_settings(name)
|
||||||
|
if settings:
|
||||||
|
return settings.get(key)
|
||||||
|
return None
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
from fastapi import Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app import schemas
|
|
||||||
from app.core.security import verify_token
|
|
||||||
from app.db import get_db
|
|
||||||
from app.db.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
token_data: schemas.TokenPayload = Depends(verify_token)
|
|
||||||
) -> User:
|
|
||||||
user = User.get(db, rid=token_data.sub)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=403, detail="用户不存在")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_active_user(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
) -> User:
|
|
||||||
if not current_user.is_active:
|
|
||||||
raise HTTPException(status_code=403, detail="用户未激活")
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_active_superuser(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
) -> User:
|
|
||||||
if not current_user.is_superuser:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail="用户权限不足"
|
|
||||||
)
|
|
||||||
return current_user
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import json
|
|
||||||
from typing import Any, Union, Dict, Optional
|
from typing import Any, Union, Dict, Optional
|
||||||
|
|
||||||
from app.db import DbOper
|
from app.db import DbOper
|
||||||
from app.db.models.userconfig import UserConfig
|
from app.db.models.userconfig import UserConfig
|
||||||
from app.schemas.types import UserConfigKey
|
from app.schemas.types import UserConfigKey
|
||||||
from app.utils.object import ObjectUtils
|
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
|
|
||||||
@@ -18,8 +16,7 @@ class UserConfigOper(DbOper, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
for item in UserConfig.list(self._db):
|
for item in UserConfig.list(self._db):
|
||||||
value = json.loads(item.value) if ObjectUtils.is_obj(item.value) else item.value
|
self.__set_config_cache(username=item.username, key=item.key, value=item.value)
|
||||||
self.__set_config_cache(username=item.username, key=item.key, value=value)
|
|
||||||
|
|
||||||
def set(self, username: str, key: Union[str, UserConfigKey], value: Any):
|
def set(self, username: str, key: Union[str, UserConfigKey], value: Any):
|
||||||
"""
|
"""
|
||||||
@@ -30,10 +27,6 @@ class UserConfigOper(DbOper, metaclass=Singleton):
|
|||||||
# 更新内存
|
# 更新内存
|
||||||
self.__set_config_cache(username=username, key=key, value=value)
|
self.__set_config_cache(username=username, key=key, value=value)
|
||||||
# 写入数据库
|
# 写入数据库
|
||||||
if ObjectUtils.is_obj(value):
|
|
||||||
value = json.dumps(value)
|
|
||||||
elif value is None:
|
|
||||||
value = ''
|
|
||||||
conf = UserConfig.get_by_key(db=self._db, username=username, key=key)
|
conf = UserConfig.get_by_key(db=self._db, username=username, key=key)
|
||||||
if conf:
|
if conf:
|
||||||
if value:
|
if value:
|
||||||
|
|||||||
42
app/db/userrequest_oper.py
Normal file
42
app/db/userrequest_oper.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.db import DbOper
|
||||||
|
from app.db.models.userrequest import UserRequest
|
||||||
|
|
||||||
|
|
||||||
|
class UserRequestOper(DbOper):
|
||||||
|
"""
|
||||||
|
用户请求管理
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_need_approve(self) -> Optional[UserRequest]:
|
||||||
|
"""
|
||||||
|
获取待审批申请
|
||||||
|
"""
|
||||||
|
return UserRequest.get_by_status(self._db, 0)
|
||||||
|
|
||||||
|
def get_my_requests(self, username: str) -> Optional[UserRequest]:
|
||||||
|
"""
|
||||||
|
获取我的申请
|
||||||
|
"""
|
||||||
|
return UserRequest.get_by_req_user(self._db, username)
|
||||||
|
|
||||||
|
def approve(self, rid: int) -> bool:
|
||||||
|
"""
|
||||||
|
审批申请
|
||||||
|
"""
|
||||||
|
user_request = UserRequest.get(self._db, rid)
|
||||||
|
if user_request:
|
||||||
|
user_request.update(self._db, {"status": 1})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def deny(self, rid: int) -> bool:
|
||||||
|
"""
|
||||||
|
拒绝申请
|
||||||
|
"""
|
||||||
|
user_request = UserRequest.get(self._db, rid)
|
||||||
|
if user_request:
|
||||||
|
user_request.update(self._db, {"status": 2})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
31
app/factory.py
Normal file
31
app/factory.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.startup.lifecycle import lifespan
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""
|
||||||
|
创建并配置 FastAPI 应用实例。
|
||||||
|
"""
|
||||||
|
_app = FastAPI(
|
||||||
|
title=settings.PROJECT_NAME,
|
||||||
|
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# 配置 CORS 中间件
|
||||||
|
_app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.ALLOWED_HOSTS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return _app
|
||||||
|
|
||||||
|
|
||||||
|
# 创建 FastAPI 应用实例
|
||||||
|
app = create_app()
|
||||||
@@ -1,620 +0,0 @@
|
|||||||
import base64
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional, Tuple, List
|
|
||||||
|
|
||||||
from requests import Response
|
|
||||||
|
|
||||||
from app import schemas
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
|
||||||
from app.log import logger
|
|
||||||
from app.schemas.types import SystemConfigKey
|
|
||||||
from app.utils.http import RequestUtils
|
|
||||||
from app.utils.string import StringUtils
|
|
||||||
from app.utils.system import SystemUtils
|
|
||||||
|
|
||||||
|
|
||||||
class AliyunHelper:
|
|
||||||
"""
|
|
||||||
阿里云相关操作
|
|
||||||
"""
|
|
||||||
|
|
||||||
_X_SIGNATURE = ('f4b7bed5d8524a04051bd2da876dd79afe922b8205226d65855d02b267422adb1'
|
|
||||||
'e0d8a816b021eaf5c36d101892180f79df655c5712b348c2a540ca136e6b22001')
|
|
||||||
|
|
||||||
_X_PUBLIC_KEY = ('04d9d2319e0480c840efeeb75751b86d0db0c5b9e72c6260a1d846958adceaf9d'
|
|
||||||
'ee789cab7472741d23aafc1a9c591f72e7ee77578656e6c8588098dea1488ac2a')
|
|
||||||
|
|
||||||
# 生成二维码
|
|
||||||
qrcode_url = ("https://passport.aliyundrive.com/newlogin/qrcode/generate.do?"
|
|
||||||
"appName=aliyun_drive&fromSite=52&appEntrance=web&isMobile=false"
|
|
||||||
"&lang=zh_CN&returnUrl=&bizParams=&_bx-v=2.0.31")
|
|
||||||
# 二维码登录确认
|
|
||||||
check_url = "https://passport.aliyundrive.com/newlogin/qrcode/query.do?appName=aliyun_drive&fromSite=52&_bx-v=2.0.31"
|
|
||||||
# 更新访问令牌
|
|
||||||
update_accessstoken_url = "https://auth.aliyundrive.com/v2/account/token"
|
|
||||||
# 创建会话
|
|
||||||
create_session_url = "https://api.aliyundrive.com/users/v1/users/device/create_session"
|
|
||||||
# 用户信息
|
|
||||||
user_info_url = "https://user.aliyundrive.com/v2/user/get"
|
|
||||||
# 浏览文件
|
|
||||||
list_file_url = "https://api.aliyundrive.com/adrive/v3/file/list"
|
|
||||||
# 创建目录或文件
|
|
||||||
create_folder_file_url = "https://api.aliyundrive.com/adrive/v2/file/createWithFolders"
|
|
||||||
# 文件详情
|
|
||||||
file_detail_url = "https://api.aliyundrive.com/v2/file/get"
|
|
||||||
# 删除文件
|
|
||||||
delete_file_url = " https://api.aliyundrive.com/v2/recyclebin/trash"
|
|
||||||
# 文件重命名
|
|
||||||
rename_file_url = "https://api.aliyundrive.com/v3/file/update"
|
|
||||||
# 获取下载链接
|
|
||||||
download_url = "https://api.aliyundrive.com/v2/file/get_download_url"
|
|
||||||
# 移动文件
|
|
||||||
move_file_url = "https://api.aliyundrive.com/v2/file/move"
|
|
||||||
# 上传文件完成
|
|
||||||
upload_file_complete_url = "https://api.aliyundrive.com/v2/file/complete"
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.systemconfig = SystemConfigOper()
|
|
||||||
|
|
||||||
def __handle_error(self, res: Response, apiname: str, action: bool = True):
|
|
||||||
"""
|
|
||||||
统一处理和打印错误信息
|
|
||||||
"""
|
|
||||||
if res is None:
|
|
||||||
logger.warn("无法连接到阿里云盘!")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
result = res.json()
|
|
||||||
except Exception as err:
|
|
||||||
logger.error(f"解析阿里云盘返回数据失败:{str(err)}")
|
|
||||||
return
|
|
||||||
code = result.get("code")
|
|
||||||
message = result.get("message")
|
|
||||||
display_message = result.get("display_message")
|
|
||||||
if code or message:
|
|
||||||
logger.warn(f"Aliyun {apiname}失败:{code} - {display_message or message}")
|
|
||||||
if action:
|
|
||||||
if code == "DeviceSessionSignatureInvalid":
|
|
||||||
logger.warn("设备已失效,正在重新建立会话...")
|
|
||||||
self.__create_session(self.__get_headers(self.__auth_params))
|
|
||||||
if code == "UserDeviceOffline":
|
|
||||||
logger.warn("设备已离线,尝试重新登录,如仍报错请检查阿里云盘绑定设备数量是否超限!")
|
|
||||||
self.__create_session(self.__get_headers(self.__auth_params))
|
|
||||||
if code == "AccessTokenInvalid":
|
|
||||||
logger.warn("访问令牌已失效,正在刷新令牌...")
|
|
||||||
self.__update_accesstoken(self.__auth_params, self.__auth_params.get("refreshToken"))
|
|
||||||
else:
|
|
||||||
logger.info(f"Aliyun {apiname}成功")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __auth_params(self):
|
|
||||||
"""
|
|
||||||
获取阿里云盘认证参数并初始化参数格式
|
|
||||||
"""
|
|
||||||
return self.systemconfig.get(SystemConfigKey.UserAliyunParams) or {}
|
|
||||||
|
|
||||||
def __update_params(self, params: dict):
|
|
||||||
"""
|
|
||||||
设置阿里云盘认证参数
|
|
||||||
"""
|
|
||||||
current_params = self.__auth_params
|
|
||||||
current_params.update(params)
|
|
||||||
self.systemconfig.set(SystemConfigKey.UserAliyunParams, current_params)
|
|
||||||
|
|
||||||
def __clear_params(self):
|
|
||||||
"""
|
|
||||||
清除阿里云盘认证参数
|
|
||||||
"""
|
|
||||||
self.systemconfig.delete(SystemConfigKey.UserAliyunParams)
|
|
||||||
|
|
||||||
def generate_qrcode(self) -> Optional[Tuple[dict, str]]:
|
|
||||||
"""
|
|
||||||
生成二维码
|
|
||||||
"""
|
|
||||||
res = RequestUtils(timeout=10).get_res(self.qrcode_url)
|
|
||||||
if res:
|
|
||||||
data = res.json().get("content", {}).get("data")
|
|
||||||
return {
|
|
||||||
"codeContent": data.get("codeContent"),
|
|
||||||
"ck": data.get("ck"),
|
|
||||||
"t": data.get("t")
|
|
||||||
}, ""
|
|
||||||
elif res is not None:
|
|
||||||
self.__handle_error(res, "生成二维码")
|
|
||||||
return {}, f"请求阿里云盘二维码失败:{res.status_code} - {res.reason}"
|
|
||||||
return {}, f"请求阿里云盘二维码失败:无法连接!"
|
|
||||||
|
|
||||||
def check_login(self, ck: str, t: str) -> Optional[Tuple[dict, str]]:
|
|
||||||
"""
|
|
||||||
二维码登录确认
|
|
||||||
"""
|
|
||||||
params = {
|
|
||||||
"t": t,
|
|
||||||
"ck": ck,
|
|
||||||
"appName": "aliyun_drive",
|
|
||||||
"appEntrance": "web",
|
|
||||||
"isMobile": "false",
|
|
||||||
"lang": "zh_CN",
|
|
||||||
"returnUrl": "",
|
|
||||||
"fromSite": "52",
|
|
||||||
"bizParams": "",
|
|
||||||
"navlanguage": "zh-CN",
|
|
||||||
"navPlatform": "MacIntel",
|
|
||||||
}
|
|
||||||
|
|
||||||
body = "&".join([f"{key}={value}" for key, value in params.items()])
|
|
||||||
|
|
||||||
status = {
|
|
||||||
"NEW": "请用阿里云盘 App 扫码",
|
|
||||||
"SCANED": "请在手机上确认",
|
|
||||||
"EXPIRED": "二维码已过期",
|
|
||||||
"CANCELED": "已取消",
|
|
||||||
"CONFIRMED": "已确认",
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
||||||
}
|
|
||||||
|
|
||||||
res = RequestUtils(headers=headers, timeout=5).post_res(self.check_url, data=body)
|
|
||||||
if res:
|
|
||||||
data = res.json().get("content", {}).get("data") or {}
|
|
||||||
qrCodeStatus = data.get("qrCodeStatus")
|
|
||||||
data["tip"] = status.get(qrCodeStatus) or "未知"
|
|
||||||
if data.get("bizExt"):
|
|
||||||
try:
|
|
||||||
bizExt = json.loads(base64.b64decode(data["bizExt"]).decode('GBK'))
|
|
||||||
pds_login_result = bizExt.get("pds_login_result")
|
|
||||||
if pds_login_result:
|
|
||||||
data.pop('bizExt')
|
|
||||||
data.update({
|
|
||||||
'userId': pds_login_result.get('userId'),
|
|
||||||
'expiresIn': pds_login_result.get('expiresIn'),
|
|
||||||
'nickName': pds_login_result.get('nickName'),
|
|
||||||
'avatar': pds_login_result.get('avatar'),
|
|
||||||
'tokenType': pds_login_result.get('tokenType'),
|
|
||||||
"refreshToken": pds_login_result.get('refreshToken'),
|
|
||||||
"accessToken": pds_login_result.get('accessToken'),
|
|
||||||
"defaultDriveId": pds_login_result.get('defaultDriveId'),
|
|
||||||
"updateTime": time.time(),
|
|
||||||
})
|
|
||||||
self.__update_params(data)
|
|
||||||
self.user_info()
|
|
||||||
except Exception as e:
|
|
||||||
return {}, f"bizExt 解码失败:{str(e)}"
|
|
||||||
return data, ""
|
|
||||||
elif res is not None:
|
|
||||||
self.__handle_error(res, "登录确认")
|
|
||||||
return {}, f"阿里云盘登录确认失败:{res.status_code} - {res.reason}"
|
|
||||||
return {}, "阿里云盘登录确认失败:无法连接!"
|
|
||||||
|
|
||||||
def __update_accesstoken(self, params: dict, refresh_token: str) -> bool:
|
|
||||||
"""
|
|
||||||
更新阿里云盘访问令牌
|
|
||||||
"""
|
|
||||||
headers = self.__get_headers(params)
|
|
||||||
res = RequestUtils(headers=headers, timeout=10).post_res(
|
|
||||||
self.update_accessstoken_url, json={
|
|
||||||
"refresh_token": refresh_token,
|
|
||||||
"grant_type": "refresh_token"
|
|
||||||
})
|
|
||||||
if res:
|
|
||||||
data = res.json()
|
|
||||||
code = data.get("code")
|
|
||||||
if code in ["RefreshTokenExpired", "InvalidParameter.RefreshToken"]:
|
|
||||||
logger.warn("刷新令牌已过期,请重新登录!")
|
|
||||||
self.__clear_params()
|
|
||||||
return False
|
|
||||||
self.__update_params({
|
|
||||||
"accessToken": data.get('access_token'),
|
|
||||||
"expiresIn": data.get('expires_in'),
|
|
||||||
"updateTime": time.time()
|
|
||||||
})
|
|
||||||
logger.info(f"阿里云盘访问令牌已更新,accessToken={data.get('access_token')}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.__handle_error(res, "更新令牌", action=False)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def __create_session(self, headers: dict):
|
|
||||||
"""
|
|
||||||
创建会话
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __os_name():
|
|
||||||
"""
|
|
||||||
获取操作系统名称
|
|
||||||
"""
|
|
||||||
if SystemUtils.is_windows():
|
|
||||||
return 'Windows 操作系统'
|
|
||||||
elif SystemUtils.is_macos():
|
|
||||||
return 'MacOS 操作系统'
|
|
||||||
else:
|
|
||||||
return '类 Unix 操作系统'
|
|
||||||
|
|
||||||
res = RequestUtils(headers=headers, timeout=5).post_res(self.create_session_url, json={
|
|
||||||
'deviceName': f'MoviePilot {SystemUtils.platform}',
|
|
||||||
'modelName': __os_name(),
|
|
||||||
'pubKey': self._X_PUBLIC_KEY,
|
|
||||||
})
|
|
||||||
self.__handle_error(res, "创建会话", action=False)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __access_params(self) -> Optional[dict]:
|
|
||||||
"""
|
|
||||||
获取阿里云盘访问参数,如果超时则更新后返回
|
|
||||||
"""
|
|
||||||
params = self.__auth_params
|
|
||||||
if not params:
|
|
||||||
logger.warn("阿里云盘访问令牌不存在,请先扫码登录!")
|
|
||||||
return None
|
|
||||||
expires_in = params.get("expiresIn")
|
|
||||||
update_time = params.get("updateTime")
|
|
||||||
refresh_token = params.get("refreshToken")
|
|
||||||
if not expires_in or not update_time or not refresh_token:
|
|
||||||
logger.warn("阿里云盘访问令牌参数错误,请重新扫码登录!")
|
|
||||||
self.__clear_params()
|
|
||||||
return None
|
|
||||||
# 是否需要更新设备信息
|
|
||||||
update_device = False
|
|
||||||
# 判断访问令牌是否过期
|
|
||||||
if (time.time() - update_time) >= expires_in:
|
|
||||||
logger.info("阿里云盘访问令牌已过期,正在更新...")
|
|
||||||
if not self.__update_accesstoken(params, refresh_token):
|
|
||||||
# 更新失败
|
|
||||||
return None
|
|
||||||
update_device = True
|
|
||||||
# 生成设备ID
|
|
||||||
x_device_id = params.get("x_device_id")
|
|
||||||
if not x_device_id:
|
|
||||||
x_device_id = uuid.uuid4().hex
|
|
||||||
params['x_device_id'] = x_device_id
|
|
||||||
self.__update_params({"x_device_id": x_device_id})
|
|
||||||
update_device = True
|
|
||||||
# 更新设备信息重新创建会话
|
|
||||||
if update_device:
|
|
||||||
self.__create_session(self.__get_headers(params))
|
|
||||||
return params
|
|
||||||
|
|
||||||
def __get_headers(self, params: dict):
|
|
||||||
"""
|
|
||||||
获取请求头
|
|
||||||
"""
|
|
||||||
if not params:
|
|
||||||
return {}
|
|
||||||
return {
|
|
||||||
"Authorization": f"Bearer {params.get('accessToken')}",
|
|
||||||
"Content-Type": "application/json;charset=UTF-8",
|
|
||||||
"Accept": "application/json, text/plain, */*",
|
|
||||||
"Referer": "https://www.alipan.com/",
|
|
||||||
"User-Agent": settings.USER_AGENT,
|
|
||||||
"X-Canary": "client=web,app=adrive,version=v4.9.0",
|
|
||||||
"x-device-id": params.get('x_device_id'),
|
|
||||||
"x-signature": self._X_SIGNATURE
|
|
||||||
}
|
|
||||||
|
|
||||||
def user_info(self) -> dict:
|
|
||||||
"""
|
|
||||||
获取用户信息(drive_id等)
|
|
||||||
"""
|
|
||||||
params = self.__access_params
|
|
||||||
if not params:
|
|
||||||
return {}
|
|
||||||
headers = self.__get_headers(params)
|
|
||||||
res = RequestUtils(headers=headers, timeout=10).post_res(self.user_info_url)
|
|
||||||
if res:
|
|
||||||
result = res.json()
|
|
||||||
self.__update_params({
|
|
||||||
"resourceDriveId": result.get("resource_drive_id"),
|
|
||||||
"backDriveId": result.get("backup_drive_id")
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
self.__handle_error(res, "获取用户信息")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def list(self, drive_id: str = None, parent_file_id: str = 'root', list_type: str = None,
|
|
||||||
limit: int = 100, order_by: str = 'updated_at', path: str = "/") -> List[schemas.FileItem]:
|
|
||||||
"""
|
|
||||||
浏览文件
|
|
||||||
limit 返回文件数量,默认 50,最大 100
|
|
||||||
order_by created_at/updated_at/name/size
|
|
||||||
parent_file_id 根目录为root
|
|
||||||
type all | file | folder
|
|
||||||
"""
|
|
||||||
params = self.__access_params
|
|
||||||
if not params:
|
|
||||||
return []
|
|
||||||
# 请求头
|
|
||||||
headers = self.__get_headers(params)
|
|
||||||
# 根目录处理
|
|
||||||
if not drive_id:
|
|
||||||
return [
|
|
||||||
schemas.FileItem(
|
|
||||||
fileid=parent_file_id,
|
|
||||||
drive_id=params.get("resourceDriveId"),
|
|
||||||
parent_fileid="root",
|
|
||||||
type="dir",
|
|
||||||
path="/资源库/",
|
|
||||||
name="资源库"
|
|
||||||
),
|
|
||||||
schemas.FileItem(
|
|
||||||
fileid=parent_file_id,
|
|
||||||
drive_id=params.get("backDriveId"),
|
|
||||||
parent_fileid="root",
|
|
||||||
type="dir",
|
|
||||||
path="/备份盘/",
|
|
||||||
name="备份盘"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
# 返回数据
|
|
||||||
ret_items = []
|
|
||||||
# 分页获取
|
|
||||||
next_marker = None
|
|
||||||
while True:
|
|
||||||
if not parent_file_id or parent_file_id == "/":
|
|
||||||
parent_file_id = "root"
|
|
||||||
res = RequestUtils(headers=headers, timeout=10).post_res(self.list_file_url, json={
|
|
||||||
"drive_id": drive_id,
|
|
||||||
"type": list_type,
|
|
||||||
"limit": limit,
|
|
||||||
"order_by": order_by,
|
|
||||||
"parent_file_id": parent_file_id,
|
|
||||||
"marker": next_marker
|
|
||||||
}, params={
|
|
||||||
'jsonmask': ('next_marker,items(name,file_id,drive_id,type,size,created_at,updated_at,'
|
|
||||||
'category,file_extension,parent_file_id,mime_type,starred,thumbnail,url,'
|
|
||||||
'streams_info,content_hash,user_tags,user_meta,trashed,video_media_metadata,'
|
|
||||||
'video_preview_metadata,sync_meta,sync_device_flag,sync_flag,punish_flag')
|
|
||||||
})
|
|
||||||
if res:
|
|
||||||
result = res.json()
|
|
||||||
items = result.get("items")
|
|
||||||
if not items:
|
|
||||||
break
|
|
||||||
# 合并数据
|
|
||||||
ret_items.extend(items)
|
|
||||||
next_marker = result.get("next_marker")
|
|
||||||
if not next_marker:
|
|
||||||
# 没有下一页
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
self.__handle_error(res, "浏览文件")
|
|
||||||
break
|
|
||||||
return [schemas.FileItem(
|
|
||||||
fileid=fileinfo.get("file_id"),
|
|
||||||
parent_fileid=fileinfo.get("parent_file_id"),
|
|
||||||
type="dir" if fileinfo.get("type") == "folder" else "file",
|
|
||||||
path=f"{path}{fileinfo.get('name')}" + ("/" if fileinfo.get("type") == "folder" else ""),
|
|
||||||
name=fileinfo.get("name"),
|
|
||||||
size=fileinfo.get("size"),
|
|
||||||
extension=fileinfo.get("file_extension"),
|
|
||||||
modify_time=StringUtils.str_to_timestamp(fileinfo.get("updated_at")),
|
|
||||||
thumbnail=fileinfo.get("thumbnail"),
|
|
||||||
drive_id=fileinfo.get("drive_id"),
|
|
||||||
) for fileinfo in ret_items]
|
|
||||||
|
|
||||||
def create_folder(self, drive_id: str, parent_file_id: str, name: str, path: str = "/") -> Optional[schemas.FileItem]:
|
|
||||||
"""
|
|
||||||
创建目录
|
|
||||||
"""
|
|
||||||
params = self.__access_params
|
|
||||||
if not params:
|
|
||||||
return None
|
|
||||||
headers = self.__get_headers(params)
|
|
||||||
res = RequestUtils(headers=headers, timeout=10).post_res(self.create_folder_file_url, json={
|
|
||||||
"drive_id": drive_id,
|
|
||||||
"parent_file_id": parent_file_id,
|
|
||||||
"name": name,
|
|
||||||
"check_name_mode": "refuse",
|
|
||||||
"type": "folder"
|
|
||||||
})
|
|
||||||
if res:
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"parent_file_id": "root",
|
|
||||||
"type": "folder",
|
|
||||||
"file_id": "6673f2c8a88344741bd64ad192d7512b92087719",
|
|
||||||
"domain_id": "bj29",
|
|
||||||
"drive_id": "39146740",
|
|
||||||
"file_name": "test",
|
|
||||||
"encrypt_mode": "none"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
result = res.json()
|
|
||||||
return schemas.FileItem(
|
|
||||||
fileid=result.get("file_id"),
|
|
||||||
drive_id=result.get("drive_id"),
|
|
||||||
parent_fileid=result.get("parent_file_id"),
|
|
||||||
type=result.get("type"),
|
|
||||||
name=result.get("file_name"),
|
|
||||||
path=f"{path}{result.get('file_name')}",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.__handle_error(res, "创建目录")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def delete(self, drive_id: str, file_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
删除文件
|
|
||||||
"""
|
|
||||||
params = self.__access_params
|
|
||||||
if not params:
|
|
||||||
return False
|
|
||||||
headers = self.__get_headers(params)
|
|
||||||
res = RequestUtils(headers=headers, timeout=10).post_res(self.delete_file_url, json={
|
|
||||||
"drive_id": drive_id,
|
|
||||||
"file_id": file_id
|
|
||||||
})
|
|
||||||
if res:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.__handle_error(res, "删除文件")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def detail(self, drive_id: str, file_id: str, path: str = "/") -> Optional[schemas.FileItem]:
|
|
||||||
"""
|
|
||||||
获取文件详情
|
|
||||||
"""
|
|
||||||
params = self.__access_params
|
|
||||||
if not params:
|
|
||||||
return None
|
|
||||||
headers = self.__get_headers(params)
|
|
||||||
res = RequestUtils(headers=headers, timeout=10).post_res(self.file_detail_url, json={
|
|
||||||
"drive_id": drive_id,
|
|
||||||
"file_id": file_id
|
|
||||||
})
|
|
||||||
if res:
|
|
||||||
result = res.json()
|
|
||||||
return schemas.FileItem(
|
|
||||||
fileid=result.get("file_id"),
|
|
||||||
drive_id=result.get("drive_id"),
|
|
||||||
parent_fileid=result.get("parent_file_id"),
|
|
||||||
type="file",
|
|
||||||
name=result.get("name"),
|
|
||||||
size=result.get("size"),
|
|
||||||
extension=result.get("file_extension"),
|
|
||||||
modify_time=StringUtils.str_to_timestamp(result.get("updated_at")),
|
|
||||||
thumbnail=result.get("thumbnail"),
|
|
||||||
path=f"{path}{result.get('name')}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.__handle_error(res, "获取文件详情")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def rename(self, drive_id: str, file_id: str, name: str) -> bool:
|
|
||||||
"""
|
|
||||||
重命名文件
|
|
||||||
"""
|
|
||||||
params = self.__access_params
|
|
||||||
if not params:
|
|
||||||
return False
|
|
||||||
headers = self.__get_headers(params)
|
|
||||||
res = RequestUtils(headers=headers, timeout=10).post_res(self.rename_file_url, json={
|
|
||||||
"drive_id": drive_id,
|
|
||||||
"file_id": file_id,
|
|
||||||
"name": name,
|
|
||||||
"check_name_mode": "refuse"
|
|
||||||
})
|
|
||||||
if res:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.__handle_error(res, "重命名文件")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def download(self, drive_id: str, file_id: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
获取下载链接
|
|
||||||
"""
|
|
||||||
params = self.__access_params
|
|
||||||
if not params:
|
|
||||||
return None
|
|
||||||
headers = self.__get_headers(params)
|
|
||||||
res = RequestUtils(headers=headers, timeout=10).post_res(self.download_url, json={
|
|
||||||
"drive_id": drive_id,
|
|
||||||
"file_id": file_id
|
|
||||||
})
|
|
||||||
if res:
|
|
||||||
return res.json().get("url")
|
|
||||||
else:
|
|
||||||
self.__handle_error(res, "获取下载链接")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def move(self, drive_id: str, file_id: str, target_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
移动文件
|
|
||||||
"""
|
|
||||||
params = self.__access_params
|
|
||||||
if not params:
|
|
||||||
return False
|
|
||||||
headers = self.__get_headers(params)
|
|
||||||
res = RequestUtils(headers=headers, timeout=10).post_res(self.move_file_url, json={
|
|
||||||
"drive_id": drive_id,
|
|
||||||
"file_id": file_id,
|
|
||||||
"to_parent_file_id": target_id,
|
|
||||||
"check_name_mode": "refuse"
|
|
||||||
})
|
|
||||||
if res:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.__handle_error(res, "移动文件")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def upload(self, drive_id: str, parent_file_id: str, file_path: Path) -> Optional[schemas.FileItem]:
|
|
||||||
"""
|
|
||||||
上传文件,并标记完成
|
|
||||||
"""
|
|
||||||
params = self.__access_params
|
|
||||||
if not params:
|
|
||||||
return None
|
|
||||||
headers = self.__get_headers(params)
|
|
||||||
res = RequestUtils(headers=headers, timeout=10).post_res(self.create_folder_file_url, json={
|
|
||||||
"drive_id": drive_id,
|
|
||||||
"parent_file_id": parent_file_id,
|
|
||||||
"name": file_path.name,
|
|
||||||
"check_name_mode": "refuse",
|
|
||||||
"create_scene": "file_upload",
|
|
||||||
"type": "file",
|
|
||||||
"part_info_list": [
|
|
||||||
{
|
|
||||||
"part_number": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"size": file_path.stat().st_size
|
|
||||||
})
|
|
||||||
if not res:
|
|
||||||
self.__handle_error(res, "创建文件")
|
|
||||||
return None
|
|
||||||
# 获取上传参数
|
|
||||||
result = res.json()
|
|
||||||
if result.get("exist"):
|
|
||||||
logger.info(f"文件{result.get('file_name')}已存在,无需上传")
|
|
||||||
return schemas.FileItem(
|
|
||||||
drive_id=result.get("drive_id"),
|
|
||||||
fileid=result.get("file_id"),
|
|
||||||
parent_fileid=result.get("parent_file_id"),
|
|
||||||
type="file",
|
|
||||||
name=result.get("file_name"),
|
|
||||||
path=f"{file_path.parent}/{result.get('file_name')}"
|
|
||||||
)
|
|
||||||
file_id = result.get("file_id")
|
|
||||||
upload_id = result.get("upload_id")
|
|
||||||
part_info_list = result.get("part_info_list")
|
|
||||||
if part_info_list:
|
|
||||||
# 上传地址
|
|
||||||
upload_url = part_info_list[0].get("upload_url")
|
|
||||||
# 上传文件
|
|
||||||
res = RequestUtils(headers={
|
|
||||||
"Content-Type": "",
|
|
||||||
"User-Agent": settings.USER_AGENT,
|
|
||||||
"Referer": "https://www.alipan.com/",
|
|
||||||
"Accept": "*/*",
|
|
||||||
}).put_res(upload_url, data=file_path.read_bytes())
|
|
||||||
if not res:
|
|
||||||
self.__handle_error(res, "上传文件")
|
|
||||||
return None
|
|
||||||
# 标记文件上传完毕
|
|
||||||
res = RequestUtils(headers=headers, timeout=10).post_res(self.upload_file_complete_url, json={
|
|
||||||
"drive_id": drive_id,
|
|
||||||
"file_id": file_id,
|
|
||||||
"upload_id": upload_id
|
|
||||||
})
|
|
||||||
if not res:
|
|
||||||
self.__handle_error(res, "标记上传状态")
|
|
||||||
return None
|
|
||||||
result = res.json()
|
|
||||||
return schemas.FileItem(
|
|
||||||
fileid=result.get("file_id"),
|
|
||||||
drive_id=result.get("drive_id"),
|
|
||||||
parent_fileid=result.get("parent_file_id"),
|
|
||||||
type="file",
|
|
||||||
name=result.get("name"),
|
|
||||||
path=f"{file_path.parent}/{result.get('name')}",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warn("上传文件失败:无法获取上传地址!")
|
|
||||||
return None
|
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
import json
|
import json
|
||||||
from hashlib import md5
|
|
||||||
from typing import Any, Dict, Tuple, Optional
|
from typing import Any, Dict, Tuple, Optional
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.utils.common import decrypt
|
from app.log import logger
|
||||||
|
from app.utils.crypto import CryptoJsUtils, HashUtils
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
from app.utils.url import UrlUtils
|
||||||
|
|
||||||
|
|
||||||
class CookieCloudHelper:
|
class CookieCloudHelper:
|
||||||
_ignore_cookies: list = ["CookieAutoDeleteBrowsingDataCleanup", "CookieAutoDeleteCleaningDiscarded"]
|
_ignore_cookies: list = ["CookieAutoDeleteBrowsingDataCleanup", "CookieAutoDeleteCleaningDiscarded"]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._sync_setting()
|
self.__sync_setting()
|
||||||
self._req = RequestUtils(content_type="application/json")
|
self._req = RequestUtils(content_type="application/json")
|
||||||
|
|
||||||
def _sync_setting(self):
|
def __sync_setting(self):
|
||||||
self._server = settings.COOKIECLOUD_HOST
|
"""
|
||||||
self._key = settings.COOKIECLOUD_KEY
|
同步CookieCloud配置项
|
||||||
self._password = settings.COOKIECLOUD_PASSWORD
|
"""
|
||||||
|
self._server = UrlUtils.standardize_base_url(settings.COOKIECLOUD_HOST)
|
||||||
|
self._key = StringUtils.safe_strip(settings.COOKIECLOUD_KEY)
|
||||||
|
self._password = StringUtils.safe_strip(settings.COOKIECLOUD_PASSWORD)
|
||||||
self._enable_local = settings.COOKIECLOUD_ENABLE_LOCAL
|
self._enable_local = settings.COOKIECLOUD_ENABLE_LOCAL
|
||||||
self._local_path = settings.COOKIE_PATH
|
self._local_path = settings.COOKIE_PATH
|
||||||
|
|
||||||
@@ -28,7 +32,7 @@ class CookieCloudHelper:
|
|||||||
:return: Cookie数据、错误信息
|
:return: Cookie数据、错误信息
|
||||||
"""
|
"""
|
||||||
# 更新为最新设置
|
# 更新为最新设置
|
||||||
self._sync_setting()
|
self.__sync_setting()
|
||||||
|
|
||||||
if ((not self._server and not self._enable_local)
|
if ((not self._server and not self._enable_local)
|
||||||
or not self._key
|
or not self._key
|
||||||
@@ -37,11 +41,11 @@ class CookieCloudHelper:
|
|||||||
|
|
||||||
if self._enable_local:
|
if self._enable_local:
|
||||||
# 开启本地服务时,从本地直接读取数据
|
# 开启本地服务时,从本地直接读取数据
|
||||||
result = self._load_local_encrypt_data(self._key)
|
result = self.__load_local_encrypt_data(self._key)
|
||||||
if not result:
|
if not result:
|
||||||
return {}, "未从本地CookieCloud服务加载到cookie数据,请检查服务器设置、用户KEY及加密密码是否正确"
|
return {}, "未从本地CookieCloud服务加载到cookie数据,请检查服务器设置、用户KEY及加密密码是否正确"
|
||||||
else:
|
else:
|
||||||
req_url = "%s/get/%s" % (self._server, str(self._key).strip())
|
req_url = UrlUtils.combine_url(host=self._server, path=f"get/{self._key}")
|
||||||
ret = self._req.get_res(url=req_url)
|
ret = self._req.get_res(url=req_url)
|
||||||
if ret and ret.status_code == 200:
|
if ret and ret.status_code == 200:
|
||||||
try:
|
try:
|
||||||
@@ -59,9 +63,9 @@ class CookieCloudHelper:
|
|||||||
if not encrypted:
|
if not encrypted:
|
||||||
return {}, "未获取到cookie密文"
|
return {}, "未获取到cookie密文"
|
||||||
else:
|
else:
|
||||||
crypt_key = self._get_crypt_key()
|
crypt_key = self.__get_crypt_key()
|
||||||
try:
|
try:
|
||||||
decrypted_data = decrypt(encrypted, crypt_key).decode('utf-8')
|
decrypted_data = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8")
|
||||||
result = json.loads(decrypted_data)
|
result = json.loads(decrypted_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {}, "cookie解密失败:" + str(e)
|
return {}, "cookie解密失败:" + str(e)
|
||||||
@@ -105,18 +109,21 @@ class CookieCloudHelper:
|
|||||||
ret_cookies[domain] = cookie_str
|
ret_cookies[domain] = cookie_str
|
||||||
return ret_cookies, ""
|
return ret_cookies, ""
|
||||||
|
|
||||||
def _get_crypt_key(self) -> bytes:
|
def __get_crypt_key(self) -> bytes:
|
||||||
"""
|
"""
|
||||||
使用UUID和密码生成CookieCloud的加解密密钥
|
使用UUID和密码生成CookieCloud的加解密密钥
|
||||||
"""
|
"""
|
||||||
md5_generator = md5()
|
combined_string = f"{self._key}-{self._password}"
|
||||||
md5_generator.update((str(self._key).strip() + '-' + str(self._password).strip()).encode('utf-8'))
|
return HashUtils.md5(combined_string)[:16].encode("utf-8")
|
||||||
return (md5_generator.hexdigest()[:16]).encode('utf-8')
|
|
||||||
|
|
||||||
def _load_local_encrypt_data(self, uuid: str) -> Dict[str, Any]:
|
def __load_local_encrypt_data(self, uuid: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取本地CookieCloud数据
|
||||||
|
"""
|
||||||
file_path = self._local_path / f"{uuid}.json"
|
file_path = self._local_path / f"{uuid}.json"
|
||||||
# 检查文件是否存在
|
# 检查文件是否存在
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
|
logger.warn(f"本地CookieCloud文件不存在:{file_path}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# 读取文件
|
# 读取文件
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ from pathlib import Path
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.log import logger
|
|
||||||
from app.schemas.types import SystemConfigKey, MediaType
|
from app.schemas.types import SystemConfigKey, MediaType
|
||||||
from app.utils.system import SystemUtils
|
|
||||||
|
|
||||||
|
|
||||||
class DirectoryHelper:
|
class DirectoryHelper:
|
||||||
@@ -18,147 +15,82 @@ class DirectoryHelper:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.systemconfig = SystemConfigOper()
|
self.systemconfig = SystemConfigOper()
|
||||||
|
|
||||||
def get_download_dirs(self) -> List[schemas.MediaDirectory]:
|
def get_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||||
"""
|
"""
|
||||||
获取下载目录
|
获取所有下载目录
|
||||||
"""
|
"""
|
||||||
dir_conf: List[dict] = self.systemconfig.get(SystemConfigKey.DownloadDirectories)
|
dir_confs: List[dict] = self.systemconfig.get(SystemConfigKey.Directories)
|
||||||
if not dir_conf:
|
if not dir_confs:
|
||||||
return []
|
return []
|
||||||
return [schemas.MediaDirectory(**d) for d in dir_conf]
|
return [schemas.TransferDirectoryConf(**d) for d in dir_confs]
|
||||||
|
|
||||||
def get_library_dirs(self) -> List[schemas.MediaDirectory]:
|
def get_download_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||||
"""
|
"""
|
||||||
获取媒体库目录
|
获取所有下载目录
|
||||||
"""
|
"""
|
||||||
dir_conf: List[dict] = self.systemconfig.get(SystemConfigKey.LibraryDirectories)
|
return sorted([d for d in self.get_dirs() if d.download_path], key=lambda x: x.priority)
|
||||||
if not dir_conf:
|
|
||||||
return []
|
|
||||||
return [schemas.MediaDirectory(**d) for d in dir_conf]
|
|
||||||
|
|
||||||
def get_download_dir(self, media: MediaInfo = None, to_path: Path = None) -> Optional[schemas.MediaDirectory]:
|
def get_local_download_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||||
"""
|
"""
|
||||||
根据媒体信息获取下载目录
|
获取所有本地的可下载目录
|
||||||
|
"""
|
||||||
|
return [d for d in self.get_download_dirs() if d.storage == "local"]
|
||||||
|
|
||||||
|
def get_library_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||||
|
"""
|
||||||
|
获取所有媒体库目录
|
||||||
|
"""
|
||||||
|
return sorted([d for d in self.get_dirs() if d.library_path], key=lambda x: x.priority)
|
||||||
|
|
||||||
|
def get_local_library_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||||
|
"""
|
||||||
|
获取所有本地的媒体库目录
|
||||||
|
"""
|
||||||
|
return [d for d in self.get_library_dirs() if d.library_storage == "local"]
|
||||||
|
|
||||||
|
def get_dir(self, media: MediaInfo, src_path: Path = None, dest_path: Path = None,
|
||||||
|
fileitem: schemas.FileItem = None, local: bool = False) -> Optional[schemas.TransferDirectoryConf]:
|
||||||
|
"""
|
||||||
|
根据媒体信息获取下载目录、媒体库目录配置
|
||||||
:param media: 媒体信息
|
:param media: 媒体信息
|
||||||
:param to_path: 目标目录
|
:param src_path: 源目录,有值时直接匹配
|
||||||
|
:param dest_path: 目标目录,有值时直接匹配
|
||||||
|
:param fileitem: 文件项,使用文件路径匹配
|
||||||
|
:param local: 是否本地目录
|
||||||
"""
|
"""
|
||||||
# 处理类型
|
# 处理类型
|
||||||
if media:
|
if media:
|
||||||
media_type = media.type.value
|
media_type = media.type.value
|
||||||
else:
|
else:
|
||||||
media_type = MediaType.UNKNOWN.value
|
media_type = MediaType.UNKNOWN.value
|
||||||
download_dirs = self.get_download_dirs()
|
dirs = self.get_dirs()
|
||||||
# 按照配置顺序查找(保存后的数据已经排序)
|
# 按照配置顺序查找
|
||||||
for download_dir in download_dirs:
|
for d in dirs:
|
||||||
if not download_dir.path:
|
# 下载目录
|
||||||
|
download_path = Path(d.download_path)
|
||||||
|
# 媒体库目录
|
||||||
|
library_path = Path(d.library_path)
|
||||||
|
# 下载目录不匹配, 不符合条件, 通常处理`下载`匹配
|
||||||
|
if src_path and download_path != src_path:
|
||||||
continue
|
continue
|
||||||
download_path = Path(download_dir.path)
|
# 媒体库目录不匹配, 或监控方式为None(即不自动整理), 不符合条件, 通常处理`整理`匹配
|
||||||
# 有目标目录,但目标目录与当前目录不相等时不要
|
if dest_path:
|
||||||
if to_path and download_path != to_path:
|
if library_path != dest_path or not d.monitor_type:
|
||||||
|
continue
|
||||||
|
# 没有目录配置时起作用, 通常处理`手动整理`未选择`目标目录`的情况
|
||||||
|
if fileitem and not Path(fileitem.path).is_relative_to(download_path):
|
||||||
|
continue
|
||||||
|
# 本地目录
|
||||||
|
if local and d.storage != "local":
|
||||||
continue
|
continue
|
||||||
# 目录类型为全部的,符合条件
|
# 目录类型为全部的,符合条件
|
||||||
if not download_dir.media_type:
|
if not d.media_type:
|
||||||
return download_dir
|
return d
|
||||||
# 目录类型相等,目录类别为全部,符合条件
|
# 目录类型相等,目录类别为全部,符合条件
|
||||||
if download_dir.media_type == media_type and not download_dir.category:
|
if d.media_type == media_type and not d.media_category:
|
||||||
return download_dir
|
return d
|
||||||
# 目录类型相等,目录类别相等,符合条件
|
# 目录类型相等,目录类别相等,符合条件
|
||||||
if download_dir.media_type == media_type and download_dir.category == media.category:
|
if d.media_type == media_type and d.media_category == media.category:
|
||||||
return download_dir
|
return d
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_library_dir(self, media: MediaInfo = None, in_path: Path = None,
|
|
||||||
to_path: Path = None) -> Optional[schemas.MediaDirectory]:
|
|
||||||
"""
|
|
||||||
根据媒体信息获取媒体库目录,需判断是否同盘优先
|
|
||||||
:param media: 媒体信息
|
|
||||||
:param in_path: 源目录
|
|
||||||
:param to_path: 目标目录
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __comman_parts(path1: Path, path2: Path) -> int:
|
|
||||||
"""
|
|
||||||
计算两个路径的公共路径长度
|
|
||||||
"""
|
|
||||||
parts1 = path1.parts
|
|
||||||
parts2 = path2.parts
|
|
||||||
root_flag = parts1[0] == '/' and parts2[0] == '/'
|
|
||||||
length = min(len(parts1), len(parts2))
|
|
||||||
for i in range(length):
|
|
||||||
if parts1[i] == '/' and parts2[i] == '/':
|
|
||||||
continue
|
|
||||||
if parts1[i] != parts2[i]:
|
|
||||||
return i - 1 if root_flag else i
|
|
||||||
return length - 1 if root_flag else length
|
|
||||||
|
|
||||||
# 处理类型
|
|
||||||
if media:
|
|
||||||
media_type = media.type.value
|
|
||||||
else:
|
|
||||||
media_type = MediaType.UNKNOWN.value
|
|
||||||
|
|
||||||
# 匹配的目录
|
|
||||||
matched_dirs = []
|
|
||||||
library_dirs = self.get_library_dirs()
|
|
||||||
# 按照配置顺序查找(保存后的数据已经排序)
|
|
||||||
for library_dir in library_dirs:
|
|
||||||
if not library_dir.path:
|
|
||||||
continue
|
|
||||||
# 有目标目录,但目标目录与当前目录不相等时不要
|
|
||||||
if to_path and Path(library_dir.path) != to_path:
|
|
||||||
continue
|
|
||||||
# 目录类型为全部的,符合条件
|
|
||||||
if not library_dir.media_type:
|
|
||||||
matched_dirs.append(library_dir)
|
|
||||||
# 目录类型相等,目录类别为全部,符合条件
|
|
||||||
if library_dir.media_type == media_type and not library_dir.category:
|
|
||||||
matched_dirs.append(library_dir)
|
|
||||||
# 目录类型相等,目录类别相等,符合条件
|
|
||||||
if library_dir.media_type == media_type and library_dir.category == media.category:
|
|
||||||
matched_dirs.append(library_dir)
|
|
||||||
|
|
||||||
# 未匹配到
|
|
||||||
if not matched_dirs:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 没有目录则创建
|
|
||||||
for matched_dir in matched_dirs:
|
|
||||||
matched_path = Path(matched_dir.path)
|
|
||||||
if not matched_path.exists():
|
|
||||||
matched_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# 只匹配到一项
|
|
||||||
if len(matched_dirs) == 1:
|
|
||||||
return matched_dirs[0]
|
|
||||||
|
|
||||||
# 有源路径,且开启同盘/同目录优先时
|
|
||||||
if in_path and settings.TRANSFER_SAME_DISK:
|
|
||||||
# 优先同根路径
|
|
||||||
max_length = 0
|
|
||||||
target_dirs = []
|
|
||||||
for matched_dir in matched_dirs:
|
|
||||||
try:
|
|
||||||
# 计算in_path和path的公共路径长度
|
|
||||||
relative_len = __comman_parts(in_path, Path(matched_dir.path))
|
|
||||||
if relative_len and relative_len >= max_length:
|
|
||||||
max_length = relative_len
|
|
||||||
target_dirs.append({
|
|
||||||
'path': matched_dir,
|
|
||||||
'relative_len': relative_len
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"计算目标路径时出错:{str(e)}")
|
|
||||||
continue
|
|
||||||
if target_dirs:
|
|
||||||
target_dirs.sort(key=lambda x: x['relative_len'], reverse=True)
|
|
||||||
matched_dirs = [x['path'] for x in target_dirs]
|
|
||||||
|
|
||||||
# 优先同盘
|
|
||||||
for matched_dir in matched_dirs:
|
|
||||||
matched_path = Path(matched_dir.path)
|
|
||||||
if SystemUtils.is_same_disk(matched_path, in_path):
|
|
||||||
return matched_dir
|
|
||||||
|
|
||||||
# 返回最优先的匹配
|
|
||||||
return matched_dirs[0]
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from app.log import logger
|
|||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
class DisplayHelper(metaclass=Singleton):
|
class DisplayHelper(metaclass=Singleton):
|
||||||
_display: Display = None
|
_display: Display = None
|
||||||
@@ -12,11 +14,14 @@ class DisplayHelper(metaclass=Singleton):
|
|||||||
if not SystemUtils.is_docker():
|
if not SystemUtils.is_docker():
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self._display = Display(visible=False, size=(1024, 768))
|
self._display = Display(visible=False, size=(1024, 768), extra_args=[os.environ['DISPLAY']])
|
||||||
self._display.start()
|
self._display.start()
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"DisplayHelper init error: {str(err)}")
|
logger.error(f"DisplayHelper init error: {str(err)}")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
if self._display:
|
if self._display:
|
||||||
|
logger.info("正在停止虚拟显示...")
|
||||||
self._display.stop()
|
self._display.stop()
|
||||||
|
logger.info("虚拟显示已停止")
|
||||||
|
|
||||||
|
|||||||
@@ -15,38 +15,19 @@ from typing import Dict, Optional
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
# 定义一个全局集合来存储注册的主机
|
|
||||||
_registered_hosts = {
|
|
||||||
'api.themoviedb.org',
|
|
||||||
'api.tmdb.org',
|
|
||||||
'webservice.fanart.tv',
|
|
||||||
'api.github.com',
|
|
||||||
'github.com',
|
|
||||||
'raw.githubusercontent.com',
|
|
||||||
'api.telegram.org'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 定义一个全局线程池执行器
|
# 定义一个全局线程池执行器
|
||||||
_executor = concurrent.futures.ThreadPoolExecutor()
|
_executor = concurrent.futures.ThreadPoolExecutor()
|
||||||
|
|
||||||
# 定义默认的DoH配置
|
# 定义默认的DoH配置
|
||||||
_doh_timeout = 5
|
_doh_timeout = 5
|
||||||
_doh_cache: Dict[str, str] = {}
|
_doh_cache: Dict[str, str] = {}
|
||||||
_doh_resolvers = [
|
|
||||||
# https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https
|
|
||||||
"1.0.0.1",
|
|
||||||
"1.1.1.1",
|
|
||||||
# https://support.quad9.net/hc/en-us
|
|
||||||
"9.9.9.9",
|
|
||||||
"149.112.112.112"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _patched_getaddrinfo(host, *args, **kwargs):
|
def _patched_getaddrinfo(host, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
socket.getaddrinfo的补丁版本。
|
socket.getaddrinfo的补丁版本。
|
||||||
"""
|
"""
|
||||||
if host not in _registered_hosts:
|
if host not in settings.DOH_DOMAINS.split(","):
|
||||||
return _orig_getaddrinfo(host, *args, **kwargs)
|
return _orig_getaddrinfo(host, *args, **kwargs)
|
||||||
|
|
||||||
# 检查主机是否已解析
|
# 检查主机是否已解析
|
||||||
@@ -57,7 +38,7 @@ def _patched_getaddrinfo(host, *args, **kwargs):
|
|||||||
|
|
||||||
# 使用DoH解析主机
|
# 使用DoH解析主机
|
||||||
futures = []
|
futures = []
|
||||||
for resolver in _doh_resolvers:
|
for resolver in settings.DOH_RESOLVERS.split(","):
|
||||||
futures.append(_executor.submit(_doh_query, resolver, host))
|
futures.append(_executor.submit(_doh_query, resolver, host))
|
||||||
|
|
||||||
for future in concurrent.futures.as_completed(futures):
|
for future in concurrent.futures.as_completed(futures):
|
||||||
|
|||||||
37
app/helper/downloader.py
Normal file
37
app/helper/downloader.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.helper.service import ServiceBaseHelper
|
||||||
|
from app.schemas import DownloaderConf, ServiceInfo
|
||||||
|
from app.schemas.types import SystemConfigKey, ModuleType
|
||||||
|
|
||||||
|
|
||||||
|
class DownloaderHelper(ServiceBaseHelper[DownloaderConf]):
|
||||||
|
"""
|
||||||
|
下载器帮助类
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
config_key=SystemConfigKey.Downloaders,
|
||||||
|
conf_type=DownloaderConf,
|
||||||
|
module_type=ModuleType.Downloader
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_downloader(
|
||||||
|
self,
|
||||||
|
service_type: Optional[str] = None,
|
||||||
|
service: Optional[ServiceInfo] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
通用的下载器类型判断方法
|
||||||
|
:param service_type: 下载器的类型名称(如 'qbittorrent', 'transmission')
|
||||||
|
:param service: 要判断的服务信息
|
||||||
|
:param name: 服务的名称
|
||||||
|
:return: 如果服务类型或实例为指定类型,返回 True;否则返回 False
|
||||||
|
"""
|
||||||
|
# 如果未提供 service 则通过 name 获取服务
|
||||||
|
service = service or self.get_service(name=name)
|
||||||
|
|
||||||
|
# 判断服务类型是否为指定类型
|
||||||
|
return bool(service and service.type == service_type)
|
||||||
@@ -9,17 +9,19 @@ class FormatParser(object):
|
|||||||
_split_chars = r"\.|\s+|\(|\)|\[|]|-|\+|【|】|/|~|;|&|\||#|_|「|」|~"
|
_split_chars = r"\.|\s+|\(|\)|\[|]|-|\+|【|】|/|~|;|&|\||#|_|「|」|~"
|
||||||
|
|
||||||
def __init__(self, eformat: str, details: str = None, part: str = None,
|
def __init__(self, eformat: str, details: str = None, part: str = None,
|
||||||
offset: int = None, key: str = "ep"):
|
offset: str = None, key: str = "ep"):
|
||||||
"""
|
"""
|
||||||
:params eformat: 格式化字符串
|
:params eformat: 格式化字符串
|
||||||
:params details: 格式化详情
|
:params details: 格式化详情
|
||||||
:params part: 分集
|
:params part: 分集
|
||||||
:params offset: 偏移量
|
:params offset: 偏移量 -10/EP*2
|
||||||
:prams key: EP关键字
|
:prams key: EP关键字
|
||||||
"""
|
"""
|
||||||
self._format = eformat
|
self._format = eformat
|
||||||
self._start_ep = None
|
self._start_ep = None
|
||||||
self._end_ep = None
|
self._end_ep = None
|
||||||
|
self.__offset = offset or "EP"
|
||||||
|
self._key = key
|
||||||
self._part = None
|
self._part = None
|
||||||
if part:
|
if part:
|
||||||
self._part = part
|
self._part = part
|
||||||
@@ -34,8 +36,6 @@ class FormatParser(object):
|
|||||||
self._end_ep = int(tmp[0]) if int(tmp[0]) > int(tmp[1]) else int(tmp[1])
|
self._end_ep = int(tmp[0]) if int(tmp[0]) > int(tmp[1]) else int(tmp[1])
|
||||||
else:
|
else:
|
||||||
self._start_ep = self._end_ep = int(tmp[0])
|
self._start_ep = self._end_ep = int(tmp[0])
|
||||||
self.__offset = int(offset) if offset else 0
|
|
||||||
self._key = key
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def format(self):
|
def format(self):
|
||||||
@@ -77,15 +77,21 @@ class FormatParser(object):
|
|||||||
if self._start_ep is not None and self._start_ep == self._end_ep:
|
if self._start_ep is not None and self._start_ep == self._end_ep:
|
||||||
if isinstance(self._start_ep, str):
|
if isinstance(self._start_ep, str):
|
||||||
s, e = self._start_ep.split("-")
|
s, e = self._start_ep.split("-")
|
||||||
|
start_ep = self.__offset.replace("EP", s)
|
||||||
|
end_ep = self.__offset.replace("EP", e)
|
||||||
if int(s) == int(e):
|
if int(s) == int(e):
|
||||||
return int(s) + self.__offset, None, self.part
|
return int(eval(start_ep)), None, self.part
|
||||||
return int(s) + self.__offset, int(e) + self.__offset, self.part
|
return int(eval(start_ep)), int(eval(end_ep)), self.part
|
||||||
return self._start_ep + self.__offset, None, self.part
|
else:
|
||||||
|
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
||||||
|
return int(eval(start_ep)), None, self.part
|
||||||
if not self._format:
|
if not self._format:
|
||||||
return self._start_ep, self._end_ep, self.part
|
return self._start_ep, self._end_ep, self.part
|
||||||
s, e = self.__handle_single(file_name)
|
else:
|
||||||
return s + self.__offset if s is not None else None, \
|
s, e = self.__handle_single(file_name)
|
||||||
e + self.__offset if e is not None else None, self.part
|
start_ep = self.__offset.replace("EP", str(s)) if s else None
|
||||||
|
end_ep = self.__offset.replace("EP", str(e)) if e else None
|
||||||
|
return int(eval(start_ep)) if start_ep else None, int(eval(end_ep)) if end_ep else None, self.part
|
||||||
|
|
||||||
def __handle_single(self, file: str) -> Tuple[Optional[int], Optional[int]]:
|
def __handle_single(self, file: str) -> Tuple[Optional[int], Optional[int]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
37
app/helper/mediaserver.py
Normal file
37
app/helper/mediaserver.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.helper.service import ServiceBaseHelper
|
||||||
|
from app.schemas import MediaServerConf, ServiceInfo
|
||||||
|
from app.schemas.types import SystemConfigKey, ModuleType
|
||||||
|
|
||||||
|
|
||||||
|
class MediaServerHelper(ServiceBaseHelper[MediaServerConf]):
|
||||||
|
"""
|
||||||
|
媒体服务器帮助类
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
config_key=SystemConfigKey.MediaServers,
|
||||||
|
conf_type=MediaServerConf,
|
||||||
|
module_type=ModuleType.MediaServer
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_media_server(
|
||||||
|
self,
|
||||||
|
service_type: Optional[str] = None,
|
||||||
|
service: Optional[ServiceInfo] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
通用的媒体服务器类型判断方法
|
||||||
|
:param service_type: 媒体服务器的类型名称(如 'plex', 'emby', 'jellyfin')
|
||||||
|
:param service: 要判断的服务信息
|
||||||
|
:param name: 服务的名称
|
||||||
|
:return: 如果服务类型或实例为指定类型,返回 True;否则返回 False
|
||||||
|
"""
|
||||||
|
# 如果未提供 service 则通过 name 获取服务
|
||||||
|
service = service or self.get_service(name=name)
|
||||||
|
|
||||||
|
# 判断服务类型是否为指定类型
|
||||||
|
return bool(service and service.type == service_type)
|
||||||
@@ -3,6 +3,7 @@ import importlib
|
|||||||
import pkgutil
|
import pkgutil
|
||||||
import traceback
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List, Any
|
||||||
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ class ModuleHelper:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, package_path: str, filter_func=lambda name, obj: True):
|
def load(cls, package_path: str, filter_func=lambda name, obj: True) -> List[Any]:
|
||||||
"""
|
"""
|
||||||
导入模块
|
导入模块
|
||||||
:param package_path: 父包名
|
:param package_path: 父包名
|
||||||
@@ -41,7 +42,7 @@ class ModuleHelper:
|
|||||||
return submodules
|
return submodules
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load_with_pre_filter(cls, package_path: str, filter_func=lambda name, obj: True):
|
def load_with_pre_filter(cls, package_path: str, filter_func=lambda name, obj: True) -> List[Any]:
|
||||||
"""
|
"""
|
||||||
导入子模块
|
导入子模块
|
||||||
:param package_path: 父包名
|
:param package_path: 父包名
|
||||||
|
|||||||
38
app/helper/notification.py
Normal file
38
app/helper/notification.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.helper.service import ServiceBaseHelper
|
||||||
|
from app.schemas import NotificationConf, ServiceInfo
|
||||||
|
from app.schemas.types import SystemConfigKey, ModuleType
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationHelper(ServiceBaseHelper[NotificationConf]):
|
||||||
|
"""
|
||||||
|
消息通知帮助类
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
config_key=SystemConfigKey.Notifications,
|
||||||
|
conf_type=NotificationConf,
|
||||||
|
module_type=ModuleType.Notification
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_notification(
|
||||||
|
self,
|
||||||
|
service_type: Optional[str] = None,
|
||||||
|
service: Optional[ServiceInfo] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
通用的消息通知服务类型判断方法
|
||||||
|
|
||||||
|
:param service_type: 消息通知服务的类型名称(如 'wechat', 'voicechat', 'telegram', 等)
|
||||||
|
:param service: 要判断的服务信息
|
||||||
|
:param name: 服务的名称
|
||||||
|
:return: 如果服务类型或实例为指定类型,返回 True;否则返回 False
|
||||||
|
"""
|
||||||
|
# 如果未提供 service 则通过 name 获取服务
|
||||||
|
service = service or self.get_service(name=name)
|
||||||
|
|
||||||
|
# 判断服务类型是否为指定类型
|
||||||
|
return bool(service and service.type == service_type)
|
||||||
@@ -2,9 +2,12 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
import traceback
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Tuple, Optional, List
|
from typing import Any, Dict, List, Optional, Tuple, Set
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
from cachetools import TTLCache, cached
|
||||||
|
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||||
|
from packaging.version import Version, InvalidVersion
|
||||||
|
from pkg_resources import Requirement, working_set
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
@@ -13,6 +16,9 @@ from app.schemas.types import SystemConfigKey
|
|||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
|
from app.utils.url import UrlUtils
|
||||||
|
|
||||||
|
PLUGIN_DIR = Path(settings.ROOT_PATH) / "app" / "plugins"
|
||||||
|
|
||||||
|
|
||||||
class PluginHelper(metaclass=Singleton):
|
class PluginHelper(metaclass=Singleton):
|
||||||
@@ -20,12 +26,9 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
插件市场管理,下载安装插件到本地
|
插件市场管理,下载安装插件到本地
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_base_url = f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/%s/%s/main/"
|
_base_url = "https://raw.githubusercontent.com/{user}/{repo}/main/"
|
||||||
|
_install_reg = f"{settings.MP_SERVER_HOST}/plugin/install/{{pid}}"
|
||||||
_install_reg = f"{settings.MP_SERVER_HOST}/plugin/install/%s"
|
|
||||||
|
|
||||||
_install_report = f"{settings.MP_SERVER_HOST}/plugin/install"
|
_install_report = f"{settings.MP_SERVER_HOST}/plugin/install"
|
||||||
|
|
||||||
_install_statistic = f"{settings.MP_SERVER_HOST}/plugin/statistic"
|
_install_statistic = f"{settings.MP_SERVER_HOST}/plugin/statistic"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -35,37 +38,67 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
if self.install_report():
|
if self.install_report():
|
||||||
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
||||||
|
|
||||||
@property
|
|
||||||
def proxies(self):
|
|
||||||
return None if settings.GITHUB_PROXY else settings.PROXY
|
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1000, ttl=1800))
|
@cached(cache=TTLCache(maxsize=1000, ttl=1800))
|
||||||
def get_plugins(self, repo_url: str) -> Dict[str, dict]:
|
def get_plugins(self, repo_url: str, package_version: str = None) -> Dict[str, dict]:
|
||||||
"""
|
"""
|
||||||
获取Github所有最新插件列表
|
获取Github所有最新插件列表
|
||||||
:param repo_url: Github仓库地址
|
:param repo_url: Github仓库地址
|
||||||
|
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||||
"""
|
"""
|
||||||
if not repo_url:
|
if not repo_url:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
user, repo = self.get_repo_info(repo_url)
|
user, repo = self.get_repo_info(repo_url)
|
||||||
if not user or not repo:
|
if not user or not repo:
|
||||||
return {}
|
return {}
|
||||||
raw_url = self._base_url % (user, repo)
|
|
||||||
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS,
|
raw_url = self._base_url.format(user=user, repo=repo)
|
||||||
timeout=10).get_res(f"{raw_url}package.json")
|
package_url = f"{raw_url}package.{package_version}.json" if package_version else f"{raw_url}package.json"
|
||||||
|
|
||||||
|
res = self.__request_with_fallback(package_url, headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}"))
|
||||||
if res:
|
if res:
|
||||||
try:
|
try:
|
||||||
return json.loads(res.text)
|
return json.loads(res.text)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.error(f"插件包数据解析失败:{res.text}")
|
logger.error(f"插件包数据解析失败:{res.text}")
|
||||||
return {}
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
def get_plugin_package_version(self, pid: str, repo_url: str, package_version: str = None) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
检查并获取指定插件的可用版本,支持多版本优先级加载和版本兼容性检测
|
||||||
|
1. 如果未指定版本,则使用系统配置的默认版本(通过 settings.VERSION_FLAG 设置)
|
||||||
|
2. 优先检查指定版本的插件(如 `package.v2.json`)
|
||||||
|
3. 如果插件不存在于指定版本,检查 `package.json` 文件,查看该插件是否兼容指定版本
|
||||||
|
4. 如果插件不存在或不兼容指定版本,返回 `None`
|
||||||
|
:param pid: 插件 ID,用于在插件列表中查找
|
||||||
|
:param repo_url: 插件仓库的 URL,指定用于获取插件信息的 GitHub 仓库地址
|
||||||
|
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
||||||
|
:return: 返回可用的插件版本号 (如 "v2",如果指定版本不可用则返回空字符串表示 v1),如果插件不可用则返回 None
|
||||||
|
"""
|
||||||
|
# 如果没有指定版本,则使用当前系统配置的版本(如 "v2")
|
||||||
|
if not package_version:
|
||||||
|
package_version = settings.VERSION_FLAG
|
||||||
|
|
||||||
|
# 优先检查指定版本的插件,即 package.v(x).json 文件中是否存在该插件,如果存在,返回该版本号
|
||||||
|
plugins = self.get_plugins(repo_url, package_version)
|
||||||
|
if pid in plugins:
|
||||||
|
return package_version
|
||||||
|
|
||||||
|
# 如果指定版本的插件不存在,检查全局 package.json 文件,查看插件是否兼容指定的版本
|
||||||
|
global_plugins = self.get_plugins(repo_url)
|
||||||
|
plugin = global_plugins.get(pid, None)
|
||||||
|
|
||||||
|
# 检查插件是否明确支持当前指定的版本(如 v2 或 v3),如果支持,返回空字符串表示使用 package.json(v1)
|
||||||
|
if plugin and plugin.get(package_version) is True:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 如果所有版本都不存在或插件不兼容,返回 None,表示插件不可用
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_repo_info(repo_url: str) -> Tuple[Optional[str], Optional[str]]:
|
def get_repo_info(repo_url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
获取Github仓库信息
|
获取GitHub仓库信息
|
||||||
:param repo_url: Github仓库地址
|
|
||||||
"""
|
"""
|
||||||
if not repo_url:
|
if not repo_url:
|
||||||
return None, None
|
return None, None
|
||||||
@@ -76,7 +109,7 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
try:
|
try:
|
||||||
user, repo = repo_url.split("/")[-4:-2]
|
user, repo = repo_url.split("/")[-4:-2]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"解析Github仓库地址失败:{str(e)} - {traceback.format_exc()}")
|
logger.error(f"解析GitHub仓库地址失败:{str(e)} - {traceback.format_exc()}")
|
||||||
return None, None
|
return None, None
|
||||||
return user, repo
|
return user, repo
|
||||||
|
|
||||||
@@ -100,7 +133,8 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
return False
|
return False
|
||||||
if not pid:
|
if not pid:
|
||||||
return False
|
return False
|
||||||
res = RequestUtils(timeout=5).get_res(self._install_reg % pid)
|
install_reg_url = self._install_reg.format(pid=pid)
|
||||||
|
res = RequestUtils(timeout=5).get_res(install_reg_url)
|
||||||
if res and res.status_code == 200:
|
if res and res.status_code == 200:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -116,114 +150,597 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
return False
|
return False
|
||||||
res = RequestUtils(content_type="application/json",
|
res = RequestUtils(content_type="application/json",
|
||||||
timeout=5).post(self._install_report,
|
timeout=5).post(self._install_report,
|
||||||
json={
|
json={"plugins": [{"plugin_id": plugin} for plugin in plugins]})
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"plugin_id": plugin,
|
|
||||||
} for plugin in plugins
|
|
||||||
]
|
|
||||||
})
|
|
||||||
return True if res else False
|
return True if res else False
|
||||||
|
|
||||||
def install(self, pid: str, repo_url: str) -> Tuple[bool, str]:
|
def install(self, pid: str, repo_url: str, package_version: str = None, force_install: bool = False) \
|
||||||
|
-> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
安装插件
|
安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略
|
||||||
|
1. 检查并获取插件的指定版本,确认版本兼容性
|
||||||
|
2. 从 GitHub 获取文件列表(包括 requirements.txt)
|
||||||
|
3. 删除旧的插件目录(如非强制安装则进行备份)
|
||||||
|
4. 下载并预安装 requirements.txt 中的依赖(如果存在)
|
||||||
|
5. 下载并安装插件的其他文件
|
||||||
|
6. 再次尝试安装依赖(确保安装完整)
|
||||||
|
:param pid: 插件 ID
|
||||||
|
:param repo_url: 插件仓库地址
|
||||||
|
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
||||||
|
:param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作
|
||||||
|
:return: (是否成功, 错误信息)
|
||||||
"""
|
"""
|
||||||
if SystemUtils.is_frozen():
|
if SystemUtils.is_frozen():
|
||||||
return False, "可执行文件模式下,只能安装本地插件"
|
return False, "可执行文件模式下,只能安装本地插件"
|
||||||
|
|
||||||
# 从Github的repo_url获取用户和项目名
|
# 验证参数
|
||||||
|
if not pid or not repo_url:
|
||||||
|
return False, "参数错误"
|
||||||
|
|
||||||
|
# 从 GitHub 的 repo_url 获取用户和项目名
|
||||||
user, repo = self.get_repo_info(repo_url)
|
user, repo = self.get_repo_info(repo_url)
|
||||||
if not user or not repo:
|
if not user or not repo:
|
||||||
return False, "不支持的插件仓库地址格式"
|
return False, "不支持的插件仓库地址格式"
|
||||||
|
|
||||||
def __get_filelist(_p: str) -> Tuple[Optional[list], Optional[str]]:
|
user_repo = f"{user}/{repo}"
|
||||||
"""
|
|
||||||
获取插件的文件列表
|
|
||||||
"""
|
|
||||||
file_api = f"https://api.github.com/repos/{user}/{repo}/contents/plugins/{_p}"
|
|
||||||
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=30).get_res(file_api)
|
|
||||||
if r is None:
|
|
||||||
return None, "连接仓库失败"
|
|
||||||
elif r.status_code != 200:
|
|
||||||
return None, f"连接仓库失败:{r.status_code} - " \
|
|
||||||
f"{'超出速率限制,请配置GITHUB_TOKEN环境变量或稍后重试' if r.status_code == 403 else r.reason}"
|
|
||||||
ret = r.json()
|
|
||||||
if ret and ret[0].get("message") == "Not Found":
|
|
||||||
return None, "插件在仓库中不存在"
|
|
||||||
return ret, ""
|
|
||||||
|
|
||||||
def __download_files(_p: str, _l: List[dict]) -> Tuple[bool, str]:
|
if not package_version:
|
||||||
"""
|
package_version = settings.VERSION_FLAG
|
||||||
下载插件文件
|
|
||||||
"""
|
|
||||||
if not _l:
|
|
||||||
return False, "文件列表为空"
|
|
||||||
for item in _l:
|
|
||||||
if item.get("download_url"):
|
|
||||||
download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}"
|
|
||||||
# 下载插件文件
|
|
||||||
res = RequestUtils(proxies=self.proxies,
|
|
||||||
headers=settings.GITHUB_HEADERS, timeout=60).get_res(download_url)
|
|
||||||
if not res:
|
|
||||||
return False, f"文件 {item.get('name')} 下载失败!"
|
|
||||||
elif res.status_code != 200:
|
|
||||||
return False, f"下载文件 {item.get('name')} 失败:{res.status_code} - " \
|
|
||||||
f"{'超出速率限制,请配置GITHUB_TOKEN环境变量或稍后重试' if res.status_code == 403 else res.reason}"
|
|
||||||
# 创建插件文件夹
|
|
||||||
file_path = Path(settings.ROOT_PATH) / "app" / item.get("path")
|
|
||||||
if not file_path.parent.exists():
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(file_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(res.text)
|
|
||||||
else:
|
|
||||||
# 递归下载子目录
|
|
||||||
p = f"{_p}/{item.get('name')}"
|
|
||||||
l, m = __get_filelist(p)
|
|
||||||
if not l:
|
|
||||||
return False, m
|
|
||||||
__download_files(p, l)
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
if not pid or not repo_url:
|
# 1. 优先检查指定版本的插件
|
||||||
return False, "参数错误"
|
package_version = self.get_plugin_package_version(pid, repo_url, package_version)
|
||||||
|
# 如果 package_version 为None,说明没有找到匹配的插件
|
||||||
|
if package_version is None:
|
||||||
|
msg = f"{pid} 没有找到适用于当前版本的插件"
|
||||||
|
logger.debug(msg)
|
||||||
|
return False, msg
|
||||||
|
# package_version 为空,表示从 package.json 中找到插件
|
||||||
|
elif package_version == "":
|
||||||
|
logger.debug(f"{pid} 从 package.json 中找到适用于当前版本的插件")
|
||||||
|
else:
|
||||||
|
logger.debug(f"{pid} 从 package.{package_version}.json 中找到适用于当前版本的插件")
|
||||||
|
|
||||||
# 获取插件的文件列表
|
# 2. 获取插件文件列表(包括 requirements.txt)
|
||||||
"""
|
file_list, msg = self.__get_file_list(pid.lower(), user_repo, package_version)
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "__init__.py",
|
|
||||||
"path": "plugins/autobackup/__init__.py",
|
|
||||||
"sha": "cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
|
|
||||||
"size": 12385,
|
|
||||||
"url": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/contents/plugins/autobackup/__init__.py?ref=main",
|
|
||||||
"html_url": "https://github.com/jxxghp/MoviePilot-Plugins/blob/main/plugins/autobackup/__init__.py",
|
|
||||||
"git_url": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/git/blobs/cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
|
|
||||||
"download_url": "https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/plugins/autobackup/__init__.py",
|
|
||||||
"type": "file",
|
|
||||||
"_links": {
|
|
||||||
"self": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/contents/plugins/autobackup/__init__.py?ref=main",
|
|
||||||
"git": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/git/blobs/cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
|
|
||||||
"html": "https://github.com/jxxghp/MoviePilot-Plugins/blob/main/plugins/autobackup/__init__.py"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
# 获取第一级文件列表
|
|
||||||
file_list, msg = __get_filelist(pid.lower())
|
|
||||||
if not file_list:
|
if not file_list:
|
||||||
return False, msg
|
return False, msg
|
||||||
# 本地存在时先删除
|
|
||||||
plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
|
# 3. 删除旧的插件目录,如果不强制安装则备份
|
||||||
if plugin_dir.exists():
|
backup_dir = None
|
||||||
shutil.rmtree(plugin_dir, ignore_errors=True)
|
if not force_install:
|
||||||
# 下载所有文件
|
backup_dir = self.__backup_plugin(pid.lower())
|
||||||
__download_files(pid.lower(), file_list)
|
|
||||||
# 插件目录下如有requirements.txt则安装依赖
|
self.__remove_old_plugin(pid.lower())
|
||||||
requirements_file = plugin_dir / "requirements.txt"
|
|
||||||
if requirements_file.exists():
|
# 4. 查找并安装 requirements.txt 中的依赖,确保插件环境的依赖尽可能完整。依赖安装可能失败且不影响插件安装,目前只记录日志
|
||||||
SystemUtils.execute(f"pip install -r {requirements_file} > /dev/null 2>&1")
|
requirements_file_info = next((f for f in file_list if f.get("name") == "requirements.txt"), None)
|
||||||
# 安装成功后统计
|
if requirements_file_info:
|
||||||
|
logger.debug(f"{pid} 发现 requirements.txt,提前下载并预安装依赖")
|
||||||
|
success, message = self.__download_and_install_requirements(requirements_file_info,
|
||||||
|
pid, user_repo)
|
||||||
|
if not success:
|
||||||
|
logger.debug(f"{pid} 依赖预安装失败:{message}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"{pid} 依赖预安装成功")
|
||||||
|
|
||||||
|
# 5. 下载插件的其他文件
|
||||||
|
logger.info(f"{pid} 准备开始下载插件文件")
|
||||||
|
success, message = self.__download_files(pid.lower(), file_list, user_repo, package_version, True)
|
||||||
|
if not success:
|
||||||
|
logger.error(f"{pid} 下载插件文件失败:{message}")
|
||||||
|
if backup_dir:
|
||||||
|
self.__restore_plugin(pid.lower(), backup_dir)
|
||||||
|
logger.warning(f"{pid} 插件安装失败,已还原备份插件")
|
||||||
|
else:
|
||||||
|
self.__remove_old_plugin(pid.lower())
|
||||||
|
logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装")
|
||||||
|
|
||||||
|
return False, message
|
||||||
|
else:
|
||||||
|
logger.info(f"{pid} 下载插件文件成功")
|
||||||
|
|
||||||
|
# 6. 插件文件安装成功后,再次尝试安装依赖,避免因为遗漏依赖导致的插件运行问题,目前依旧只记录日志
|
||||||
|
dependencies_exist, success, message = self.__install_dependencies_if_required(pid)
|
||||||
|
if dependencies_exist:
|
||||||
|
if not success:
|
||||||
|
logger.error(f"{pid} 依赖安装失败:{message}")
|
||||||
|
if backup_dir:
|
||||||
|
self.__restore_plugin(pid.lower(), backup_dir)
|
||||||
|
logger.warning(f"{pid} 插件安装失败,已还原备份插件")
|
||||||
|
else:
|
||||||
|
self.__remove_old_plugin(pid.lower())
|
||||||
|
logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装")
|
||||||
|
else:
|
||||||
|
logger.info(f"{pid} 依赖安装成功")
|
||||||
|
|
||||||
|
# 插件安装成功后,统计安装信息
|
||||||
self.install_reg(pid)
|
self.install_reg(pid)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def __get_file_list(self, pid: str, user_repo: str, package_version: str = None) -> \
|
||||||
|
Tuple[Optional[list], Optional[str]]:
|
||||||
|
"""
|
||||||
|
获取插件的文件列表
|
||||||
|
:param pid: 插件 ID
|
||||||
|
:param user_repo: GitHub 仓库的 user/repo 路径
|
||||||
|
:return: (文件列表, 错误信息)
|
||||||
|
"""
|
||||||
|
file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins"
|
||||||
|
# 如果 package_version 存在(如 "v2"),则加上版本号
|
||||||
|
if package_version:
|
||||||
|
file_api += f".{package_version}"
|
||||||
|
file_api += f"/{pid}"
|
||||||
|
|
||||||
|
res = self.__request_with_fallback(file_api,
|
||||||
|
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
|
||||||
|
is_api=True,
|
||||||
|
timeout=30)
|
||||||
|
if res is None:
|
||||||
|
return None, "连接仓库失败"
|
||||||
|
elif res.status_code != 200:
|
||||||
|
return None, f"连接仓库失败:{res.status_code} - " \
|
||||||
|
f"{'超出速率限制,请配置GITHUB_TOKEN环境变量或稍后重试' if res.status_code == 403 else res.reason}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
ret = res.json()
|
||||||
|
if isinstance(ret, list) and len(ret) > 0 and "message" not in ret[0]:
|
||||||
|
return ret, ""
|
||||||
|
else:
|
||||||
|
return None, "插件在仓库中不存在或返回数据格式不正确"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"插件数据解析失败:{res.text},{e}")
|
||||||
|
return None, "插件数据解析失败"
|
||||||
|
|
||||||
|
def __download_files(self, pid: str, file_list: List[dict], user_repo: str,
|
||||||
|
package_version: str = None, skip_requirements: bool = False) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
下载插件文件
|
||||||
|
:param pid: 插件 ID
|
||||||
|
:param file_list: 要下载的文件列表,包含文件的元数据(包括下载链接)
|
||||||
|
:param user_repo: GitHub 仓库的 user/repo 路径
|
||||||
|
:param skip_requirements: 是否跳过 requirements.txt 文件的下载
|
||||||
|
:return: (是否成功, 错误信息)
|
||||||
|
"""
|
||||||
|
if not file_list:
|
||||||
|
return False, "文件列表为空"
|
||||||
|
|
||||||
|
# 使用栈结构来替代递归调用,避免递归深度过大问题
|
||||||
|
stack = [(pid, file_list)]
|
||||||
|
|
||||||
|
while stack:
|
||||||
|
current_pid, current_file_list = stack.pop()
|
||||||
|
|
||||||
|
for item in current_file_list:
|
||||||
|
# 跳过 requirements.txt 的下载
|
||||||
|
if skip_requirements and item.get("name") == "requirements.txt":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item.get("download_url"):
|
||||||
|
logger.debug(f"正在下载文件:{item.get('path')}")
|
||||||
|
res = self.__request_with_fallback(item.get('download_url'),
|
||||||
|
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo))
|
||||||
|
if not res:
|
||||||
|
return False, f"文件 {item.get('path')} 下载失败!"
|
||||||
|
elif res.status_code != 200:
|
||||||
|
return False, f"下载文件 {item.get('path')} 失败:{res.status_code}"
|
||||||
|
|
||||||
|
# 确保文件路径不包含版本号(如 v2、v3),如果有 package_version,移除路径中的版本号
|
||||||
|
relative_path = item.get("path")
|
||||||
|
if package_version:
|
||||||
|
relative_path = relative_path.replace(f"plugins.{package_version}", "plugins", 1)
|
||||||
|
|
||||||
|
# 创建插件文件夹并写入文件
|
||||||
|
file_path = Path(settings.ROOT_PATH) / "app" / relative_path
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(res.text)
|
||||||
|
logger.debug(f"文件 {item.get('path')} 下载成功,保存路径:{file_path}")
|
||||||
|
else:
|
||||||
|
# 如果是子目录,则将子目录内容加入栈中继续处理
|
||||||
|
sub_list, msg = self.__get_file_list(f"{current_pid}/{item.get('name')}", user_repo,
|
||||||
|
package_version)
|
||||||
|
if not sub_list:
|
||||||
|
return False, msg
|
||||||
|
stack.append((f"{current_pid}/{item.get('name')}", sub_list))
|
||||||
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
def __download_and_install_requirements(self, requirements_file_info: dict, pid: str, user_repo: str) \
|
||||||
|
-> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
下载并安装 requirements.txt 文件中的依赖
|
||||||
|
:param requirements_file_info: requirements.txt 文件的元数据信息
|
||||||
|
:param pid: 插件 ID
|
||||||
|
:param user_repo: GitHub 仓库的 user/repo 路径
|
||||||
|
:return: (是否成功, 错误信息)
|
||||||
|
"""
|
||||||
|
# 下载 requirements.txt
|
||||||
|
res = self.__request_with_fallback(requirements_file_info.get("download_url"),
|
||||||
|
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo))
|
||||||
|
if not res:
|
||||||
|
return False, "requirements.txt 文件下载失败"
|
||||||
|
elif res.status_code != 200:
|
||||||
|
return False, f"下载 requirements.txt 文件失败:{res.status_code}"
|
||||||
|
|
||||||
|
requirements_txt = res.text
|
||||||
|
if requirements_txt.strip():
|
||||||
|
# 保存并安装依赖
|
||||||
|
requirements_file_path = PLUGIN_DIR / pid.lower() / "requirements.txt"
|
||||||
|
requirements_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(requirements_file_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(requirements_txt)
|
||||||
|
|
||||||
|
success, message = self.__pip_install_with_fallback(requirements_file_path)
|
||||||
|
return success, message
|
||||||
|
|
||||||
|
return True, "" # 如果 requirements.txt 为空,视作成功
|
||||||
|
|
||||||
|
def __install_dependencies_if_required(self, pid: str) -> Tuple[bool, bool, str]:
|
||||||
|
"""
|
||||||
|
安装插件依赖。
|
||||||
|
:param pid: 插件 ID
|
||||||
|
:return: (是否存在依赖,安装是否成功, 错误信息)
|
||||||
|
"""
|
||||||
|
# 定位插件目录和依赖文件
|
||||||
|
plugin_dir = PLUGIN_DIR / pid.lower()
|
||||||
|
requirements_file = plugin_dir / "requirements.txt"
|
||||||
|
|
||||||
|
# 检查是否存在 requirements.txt 文件
|
||||||
|
if requirements_file.exists():
|
||||||
|
logger.info(f"{pid} 存在依赖,开始尝试安装依赖")
|
||||||
|
success, error_message = self.__pip_install_with_fallback(requirements_file)
|
||||||
|
if success:
|
||||||
|
return True, True, ""
|
||||||
|
else:
|
||||||
|
return True, False, error_message
|
||||||
|
|
||||||
|
return False, False, "不存在依赖"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __backup_plugin(pid: str) -> str:
|
||||||
|
"""
|
||||||
|
备份旧插件目录
|
||||||
|
:param pid: 插件 ID
|
||||||
|
:return: 备份目录路径
|
||||||
|
"""
|
||||||
|
plugin_dir = PLUGIN_DIR / pid
|
||||||
|
backup_dir = Path(settings.TEMP_PATH) / "plugin_backup" / pid
|
||||||
|
|
||||||
|
if plugin_dir.exists():
|
||||||
|
# 备份时清理已有的备份目录,防止残留文件影响
|
||||||
|
if backup_dir.exists():
|
||||||
|
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||||
|
logger.debug(f"{pid} 旧的备份目录已清理 {backup_dir}")
|
||||||
|
|
||||||
|
shutil.copytree(plugin_dir, backup_dir, dirs_exist_ok=True)
|
||||||
|
logger.debug(f"{pid} 插件已备份到 {backup_dir}")
|
||||||
|
|
||||||
|
return str(backup_dir) if backup_dir.exists() else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __restore_plugin(pid: str, backup_dir: str):
|
||||||
|
"""
|
||||||
|
还原旧插件目录
|
||||||
|
:param pid: 插件 ID
|
||||||
|
:param backup_dir: 备份目录路径
|
||||||
|
"""
|
||||||
|
plugin_dir = PLUGIN_DIR / pid
|
||||||
|
if plugin_dir.exists():
|
||||||
|
shutil.rmtree(plugin_dir, ignore_errors=True)
|
||||||
|
logger.debug(f"{pid} 已清理插件目录 {plugin_dir}")
|
||||||
|
|
||||||
|
if Path(backup_dir).exists():
|
||||||
|
shutil.copytree(backup_dir, plugin_dir, dirs_exist_ok=True)
|
||||||
|
logger.debug(f"{pid} 已还原插件目录 {plugin_dir}")
|
||||||
|
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||||
|
logger.debug(f"{pid} 已删除备份目录 {backup_dir}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __remove_old_plugin(pid: str):
|
||||||
|
"""
|
||||||
|
删除旧插件
|
||||||
|
:param pid: 插件 ID
|
||||||
|
"""
|
||||||
|
plugin_dir = PLUGIN_DIR / pid
|
||||||
|
if plugin_dir.exists():
|
||||||
|
shutil.rmtree(plugin_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __pip_uninstall_and_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
先卸载 requirements.txt 中的依赖,再按照自动降级策略重新安装,不使用 PIP 缓存
|
||||||
|
|
||||||
|
:param requirements_file: 依赖的 requirements.txt 文件路径
|
||||||
|
:return: (是否成功, 错误信息)
|
||||||
|
"""
|
||||||
|
# 读取 requirements.txt 文件中的依赖列表
|
||||||
|
try:
|
||||||
|
with open(requirements_file, "r", encoding="utf-8") as f:
|
||||||
|
dependencies = [line.strip() for line in f if line.strip() and not line.startswith("#")]
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"无法读取 requirements.txt 文件:{str(e)}"
|
||||||
|
|
||||||
|
# 1. 先卸载所有依赖包
|
||||||
|
for dep in dependencies:
|
||||||
|
pip_uninstall_command = ["pip", "uninstall", "-y", dep]
|
||||||
|
logger.debug(f"尝试卸载依赖:{dep},命令:{' '.join(pip_uninstall_command)}")
|
||||||
|
success, message = SystemUtils.execute_with_subprocess(pip_uninstall_command)
|
||||||
|
if success:
|
||||||
|
logger.debug(f"依赖 {dep} 卸载成功,输出:{message}")
|
||||||
|
else:
|
||||||
|
error_message = f"卸载依赖 {dep} 失败,错误信息:{message}"
|
||||||
|
logger.error(error_message)
|
||||||
|
|
||||||
|
# 2. 重新安装所有依赖,使用自动降级策略
|
||||||
|
strategies = []
|
||||||
|
|
||||||
|
# 添加策略到列表中
|
||||||
|
if settings.PIP_PROXY:
|
||||||
|
strategies.append(("镜像站",
|
||||||
|
["pip", "install", "-r", str(requirements_file),
|
||||||
|
"-i", settings.PIP_PROXY, "--no-cache-dir"]))
|
||||||
|
if settings.PROXY_HOST:
|
||||||
|
strategies.append(("代理",
|
||||||
|
["pip", "install", "-r", str(requirements_file),
|
||||||
|
"--proxy", settings.PROXY_HOST, "--no-cache-dir"]))
|
||||||
|
strategies.append(("直连", ["pip", "install", "-r", str(requirements_file), "--no-cache-dir"]))
|
||||||
|
|
||||||
|
# 遍历策略进行安装
|
||||||
|
for strategy_name, pip_command in strategies:
|
||||||
|
logger.debug(f"[PIP] 尝试使用策略:{strategy_name} 安装依赖,命令:{' '.join(pip_command)}")
|
||||||
|
success, message = SystemUtils.execute_with_subprocess(pip_command)
|
||||||
|
if success:
|
||||||
|
logger.debug(f"[PIP] 策略:{strategy_name} 安装依赖成功,输出:{message}")
|
||||||
|
return True, message
|
||||||
|
else:
|
||||||
|
logger.error(f"[PIP] 策略:{strategy_name} 安装依赖失败,错误信息:{message}")
|
||||||
|
|
||||||
|
return False, "[PIP] 所有策略均安装依赖失败,请检查网络连接或 PIP 配置"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __pip_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
使用自动降级策略,PIP 安装依赖,优先级依次为镜像站、代理、直连
|
||||||
|
:param requirements_file: 依赖的 requirements.txt 文件路径
|
||||||
|
:return: (是否成功, 错误信息)
|
||||||
|
"""
|
||||||
|
strategies = []
|
||||||
|
|
||||||
|
# 添加策略到列表中
|
||||||
|
if settings.PIP_PROXY:
|
||||||
|
strategies.append(("镜像站", ["pip", "install", "-r", str(requirements_file), "-i", settings.PIP_PROXY]))
|
||||||
|
if settings.PROXY_HOST:
|
||||||
|
strategies.append(
|
||||||
|
("代理", ["pip", "install", "-r", str(requirements_file), "--proxy", settings.PROXY_HOST]))
|
||||||
|
strategies.append(("直连", ["pip", "install", "-r", str(requirements_file)]))
|
||||||
|
|
||||||
|
# 遍历策略进行安装
|
||||||
|
for strategy_name, pip_command in strategies:
|
||||||
|
logger.debug(f"[PIP] 尝试使用策略:{strategy_name} 安装依赖,命令:{' '.join(pip_command)}")
|
||||||
|
success, message = SystemUtils.execute_with_subprocess(pip_command)
|
||||||
|
if success:
|
||||||
|
logger.debug(f"[PIP] 策略:{strategy_name} 安装依赖成功,输出:{message}")
|
||||||
|
return True, message
|
||||||
|
else:
|
||||||
|
logger.error(f"[PIP] 策略:{strategy_name} 安装依赖失败,错误信息:{message}")
|
||||||
|
|
||||||
|
return False, "[PIP] 所有策略均安装依赖失败,请检查网络连接或 PIP 配置"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __request_with_fallback(url: str,
|
||||||
|
headers: Optional[dict] = None,
|
||||||
|
timeout: int = 60,
|
||||||
|
is_api: bool = False) -> Optional[Any]:
|
||||||
|
"""
|
||||||
|
使用自动降级策略,请求资源,优先级依次为镜像站、代理、直连
|
||||||
|
:param url: 目标URL
|
||||||
|
:param headers: 请求头信息
|
||||||
|
:param timeout: 请求超时时间
|
||||||
|
:param is_api: 是否为GitHub API请求,API请求不走镜像站
|
||||||
|
:return: 请求成功则返回 Response,失败返回 None
|
||||||
|
"""
|
||||||
|
strategies = []
|
||||||
|
|
||||||
|
# 1. 尝试使用镜像站,镜像站一般不支持API请求,因此API请求直接跳过镜像站
|
||||||
|
if not is_api and settings.GITHUB_PROXY:
|
||||||
|
proxy_url = f"{UrlUtils.standardize_base_url(settings.GITHUB_PROXY)}{url}"
|
||||||
|
strategies.append(("镜像站", proxy_url, {"headers": headers, "timeout": timeout}))
|
||||||
|
|
||||||
|
# 2. 尝试使用代理
|
||||||
|
if settings.PROXY_HOST:
|
||||||
|
strategies.append(("代理", url, {"headers": headers, "proxies": settings.PROXY, "timeout": timeout}))
|
||||||
|
|
||||||
|
# 3. 最后尝试直连
|
||||||
|
strategies.append(("直连", url, {"headers": headers, "timeout": timeout}))
|
||||||
|
|
||||||
|
# 遍历策略并尝试请求
|
||||||
|
for strategy_name, target_url, request_params in strategies:
|
||||||
|
logger.debug(f"[GitHub] 尝试使用策略:{strategy_name} 请求 URL:{target_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = RequestUtils(**request_params).get_res(url=target_url, raise_exception=True)
|
||||||
|
logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}")
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[GitHub] 请求失败,策略:{strategy_name}, URL: {target_url},错误:{str(e)}")
|
||||||
|
|
||||||
|
logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_missing_dependencies(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
收集所有需要安装或更新的依赖项
|
||||||
|
1. 收集所有插件的依赖项,合并版本约束
|
||||||
|
2. 获取已安装的包及其版本
|
||||||
|
3. 比较已安装的包与所需的依赖项,找出需要安装或升级的包
|
||||||
|
:return: 需要安装或更新的依赖项列表,例如 ["package1>=1.0.0", "package2"]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 收集所有插件的依赖项
|
||||||
|
plugin_dependencies = self.__find_plugin_dependencies() # 返回格式为 {package_name: version_specifier}
|
||||||
|
# 获取已安装的包及其版本
|
||||||
|
installed_packages = self.__get_installed_packages() # 返回格式为 {package_name: Version}
|
||||||
|
# 需要安装或更新的依赖项列表
|
||||||
|
dependencies_to_install = []
|
||||||
|
for pkg_name, version_specifier in plugin_dependencies.items():
|
||||||
|
spec_set = SpecifierSet(version_specifier)
|
||||||
|
installed_version = installed_packages.get(pkg_name)
|
||||||
|
if installed_version is None:
|
||||||
|
# 包未安装,需要安装
|
||||||
|
if version_specifier:
|
||||||
|
dependencies_to_install.append(f"{pkg_name}{version_specifier}")
|
||||||
|
else:
|
||||||
|
dependencies_to_install.append(pkg_name)
|
||||||
|
elif not spec_set.contains(installed_version, prereleases=True):
|
||||||
|
# 已安装的版本不满足版本约束,需要升级或降级
|
||||||
|
if version_specifier:
|
||||||
|
dependencies_to_install.append(f"{pkg_name}{version_specifier}")
|
||||||
|
else:
|
||||||
|
dependencies_to_install.append(pkg_name)
|
||||||
|
# 已安装的版本满足要求,无需操作
|
||||||
|
return dependencies_to_install
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
安装指定的依赖项列表
|
||||||
|
|
||||||
|
:param dependencies: 需要安装或更新的依赖项列表
|
||||||
|
:return: (success, message)
|
||||||
|
"""
|
||||||
|
if not dependencies:
|
||||||
|
return False, "没有传入需要安装的依赖项"
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(f"需要安装或更新的依赖项:{dependencies}")
|
||||||
|
# 创建临时的 requirements.txt 文件用于批量安装
|
||||||
|
requirements_temp_file = Path(settings.TEMP_PATH) / "plugin_dependencies" / "requirements.txt"
|
||||||
|
requirements_temp_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(requirements_temp_file, "w", encoding="utf-8") as f:
|
||||||
|
for dep in dependencies:
|
||||||
|
f.write(dep + "\n")
|
||||||
|
|
||||||
|
# 使用自动降级策略安装依赖
|
||||||
|
success, message = self.__pip_install_with_fallback(requirements_temp_file)
|
||||||
|
# 删除临时文件
|
||||||
|
requirements_temp_file.unlink()
|
||||||
|
return success, message
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"安装依赖项时发生错误:{e}")
|
||||||
|
return False, f"安装依赖项时发生错误:{e}"
|
||||||
|
|
||||||
|
def __get_installed_packages(self) -> Dict[str, Version]:
|
||||||
|
"""
|
||||||
|
获取已安装的包及其版本
|
||||||
|
使用 pkg_resources 获取当前环境中已安装的包,标准化包名并转换版本信息
|
||||||
|
对于无法解析的版本,记录警告日志并跳过
|
||||||
|
:return: 已安装包的字典,格式为 {package_name: Version}
|
||||||
|
"""
|
||||||
|
installed_packages = {}
|
||||||
|
try:
|
||||||
|
for dist in working_set:
|
||||||
|
pkg_name = self.__standardize_pkg_name(dist.project_name)
|
||||||
|
try:
|
||||||
|
installed_packages[pkg_name] = Version(dist.version)
|
||||||
|
except InvalidVersion:
|
||||||
|
logger.debug(f"无法解析已安装包 '{pkg_name}' 的版本:{dist.version}")
|
||||||
|
continue
|
||||||
|
return installed_packages
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取已安装的包时发生错误:{e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def __find_plugin_dependencies(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
收集所有插件的依赖项
|
||||||
|
遍历 plugins 目录下的所有插件,查找存在 requirements.txt 的插件目录
|
||||||
|
,并解析其中的依赖项,同时将所有插件的依赖项合并到字典中,方便后续统一处理
|
||||||
|
:return: 依赖项字典,格式为 {package_name: set(version_specifiers)}
|
||||||
|
"""
|
||||||
|
dependencies = {}
|
||||||
|
try:
|
||||||
|
for plugin_dir in PLUGIN_DIR.iterdir():
|
||||||
|
if plugin_dir.is_dir():
|
||||||
|
requirements_file = plugin_dir / "requirements.txt"
|
||||||
|
if requirements_file.exists():
|
||||||
|
# 解析当前插件的 requirements.txt,获取依赖项
|
||||||
|
plugin_deps = self.__parse_requirements(requirements_file)
|
||||||
|
for pkg_name, version_specifiers in plugin_deps.items():
|
||||||
|
if pkg_name in dependencies:
|
||||||
|
# 更新已存在的包的版本约束集合
|
||||||
|
dependencies[pkg_name].update(version_specifiers)
|
||||||
|
else:
|
||||||
|
# 添加新的包及其版本约束
|
||||||
|
dependencies[pkg_name] = set(version_specifiers)
|
||||||
|
return self.__merge_dependencies(dependencies)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"收集插件依赖项时发生错误:{e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def __parse_requirements(self, requirements_file: Path) -> Dict[str, List[str]]:
|
||||||
|
"""
|
||||||
|
解析 requirements.txt 文件,返回依赖项字典
|
||||||
|
使用 packaging 库解析每一行依赖项,提取包名和版本约束
|
||||||
|
对于无法解析的行,记录警告日志,便于后续检查
|
||||||
|
:param requirements_file: requirements.txt 文件的路径
|
||||||
|
:return: 依赖项字典,格式为 {package_name: [version_specifier]}
|
||||||
|
"""
|
||||||
|
dependencies = {}
|
||||||
|
try:
|
||||||
|
with open(requirements_file, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
# 使用 packaging 库解析依赖项
|
||||||
|
try:
|
||||||
|
req = Requirement(line)
|
||||||
|
pkg_name = self.__standardize_pkg_name(req.name)
|
||||||
|
version_specifier = str(req.specifier)
|
||||||
|
if pkg_name in dependencies:
|
||||||
|
dependencies[pkg_name].append(version_specifier)
|
||||||
|
else:
|
||||||
|
dependencies[pkg_name] = [version_specifier]
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"无法解析依赖项 '{line}':{e}")
|
||||||
|
return dependencies
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析 requirements.txt 时发生错误:{e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __merge_dependencies(dependencies: Dict[str, Set[str]]) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
合并依赖项,选择每个包的最高版本要求
|
||||||
|
对于多个插件依赖同一包的情况,合并其版本约束,取交集以满足所有插件的要求
|
||||||
|
如果交集为空,表示存在版本冲突,需要根据策略进行处理
|
||||||
|
:param dependencies: 依赖项字典,格式为 {package_name: set(version_specifiers)}
|
||||||
|
:return: 合并后的依赖项字典,格式为 {package_name: version_specifiers}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
merged_dependencies = {}
|
||||||
|
for pkg_name, version_specifiers in dependencies.items():
|
||||||
|
# 合并版本约束
|
||||||
|
spec_set = SpecifierSet()
|
||||||
|
for specifier in version_specifiers:
|
||||||
|
try:
|
||||||
|
if specifier:
|
||||||
|
spec_set &= SpecifierSet(specifier)
|
||||||
|
except InvalidSpecifier as e:
|
||||||
|
logger.error(f"发生版本约束冲突:{e}")
|
||||||
|
# 将合并后的版本约束添加到结果字典
|
||||||
|
merged_dependencies[pkg_name] = str(spec_set) if spec_set else ''
|
||||||
|
return merged_dependencies
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"合并依赖项时发生错误:{e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __standardize_pkg_name(name: str) -> str:
|
||||||
|
"""
|
||||||
|
标准化包名,将包名转换为小写并将连字符替换为下划线
|
||||||
|
|
||||||
|
:param name: 原始包名
|
||||||
|
:return: 标准化后的包名
|
||||||
|
"""
|
||||||
|
return name.lower().replace("-", "_") if name else name
|
||||||
|
|||||||
@@ -225,12 +225,13 @@ class RssHelper:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse(url, proxy: bool = False, timeout: int = 15) -> Union[List[dict], None]:
|
def parse(url, proxy: bool = False, timeout: int = 15, headers: dict = None) -> Union[List[dict], None]:
|
||||||
"""
|
"""
|
||||||
解析RSS订阅URL,获取RSS中的种子信息
|
解析RSS订阅URL,获取RSS中的种子信息
|
||||||
:param url: RSS地址
|
:param url: RSS地址
|
||||||
:param proxy: 是否使用代理
|
:param proxy: 是否使用代理
|
||||||
:param timeout: 请求超时
|
:param timeout: 请求超时
|
||||||
|
:param headers: 自定义请求头
|
||||||
:return: 种子信息列表,如为None代表Rss过期
|
:return: 种子信息列表,如为None代表Rss过期
|
||||||
"""
|
"""
|
||||||
# 开始处理
|
# 开始处理
|
||||||
@@ -238,7 +239,8 @@ class RssHelper:
|
|||||||
if not url:
|
if not url:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
ret = RequestUtils(proxies=settings.PROXY if proxy else None, timeout=timeout).get_res(url)
|
ret = RequestUtils(proxies=settings.PROXY if proxy else None,
|
||||||
|
timeout=timeout, headers=headers).get_res(url)
|
||||||
if not ret:
|
if not ret:
|
||||||
return []
|
return []
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
@@ -363,7 +365,7 @@ class RssHelper:
|
|||||||
return "", f"获取RSS链接失败:无法连接 {url} "
|
return "", f"获取RSS链接失败:无法连接 {url} "
|
||||||
# 解析HTML
|
# 解析HTML
|
||||||
html = etree.HTML(html_text)
|
html = etree.HTML(html_text)
|
||||||
if html:
|
if StringUtils.is_valid_html_element(html):
|
||||||
rss_link = html.xpath(site_conf.get("xpath"))
|
rss_link = html.xpath(site_conf.get("xpath"))
|
||||||
if rss_link:
|
if rss_link:
|
||||||
return str(rss_link[-1]), ""
|
return str(rss_link[-1]), ""
|
||||||
|
|||||||
70
app/helper/rule.py
Normal file
70
app/helper/rule.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from app.core.context import MediaInfo
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.schemas import FilterRuleGroup, CustomRule
|
||||||
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
|
|
||||||
|
class RuleHelper:
|
||||||
|
"""
|
||||||
|
规划帮助类
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.systemconfig = SystemConfigOper()
|
||||||
|
|
||||||
|
def get_rule_groups(self) -> List[FilterRuleGroup]:
|
||||||
|
"""
|
||||||
|
获取用户所有规则组
|
||||||
|
"""
|
||||||
|
rule_groups: List[dict] = self.systemconfig.get(SystemConfigKey.UserFilterRuleGroups)
|
||||||
|
if not rule_groups:
|
||||||
|
return []
|
||||||
|
return [FilterRuleGroup(**group) for group in rule_groups]
|
||||||
|
|
||||||
|
def get_rule_group(self, group_name: str) -> Optional[FilterRuleGroup]:
|
||||||
|
"""
|
||||||
|
获取规则组
|
||||||
|
"""
|
||||||
|
rule_groups = self.get_rule_groups()
|
||||||
|
for group in rule_groups:
|
||||||
|
if group.name == group_name:
|
||||||
|
return group
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_rule_group_by_media(self, media: MediaInfo, group_names: list = None) -> List[FilterRuleGroup]:
|
||||||
|
"""
|
||||||
|
根据媒体信息获取规则组
|
||||||
|
"""
|
||||||
|
ret_groups = []
|
||||||
|
rule_groups = self.get_rule_groups()
|
||||||
|
if group_names:
|
||||||
|
rule_groups = [group for group in rule_groups if group.name in group_names]
|
||||||
|
for group in rule_groups:
|
||||||
|
if not group.media_type:
|
||||||
|
ret_groups.append(group)
|
||||||
|
elif not group.category and group.media_type == media.type.value:
|
||||||
|
ret_groups.append(group)
|
||||||
|
elif group.category == media.category:
|
||||||
|
ret_groups.append(group)
|
||||||
|
return ret_groups
|
||||||
|
|
||||||
|
def get_custom_rules(self) -> List[CustomRule]:
|
||||||
|
"""
|
||||||
|
获取用户所有自定义规则
|
||||||
|
"""
|
||||||
|
rules: List[dict] = self.systemconfig.get(SystemConfigKey.CustomFilterRules)
|
||||||
|
if not rules:
|
||||||
|
return []
|
||||||
|
return [CustomRule(**rule) for rule in rules]
|
||||||
|
|
||||||
|
def get_custom_rule(self, rule_id: str) -> Optional[CustomRule]:
|
||||||
|
"""
|
||||||
|
获取自定义规则
|
||||||
|
"""
|
||||||
|
rules = self.get_custom_rules()
|
||||||
|
for rule in rules:
|
||||||
|
if rule.id == rule_id:
|
||||||
|
return rule
|
||||||
|
return None
|
||||||
164
app/helper/service.py
Normal file
164
app/helper/service.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator
|
||||||
|
|
||||||
|
from app.core.module import ModuleManager
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf, ServiceInfo
|
||||||
|
from app.schemas.types import NotificationType, SystemConfigKey, ModuleType
|
||||||
|
|
||||||
|
TConf = TypeVar("TConf")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceConfigHelper:
|
||||||
|
"""
|
||||||
|
配置帮助类,获取不同类型的服务配置
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List:
|
||||||
|
"""
|
||||||
|
通用获取配置的方法,根据 config_key 获取相应的配置并返回指定类型的配置列表
|
||||||
|
|
||||||
|
:param config_key: 系统配置的 key
|
||||||
|
:param conf_type: 用于实例化配置对象的类类型
|
||||||
|
:return: 配置对象列表
|
||||||
|
"""
|
||||||
|
config_data = SystemConfigOper().get(config_key)
|
||||||
|
if not config_data:
|
||||||
|
return []
|
||||||
|
# 直接使用 conf_type 来实例化配置对象
|
||||||
|
return [conf_type(**conf) for conf in config_data]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_downloader_configs() -> List[DownloaderConf]:
|
||||||
|
"""
|
||||||
|
获取下载器的配置
|
||||||
|
"""
|
||||||
|
return ServiceConfigHelper.get_configs(SystemConfigKey.Downloaders, DownloaderConf)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mediaserver_configs() -> List[MediaServerConf]:
|
||||||
|
"""
|
||||||
|
获取媒体服务器的配置
|
||||||
|
"""
|
||||||
|
return ServiceConfigHelper.get_configs(SystemConfigKey.MediaServers, MediaServerConf)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_notification_configs() -> List[NotificationConf]:
|
||||||
|
"""
|
||||||
|
获取消息通知渠道的配置
|
||||||
|
"""
|
||||||
|
return ServiceConfigHelper.get_configs(SystemConfigKey.Notifications, NotificationConf)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_notification_switches() -> List[NotificationSwitchConf]:
|
||||||
|
"""
|
||||||
|
获取消息通知场景的开关
|
||||||
|
"""
|
||||||
|
return ServiceConfigHelper.get_configs(SystemConfigKey.NotificationSwitchs, NotificationSwitchConf)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_notification_switch(mtype: NotificationType) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取指定类型的消息通知场景的开关
|
||||||
|
"""
|
||||||
|
switchs = ServiceConfigHelper.get_notification_switches()
|
||||||
|
for switch in switchs:
|
||||||
|
if switch.type == mtype.value:
|
||||||
|
return switch.action
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceBaseHelper(Generic[TConf]):
|
||||||
|
"""
|
||||||
|
通用服务帮助类,抽象获取配置和服务实例的通用逻辑
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config_key: SystemConfigKey, conf_type: Type[TConf], module_type: ModuleType):
|
||||||
|
self.modulemanager = ModuleManager()
|
||||||
|
self.config_key = config_key
|
||||||
|
self.conf_type = conf_type
|
||||||
|
self.module_type = module_type
|
||||||
|
|
||||||
|
def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]:
|
||||||
|
"""
|
||||||
|
获取配置列表
|
||||||
|
|
||||||
|
:param include_disabled: 是否包含禁用的配置,默认 False(仅返回启用的配置)
|
||||||
|
:return: 配置字典
|
||||||
|
"""
|
||||||
|
configs: List[TConf] = ServiceConfigHelper.get_configs(self.config_key, self.conf_type)
|
||||||
|
return {
|
||||||
|
config.name: config
|
||||||
|
for config in configs
|
||||||
|
if (config.name and config.type and config.enabled) or include_disabled
|
||||||
|
} if configs else {}
|
||||||
|
|
||||||
|
def get_config(self, name: str) -> Optional[TConf]:
|
||||||
|
"""
|
||||||
|
获取指定名称配置
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
configs = self.get_configs()
|
||||||
|
return configs.get(name)
|
||||||
|
|
||||||
|
def iterate_module_instances(self) -> Iterator[ServiceInfo]:
|
||||||
|
"""
|
||||||
|
迭代所有模块的实例及其对应的配置,返回 ServiceInfo 实例
|
||||||
|
"""
|
||||||
|
configs = self.get_configs()
|
||||||
|
modules = self.modulemanager.get_running_type_modules(self.module_type)
|
||||||
|
for module in modules:
|
||||||
|
if not module:
|
||||||
|
continue
|
||||||
|
module_instances = module.get_instances()
|
||||||
|
if not isinstance(module_instances, dict):
|
||||||
|
continue
|
||||||
|
for name, instance in module_instances.items():
|
||||||
|
if not instance:
|
||||||
|
continue
|
||||||
|
config = configs.get(name)
|
||||||
|
service_info = ServiceInfo(
|
||||||
|
name=name,
|
||||||
|
instance=instance,
|
||||||
|
module=module,
|
||||||
|
type=config.type if config else None,
|
||||||
|
config=config
|
||||||
|
)
|
||||||
|
yield service_info
|
||||||
|
|
||||||
|
def get_services(self, type_filter: Optional[str] = None, name_filters: Optional[List[str]] = None) \
|
||||||
|
-> Dict[str, ServiceInfo]:
|
||||||
|
"""
|
||||||
|
获取服务信息列表,并根据类型和名称列表进行过滤
|
||||||
|
|
||||||
|
:param type_filter: 需要过滤的服务类型
|
||||||
|
:param name_filters: 需要过滤的服务名称列表
|
||||||
|
:return: 过滤后的服务信息字典
|
||||||
|
"""
|
||||||
|
name_filters_set = set(name_filters) if name_filters else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
service_info.name: service_info
|
||||||
|
for service_info in self.iterate_module_instances()
|
||||||
|
if service_info.config and (
|
||||||
|
type_filter is None or service_info.type == type_filter
|
||||||
|
) and (
|
||||||
|
name_filters_set is None or service_info.name in name_filters_set)
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_service(self, name: str, type_filter: Optional[str] = None) -> Optional[ServiceInfo]:
|
||||||
|
"""
|
||||||
|
获取指定名称的服务信息,并根据类型过滤
|
||||||
|
|
||||||
|
:param name: 服务名称
|
||||||
|
:param type_filter: 需要过滤的服务类型
|
||||||
|
:return: 对应的服务信息,若不存在或类型不匹配则返回 None
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
for service_info in self.iterate_module_instances():
|
||||||
|
if service_info.name == name:
|
||||||
|
if service_info.config and (type_filter is None or service_info.type == type_filter):
|
||||||
|
return service_info
|
||||||
|
return None
|
||||||
52
app/helper/storage.py
Normal file
52
app/helper/storage.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
|
|
||||||
|
class StorageHelper:
|
||||||
|
"""
|
||||||
|
存储帮助类
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.systemconfig = SystemConfigOper()
|
||||||
|
|
||||||
|
def get_storagies(self) -> List[schemas.StorageConf]:
|
||||||
|
"""
|
||||||
|
获取所有存储设置
|
||||||
|
"""
|
||||||
|
storage_confs: List[dict] = self.systemconfig.get(SystemConfigKey.Storages)
|
||||||
|
if not storage_confs:
|
||||||
|
return []
|
||||||
|
return [schemas.StorageConf(**s) for s in storage_confs]
|
||||||
|
|
||||||
|
def get_storage(self, storage: str) -> Optional[schemas.StorageConf]:
|
||||||
|
"""
|
||||||
|
获取指定存储配置
|
||||||
|
"""
|
||||||
|
storagies = self.get_storagies()
|
||||||
|
for s in storagies:
|
||||||
|
if s.type == storage:
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_storage(self, storage: str, conf: dict):
|
||||||
|
"""
|
||||||
|
设置存储配置
|
||||||
|
"""
|
||||||
|
storagies = self.get_storagies()
|
||||||
|
if not storagies:
|
||||||
|
storagies = [
|
||||||
|
schemas.StorageConf(
|
||||||
|
type=storage,
|
||||||
|
config=conf
|
||||||
|
)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
for s in storagies:
|
||||||
|
if s.type == storage:
|
||||||
|
s.config = conf
|
||||||
|
break
|
||||||
|
self.systemconfig.set(SystemConfigKey.Storages, [s.dict() for s in storagies])
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from threading import Thread
|
from threading import Thread
|
||||||
from typing import List
|
from typing import List, Tuple
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
from cachetools import TTLCache, cached
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ from app.utils.singleton import Singleton
|
|||||||
|
|
||||||
class SubscribeHelper(metaclass=Singleton):
|
class SubscribeHelper(metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
订阅数据统计
|
订阅数据统计/订阅分享等
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_sub_reg = f"{settings.MP_SERVER_HOST}/subscribe/add"
|
_sub_reg = f"{settings.MP_SERVER_HOST}/subscribe/add"
|
||||||
@@ -24,6 +24,12 @@ class SubscribeHelper(metaclass=Singleton):
|
|||||||
|
|
||||||
_sub_statistic = f"{settings.MP_SERVER_HOST}/subscribe/statistic"
|
_sub_statistic = f"{settings.MP_SERVER_HOST}/subscribe/statistic"
|
||||||
|
|
||||||
|
_sub_share = f"{settings.MP_SERVER_HOST}/subscribe/share"
|
||||||
|
|
||||||
|
_sub_shares = f"{settings.MP_SERVER_HOST}/subscribe/shares"
|
||||||
|
|
||||||
|
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.systemconfig = SystemConfigOper()
|
self.systemconfig = SystemConfigOper()
|
||||||
if settings.SUBSCRIBE_STATISTIC_SHARE:
|
if settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||||
@@ -102,21 +108,66 @@ class SubscribeHelper(metaclass=Singleton):
|
|||||||
timeout=10).post(self._sub_report,
|
timeout=10).post(self._sub_report,
|
||||||
json={
|
json={
|
||||||
"subscribes": [
|
"subscribes": [
|
||||||
{
|
sub.to_dict() for sub in subscribes
|
||||||
"name": sub.name,
|
|
||||||
"year": sub.year,
|
|
||||||
"type": sub.type,
|
|
||||||
"tmdbid": sub.tmdbid,
|
|
||||||
"imdbid": sub.imdbid,
|
|
||||||
"tvdbid": sub.tvdbid,
|
|
||||||
"doubanid": sub.doubanid,
|
|
||||||
"bangumiid": sub.bangumiid,
|
|
||||||
"season": sub.season,
|
|
||||||
"poster": sub.poster,
|
|
||||||
"backdrop": sub.backdrop,
|
|
||||||
"vote": sub.vote,
|
|
||||||
"description": sub.description
|
|
||||||
} for sub in subscribes
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
return True if res else False
|
return True if res else False
|
||||||
|
|
||||||
|
def sub_share(self, subscribe_id: int,
|
||||||
|
share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
分享订阅
|
||||||
|
"""
|
||||||
|
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||||
|
return False, "当前没有开启订阅数据共享功能"
|
||||||
|
subscribe = SubscribeOper().get(subscribe_id)
|
||||||
|
if not subscribe:
|
||||||
|
return False, "订阅不存在"
|
||||||
|
subscribe_dict = subscribe.to_dict()
|
||||||
|
subscribe_dict.pop("id")
|
||||||
|
res = RequestUtils(content_type="application/json",
|
||||||
|
timeout=10).post(self._sub_share,
|
||||||
|
json={
|
||||||
|
"share_title": share_title,
|
||||||
|
"share_comment": share_comment,
|
||||||
|
"share_user": share_user,
|
||||||
|
**subscribe_dict
|
||||||
|
})
|
||||||
|
if res is None:
|
||||||
|
return False, "连接MoviePilot服务器失败"
|
||||||
|
if res.ok:
|
||||||
|
return True, ""
|
||||||
|
else:
|
||||||
|
return False, res.json().get("message")
|
||||||
|
|
||||||
|
def sub_fork(self, share_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
复用分享的订阅
|
||||||
|
"""
|
||||||
|
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||||
|
return False, "当前没有开启订阅数据共享功能"
|
||||||
|
res = RequestUtils(timeout=5, headers={
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}).get_res(self._sub_fork % share_id)
|
||||||
|
if res is None:
|
||||||
|
return False, "连接MoviePilot服务器失败"
|
||||||
|
if res.ok:
|
||||||
|
return True, ""
|
||||||
|
else:
|
||||||
|
return False, res.json().get("message")
|
||||||
|
|
||||||
|
@cached(cache=TTLCache(maxsize=20, ttl=1800))
|
||||||
|
def get_shares(self, name: str, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
|
"""
|
||||||
|
获取订阅分享数据
|
||||||
|
"""
|
||||||
|
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||||
|
return []
|
||||||
|
res = RequestUtils(timeout=15).get_res(self._sub_shares, params={
|
||||||
|
"name": name,
|
||||||
|
"page": page,
|
||||||
|
"count": count
|
||||||
|
})
|
||||||
|
if res and res.status_code == 200:
|
||||||
|
return res.json()
|
||||||
|
return []
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
import traceback
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Tuple, Optional, List, Union, Dict
|
from typing import Tuple, Optional, List, Union, Dict
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
@@ -11,10 +10,11 @@ from torrentool.api import Torrent
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import Context, TorrentInfo, MediaInfo
|
from app.core.context import Context, TorrentInfo, MediaInfo
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
|
from app.db.site_oper import SiteOper
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.utils.http import RequestUtils
|
|
||||||
from app.schemas.types import MediaType, SystemConfigKey
|
from app.schemas.types import MediaType, SystemConfigKey
|
||||||
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ class TorrentHelper(metaclass=Singleton):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.system_config = SystemConfigOper()
|
self.system_config = SystemConfigOper()
|
||||||
|
self.site_oper = SiteOper()
|
||||||
|
|
||||||
def download_torrent(self, url: str,
|
def download_torrent(self, url: str,
|
||||||
cookie: str = None,
|
cookie: str = None,
|
||||||
@@ -192,50 +193,60 @@ class TorrentHelper(metaclass=Singleton):
|
|||||||
|
|
||||||
def sort_torrents(self, torrent_list: List[Context]) -> List[Context]:
|
def sort_torrents(self, torrent_list: List[Context]) -> List[Context]:
|
||||||
"""
|
"""
|
||||||
对种子对行排序
|
对种子对行排序:torrent、site、upload、seeder
|
||||||
"""
|
"""
|
||||||
if not torrent_list:
|
if not torrent_list:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# 下载规则
|
||||||
|
priority_rule: List[str] = self.system_config.get(
|
||||||
|
SystemConfigKey.TorrentsPriority) or ["torrent", "upload", "seeder"]
|
||||||
|
# 站点上传量
|
||||||
|
site_uploads = {
|
||||||
|
site.name: site.upload for site in self.site_oper.get_userdata_latest()
|
||||||
|
}
|
||||||
|
|
||||||
def get_sort_str(_context):
|
def get_sort_str(_context):
|
||||||
"""
|
"""
|
||||||
排序函数,值越大越优先
|
拼装排序字段
|
||||||
"""
|
"""
|
||||||
_meta = _context.meta_info
|
_meta = _context.meta_info
|
||||||
_torrent = _context.torrent_info
|
_torrent = _context.torrent_info
|
||||||
_media = _context.media_info
|
_media = _context.media_info
|
||||||
|
# 标题
|
||||||
|
_title = str(_media.title).ljust(200, ' ')
|
||||||
# 站点优先级
|
# 站点优先级
|
||||||
_site_order = 999 - (_torrent.site_order or 0)
|
_site_order = str(999 - (_torrent.site_order or 0)).rjust(3, '0')
|
||||||
# 季数
|
# 站点上传量
|
||||||
_season_len = str(len(_meta.season_list)).rjust(2, '0')
|
_site_upload = str(site_uploads.get(_torrent.site_name) or 0).rjust(30, '0')
|
||||||
# 集数
|
# 资源优先级
|
||||||
|
_torrent_order = str(_torrent.pri_order or 0).rjust(3, '0')
|
||||||
|
# 资源做种数
|
||||||
|
_torrent_seeders = str(_torrent.seeders or 0).rjust(10, '0')
|
||||||
|
# 季集
|
||||||
if not _meta.episode_list:
|
if not _meta.episode_list:
|
||||||
# 无集数的排最前面
|
# 无集数的排最前面
|
||||||
_episode_len = "9999"
|
_season_episode = "%s%s" % (str(len(_meta.season_list)).rjust(3, '0'), "9999")
|
||||||
else:
|
else:
|
||||||
# 集数越多的排越前面
|
# 集数越多的排越前面
|
||||||
_episode_len = str(len(_meta.episode_list)).rjust(4, '0')
|
_season_episode = "%s%s" % (str(len(_meta.season_list)).rjust(3, '0'),
|
||||||
# 优先规则
|
str(len(_meta.episode_list)).rjust(4, '0'))
|
||||||
priority = self.system_config.get(SystemConfigKey.TorrentsPriority)
|
# 根据下载规则的顺序拼装排序字符串
|
||||||
if priority != "site":
|
_sort_str = _title
|
||||||
# 排序:标题、资源类型、做种、季集
|
for rule in priority_rule:
|
||||||
return "%s%s%s%s" % (str(_media.title).ljust(100, ' '),
|
if rule == "torrent":
|
||||||
str(_torrent.pri_order).rjust(3, '0'),
|
_sort_str += _torrent_order
|
||||||
str(_torrent.seeders).rjust(10, '0'),
|
elif rule == "site":
|
||||||
"%s%s" % (_season_len, _episode_len))
|
_sort_str += _site_order
|
||||||
else:
|
elif rule == "upload":
|
||||||
# 排序:标题、资源类型、站点、做种、季集
|
_sort_str += _site_upload
|
||||||
return "%s%s%s%s%s" % (str(_media.title).ljust(100, ' '),
|
elif rule == "seeder":
|
||||||
str(_torrent.pri_order).rjust(3, '0'),
|
_sort_str += _torrent_seeders
|
||||||
str(_site_order).rjust(3, '0'),
|
_sort_str += _season_episode
|
||||||
str(_torrent.seeders).rjust(10, '0'),
|
return _sort_str
|
||||||
"%s%s" % (_season_len, _episode_len))
|
|
||||||
|
|
||||||
# 匹配的资源中排序分组选最好的一个下载
|
# 排序
|
||||||
# 按站点顺序、资源匹配顺序、做种人数下载数逆序排序
|
return sorted(torrent_list, key=lambda x: get_sort_str(x), reverse=True)
|
||||||
torrent_list = sorted(torrent_list, key=lambda x: get_sort_str(x), reverse=True)
|
|
||||||
|
|
||||||
return torrent_list
|
|
||||||
|
|
||||||
def sort_group_torrents(self, torrent_list: List[Context]) -> List[Context]:
|
def sort_group_torrents(self, torrent_list: List[Context]) -> List[Context]:
|
||||||
"""
|
"""
|
||||||
@@ -298,135 +309,6 @@ class TorrentHelper(metaclass=Singleton):
|
|||||||
if url not in self._invalid_torrents:
|
if url not in self._invalid_torrents:
|
||||||
self._invalid_torrents.append(url)
|
self._invalid_torrents.append(url)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def filter_torrent(torrent_info: TorrentInfo,
|
|
||||||
filter_rule: Dict[str, str],
|
|
||||||
mediainfo: MediaInfo) -> bool:
|
|
||||||
"""
|
|
||||||
检查种子是否匹配订阅过滤规则
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __get_size_range(size_str: str) -> Tuple[float, float]:
|
|
||||||
"""
|
|
||||||
获取大小范围
|
|
||||||
"""
|
|
||||||
if not size_str:
|
|
||||||
return 0, 0
|
|
||||||
try:
|
|
||||||
size_range = size_str.split("-")
|
|
||||||
if len(size_range) == 1:
|
|
||||||
return 0, float(size_range[0])
|
|
||||||
elif len(size_range) == 2:
|
|
||||||
return float(size_range[0]), float(size_range[1])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
|
|
||||||
return 0, 0
|
|
||||||
|
|
||||||
def __get_pubminutes(pubdate: str) -> float:
|
|
||||||
"""
|
|
||||||
将字符串转换为时间,并计算与当前时间差)(分钟)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not pubdate:
|
|
||||||
return 0
|
|
||||||
pubdate = pubdate.replace("T", " ").replace("Z", "")
|
|
||||||
pubdate = datetime.datetime.strptime(pubdate, "%Y-%m-%d %H:%M:%S")
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
return (now - pubdate).total_seconds() // 60
|
|
||||||
except Exception as e:
|
|
||||||
print(str(e))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if not filter_rule:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 匹配内容
|
|
||||||
content = (f"{torrent_info.title} "
|
|
||||||
f"{torrent_info.description} "
|
|
||||||
f"{' '.join(torrent_info.labels or [])} "
|
|
||||||
f"{torrent_info.volume_factor}")
|
|
||||||
|
|
||||||
# 最少做种人数
|
|
||||||
min_seeders = filter_rule.get("min_seeders")
|
|
||||||
if min_seeders and torrent_info.seeders < int(min_seeders):
|
|
||||||
# 最少做种人数生效发布时间(分钟)(在设置发布时间之外的最少做种人数生效)
|
|
||||||
min_seeders_time = filter_rule.get("min_seeders_time") or 0
|
|
||||||
if min_seeders_time:
|
|
||||||
# 发布时间与当前时间差(分钟)
|
|
||||||
pubdate_minutes = __get_pubminutes(torrent_info.pubdate)
|
|
||||||
if pubdate_minutes > int(min_seeders_time):
|
|
||||||
logger.info(f"{torrent_info.title} 发布时间大于 {min_seeders_time} 分钟,做种人数不足 {min_seeders}")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
logger.info(f"{torrent_info.title} 做种人数不足 {min_seeders}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 包含
|
|
||||||
include = filter_rule.get("include")
|
|
||||||
if include:
|
|
||||||
if not re.search(r"%s" % include, content, re.I):
|
|
||||||
logger.info(f"{content} 不匹配包含规则 {include}")
|
|
||||||
return False
|
|
||||||
# 排除
|
|
||||||
exclude = filter_rule.get("exclude")
|
|
||||||
if exclude:
|
|
||||||
if re.search(r"%s" % exclude, content, re.I):
|
|
||||||
logger.info(f"{content} 匹配排除规则 {exclude}")
|
|
||||||
return False
|
|
||||||
# 质量
|
|
||||||
quality = filter_rule.get("quality")
|
|
||||||
if quality:
|
|
||||||
if not re.search(r"%s" % quality, torrent_info.title, re.I):
|
|
||||||
logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}")
|
|
||||||
return False
|
|
||||||
# 分辨率
|
|
||||||
resolution = filter_rule.get("resolution")
|
|
||||||
if resolution:
|
|
||||||
if not re.search(r"%s" % resolution, torrent_info.title, re.I):
|
|
||||||
logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}")
|
|
||||||
return False
|
|
||||||
# 特效
|
|
||||||
effect = filter_rule.get("effect")
|
|
||||||
if effect:
|
|
||||||
if not re.search(r"%s" % effect, torrent_info.title, re.I):
|
|
||||||
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 大小
|
|
||||||
tv_size = filter_rule.get("tv_size")
|
|
||||||
movie_size = filter_rule.get("movie_size")
|
|
||||||
if movie_size or tv_size:
|
|
||||||
if mediainfo.type == MediaType.TV:
|
|
||||||
size = tv_size
|
|
||||||
else:
|
|
||||||
size = movie_size
|
|
||||||
# 大小范围
|
|
||||||
begin_size, end_size = __get_size_range(size)
|
|
||||||
if begin_size or end_size:
|
|
||||||
meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description)
|
|
||||||
# 集数
|
|
||||||
if mediainfo.type == MediaType.TV:
|
|
||||||
# 电视剧
|
|
||||||
season = meta.begin_season or 1
|
|
||||||
if meta.total_episode:
|
|
||||||
# 识别的总集数
|
|
||||||
episodes_num = meta.total_episode
|
|
||||||
else:
|
|
||||||
# 整季集数
|
|
||||||
episodes_num = len(mediainfo.seasons.get(season) or [1])
|
|
||||||
# 比较大小
|
|
||||||
if not (begin_size * 1024 ** 3 <= (torrent_info.size / episodes_num) <= end_size * 1024 ** 3):
|
|
||||||
logger.info(f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} "
|
|
||||||
f"共{episodes_num}集,不匹配大小规则 {size}")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
# 电影比较大小
|
|
||||||
if not (begin_size * 1024 ** 3 <= torrent_info.size <= end_size * 1024 ** 3):
|
|
||||||
logger.info(
|
|
||||||
f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} 不匹配大小规则 {size}")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo, torrent: TorrentInfo) -> bool:
|
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo, torrent: TorrentInfo) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -514,3 +396,52 @@ class TorrentHelper(metaclass=Singleton):
|
|||||||
# 未匹配
|
# 未匹配
|
||||||
logger.debug(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}')
|
logger.debug(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_torrent(torrent_info: TorrentInfo,
|
||||||
|
filter_params: Dict[str, str]) -> bool:
|
||||||
|
"""
|
||||||
|
检查种子是否匹配订阅过滤规则
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not filter_params:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 匹配内容
|
||||||
|
content = (f"{torrent_info.title} "
|
||||||
|
f"{torrent_info.description} "
|
||||||
|
f"{' '.join(torrent_info.labels or [])} "
|
||||||
|
f"{torrent_info.volume_factor}")
|
||||||
|
|
||||||
|
# 包含
|
||||||
|
include = filter_params.get("include")
|
||||||
|
if include:
|
||||||
|
if not re.search(r"%s" % include, content, re.I):
|
||||||
|
logger.info(f"{content} 不匹配包含规则 {include}")
|
||||||
|
return False
|
||||||
|
# 排除
|
||||||
|
exclude = filter_params.get("exclude")
|
||||||
|
if exclude:
|
||||||
|
if re.search(r"%s" % exclude, content, re.I):
|
||||||
|
logger.info(f"{content} 匹配排除规则 {exclude}")
|
||||||
|
return False
|
||||||
|
# 质量
|
||||||
|
quality = filter_params.get("quality")
|
||||||
|
if quality:
|
||||||
|
if not re.search(r"%s" % quality, torrent_info.title, re.I):
|
||||||
|
logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}")
|
||||||
|
return False
|
||||||
|
# 分辨率
|
||||||
|
resolution = filter_params.get("resolution")
|
||||||
|
if resolution:
|
||||||
|
if not re.search(r"%s" % resolution, torrent_info.title, re.I):
|
||||||
|
logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}")
|
||||||
|
return False
|
||||||
|
# 特效
|
||||||
|
effect = filter_params.get("effect")
|
||||||
|
if effect:
|
||||||
|
if not re.search(r"%s" % effect, torrent_info.title, re.I):
|
||||||
|
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user