mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-11 09:59:51 +08:00
Compare commits
1212 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 | ||
|
|
7dd1e75ad7 | ||
|
|
93b8f24ec7 | ||
|
|
1c240f9d76 | ||
|
|
9a2ef5fe48 | ||
|
|
7bd55caed7 | ||
|
|
ae36f5100a | ||
|
|
b2efac0495 | ||
|
|
1dced579ea | ||
|
|
0deea17ef9 | ||
|
|
3d0c06013d | ||
|
|
2536119f60 | ||
|
|
aeede861e3 | ||
|
|
1edbfb0d2d | ||
|
|
265724bbe9 | ||
|
|
2b0b190cf8 | ||
|
|
08a2b348d8 | ||
|
|
e896068bc5 | ||
|
|
85e5338121 | ||
|
|
5c3cd8cabc | ||
|
|
5a837a4161 | ||
|
|
1e1f80b6d9 | ||
|
|
e06e00204b | ||
|
|
b98c0f205d | ||
|
|
0c266726ea | ||
|
|
b43e591e4c | ||
|
|
3d6e1335f8 | ||
|
|
361e8dd65d | ||
|
|
de865f3cf1 | ||
|
|
37985eba25 | ||
|
|
e0a251b339 | ||
|
|
f9f4d97a51 | ||
|
|
6adc0e27d5 | ||
|
|
5deb0089bb | ||
|
|
bfbeae7fa7 | ||
|
|
8a98c65026 | ||
|
|
0133c6e60c | ||
|
|
ae0e171dd2 | ||
|
|
9f0ed49d43 | ||
|
|
8df2955a67 | ||
|
|
ef0cd7d5c5 | ||
|
|
463fd3761a | ||
|
|
4af4ad0243 | ||
|
|
24aa64232f | ||
|
|
9937f6792e | ||
|
|
185b72dc8d | ||
|
|
0fb12c77eb | ||
|
|
631df4c9f8 | ||
|
|
0da08394ae | ||
|
|
6392ee627f | ||
|
|
da6ba3fa8b | ||
|
|
cb0bb8a38e | ||
|
|
e1cdc51904 | ||
|
|
79c57d8e4f | ||
|
|
681f1eaeb5 | ||
|
|
de2323d67a | ||
|
|
9cf240b8e8 | ||
|
|
b93c97938c | ||
|
|
41d347bcef | ||
|
|
060e2f225c | ||
|
|
7103b0334a | ||
|
|
354d5977e0 | ||
|
|
19a56f7d24 | ||
|
|
323ad099c3 | ||
|
|
484ecf10c3 | ||
|
|
2a333add9b | ||
|
|
90df09e64d | ||
|
|
53397536ce | ||
|
|
f902f43c56 | ||
|
|
9948db8bce | ||
|
|
1b6a06bd7b | ||
|
|
ce1db7f62b | ||
|
|
74dbae8514 | ||
|
|
7d4ec2ddec | ||
|
|
3654b9609f | ||
|
|
83e583032a | ||
|
|
35a4d77915 | ||
|
|
cbfb2027a8 | ||
|
|
ce0548632e | ||
|
|
da1f6a0997 | ||
|
|
a514ec0761 | ||
|
|
851dd85fc6 | ||
|
|
0270af5b19 | ||
|
|
f8f964106a | ||
|
|
aa0f2a571c | ||
|
|
727a14864e | ||
|
|
c7e909520c | ||
|
|
7f40863449 | ||
|
|
e994a9fc92 | ||
|
|
d8fe8b28e8 | ||
|
|
7f4f085d4a | ||
|
|
2052766a71 | ||
|
|
887fe834bd | ||
|
|
0d4f87a631 | ||
|
|
ed96241053 | ||
|
|
788104d151 | ||
|
|
f8b3dbaef5 | ||
|
|
b66ca92d72 | ||
|
|
c2a80dbedd | ||
|
|
95202af139 | ||
|
|
d77ea8f0a0 | ||
|
|
bbba9813a2 | ||
|
|
220cbc3072 | ||
|
|
fcbdef5e66 | ||
|
|
e2e1c7642d | ||
|
|
33813ecf1d | ||
|
|
ef656fcc67 | ||
|
|
8fe7e015dd | ||
|
|
7132fdbb26 | ||
|
|
0f57b39345 | ||
|
|
d13b5622c7 | ||
|
|
b5eaba26da | ||
|
|
60007cf398 | ||
|
|
65cc169391 | ||
|
|
68a9fc4a13 | ||
|
|
08870a67ec | ||
|
|
518206c34a | ||
|
|
e05c643a6b | ||
|
|
748de0ff00 | ||
|
|
29b94e859f | ||
|
|
ed3bd0ddef | ||
|
|
3cdbdc2f78 | ||
|
|
f8fbf9b5eb | ||
|
|
9e0751367b | ||
|
|
bc689074e0 | ||
|
|
7e442650b0 | ||
|
|
0a9a391eb3 | ||
|
|
ea1e600474 | ||
|
|
b0a2c1b957 | ||
|
|
624363476a | ||
|
|
48a860bfd4 | ||
|
|
2d4fb5d52e | ||
|
|
c0c787f7ed | ||
|
|
03d6834471 | ||
|
|
947d0d6d4b | ||
|
|
7611c88aa6 | ||
|
|
7be262b182 | ||
|
|
a7a06a9a75 | ||
|
|
6aa5a836b9 | ||
|
|
efd0fc39c6 | ||
|
|
7e1951b8e4 | ||
|
|
27c6392b66 | ||
|
|
0fc7d883c0 | ||
|
|
95b480af6d | ||
|
|
abe7795105 | ||
|
|
74c71390c9 | ||
|
|
1ddd844c17 | ||
|
|
de3ff2db2e | ||
|
|
655e73f829 | ||
|
|
2232e51509 | ||
|
|
44f1a321d2 | ||
|
|
c05223846f | ||
|
|
45945bd025 | ||
|
|
acff7e0610 | ||
|
|
e97ae488fd | ||
|
|
a7689e1e10 | ||
|
|
9a4d537543 | ||
|
|
1b09bb8d22 | ||
|
|
13832a51e0 | ||
|
|
a09b2fa88a | ||
|
|
6361f8654c | ||
|
|
db4bda3b73 | ||
|
|
3f557ee43c | ||
|
|
9e7e0a8730 | ||
|
|
07de1eaa0d | ||
|
|
c872043bf4 | ||
|
|
7ed194a62c | ||
|
|
882da68903 | ||
|
|
2798700f71 | ||
|
|
34e70adabb | ||
|
|
fe999aa346 | ||
|
|
f7ca4abb01 | ||
|
|
8a4202cee5 | ||
|
|
55a85b87dd | ||
|
|
3470f96e39 | ||
|
|
74980911fe | ||
|
|
4c5366f8b4 | ||
|
|
8eb89eec86 | ||
|
|
cfd7208cda | ||
|
|
0c6684a572 | ||
|
|
f0692b2fb8 | ||
|
|
c29ee4fb07 | ||
|
|
dd40ef54c0 | ||
|
|
84d5e2a6b3 | ||
|
|
7defcff0e5 | ||
|
|
d9e767f87d | ||
|
|
2b82173fba | ||
|
|
1425b15333 | ||
|
|
8d82d0f4fd | ||
|
|
d352f09d4e | ||
|
|
aebd121939 | ||
|
|
81eed0d06d | ||
|
|
bacb7aaeb4 | ||
|
|
b238c6ad11 | ||
|
|
5c8b843030 | ||
|
|
58acc62e16 | ||
|
|
ca5a240fc4 | ||
|
|
dd5887d18d | ||
|
|
97669405d0 | ||
|
|
bf2ea271b6 | ||
|
|
afd91bf760 | ||
|
|
7e982eaf4d | ||
|
|
5f13824aa6 | ||
|
|
9ca8e3f4a8 | ||
|
|
9b749035c9 | ||
|
|
b8e09a6b06 | ||
|
|
4bb95d519d | ||
|
|
04280021b4 | ||
|
|
355dad9205 | ||
|
|
a6714d3712 | ||
|
|
fe53819a81 | ||
|
|
6965415c52 | ||
|
|
9be671fa2c | ||
|
|
27b4f206a1 | ||
|
|
a2b0c9bd3a | ||
|
|
ebc46d7d3b | ||
|
|
eb4e4b5141 | ||
|
|
be11ef72a9 | ||
|
|
a278c80951 | ||
|
|
6ee6de48ff | ||
|
|
671bdad77c | ||
|
|
a9ff8ec96d | ||
|
|
d1678355f1 | ||
|
|
ea399daef9 | ||
|
|
e1122af97c | ||
|
|
21861111e6 | ||
|
|
bd1e83ee8a | ||
|
|
43da33bc50 | ||
|
|
a09a207407 | ||
|
|
0aa3aa8521 | ||
|
|
d9c6375252 | ||
|
|
f1f187fc77 | ||
|
|
99d22554a1 | ||
|
|
4835f6c6c9 | ||
|
|
5be2bf0633 | ||
|
|
7c7bc0b504 | ||
|
|
e0939fee75 | ||
|
|
82226f1956 | ||
|
|
cfb43b4b04 | ||
|
|
ebe2795eae | ||
|
|
f7f747278d | ||
|
|
58f17e89b6 | ||
|
|
433ca2ec28 | ||
|
|
ffac57ad4d | ||
|
|
0d2a4c50d6 | ||
|
|
02c2edc30e | ||
|
|
65975235d4 | ||
|
|
07a6abde0e | ||
|
|
fa47d9adeb | ||
|
|
18d08c3672 | ||
|
|
cf20049b7f | ||
|
|
e3ce3302da | ||
|
|
d20951e7a0 | ||
|
|
8a565bb79f | ||
|
|
cfdc8fb2c3 | ||
|
|
111f830664 | ||
|
|
2821d6a9dc | ||
|
|
495d98c2b2 | ||
|
|
e1e2779e48 | ||
|
|
363318f4f0 | ||
|
|
521b960364 | ||
|
|
d2bcb197eb | ||
|
|
b0f9ca52e3 | ||
|
|
01a3efd402 | ||
|
|
a50427948a | ||
|
|
5614f10962 | ||
|
|
5ff80dbe89 | ||
|
|
278835c5d4 | ||
|
|
92cdd67f3a | ||
|
|
c56b58cc56 | ||
|
|
8bd4c21511 | ||
|
|
b94f201667 | ||
|
|
125e9eb30a | ||
|
|
ea09d8c8d4 | ||
|
|
de0237f348 | ||
|
|
62143bf7b6 | ||
|
|
3088bbb2f8 | ||
|
|
43647e59a4 | ||
|
|
a740330e66 | ||
|
|
10d4766353 | ||
|
|
0a845fe8b6 | ||
|
|
90dc52bb70 | ||
|
|
3816e2fba8 | ||
|
|
20d92ca577 | ||
|
|
db92761964 | ||
|
|
3af5870733 | ||
|
|
53d01267b8 | ||
|
|
5ff21641f9 | ||
|
|
643f2e3e66 | ||
|
|
ae839235eb | ||
|
|
5af94144ce | ||
|
|
4281692321 | ||
|
|
bd6d6b6882 | ||
|
|
fabb02a8a0 | ||
|
|
223e655b6f | ||
|
|
f0cb5b3e85 | ||
|
|
7579aae823 | ||
|
|
36a8b6d780 | ||
|
|
0471167b74 | ||
|
|
28b996e54b | ||
|
|
d0989f72a9 | ||
|
|
663e61e3a1 | ||
|
|
71b0090947 | ||
|
|
8ff0f81f47 | ||
|
|
4186613a86 | ||
|
|
4e1be23317 | ||
|
|
0e9e626ab6 | ||
|
|
3d5761157a | ||
|
|
c407800b30 | ||
|
|
c888a37aba | ||
|
|
d43998efee | ||
|
|
8813e84053 | ||
|
|
cf5a746f53 | ||
|
|
f9f58fc559 | ||
|
|
f59b5b6d27 | ||
|
|
30b3ad4a99 | ||
|
|
dfb9ce7520 | ||
|
|
6c365f552e | ||
|
|
a81ee7d89a | ||
|
|
5c9039e6d0 | ||
|
|
cce2e13e21 | ||
|
|
0da87abc71 | ||
|
|
6a2eecc744 | ||
|
|
c049e13c1c | ||
|
|
7ec49ce076 | ||
|
|
5be2fc35b5 | ||
|
|
0b84312559 | ||
|
|
8bb43b52bc | ||
|
|
bd348f118c | ||
|
|
4a3a3483d0 | ||
|
|
fd6314f19f | ||
|
|
17a9f3a626 | ||
|
|
75c898e6eb | ||
|
|
089d4785aa | ||
|
|
4d48295f72 | ||
|
|
ed119b7beb | ||
|
|
90d5a8b0c9 | ||
|
|
dd5c0de7b1 | ||
|
|
73bdca282c | ||
|
|
360a54581f | ||
|
|
1fc7587cbb | ||
|
|
dcd46f1627 | ||
|
|
d8644a20c0 | ||
|
|
23b47f98c1 | ||
|
|
347c91fa0b | ||
|
|
ac961b37b4 | ||
|
|
068c49a79a | ||
|
|
e7174b402c | ||
|
|
d21267090a | ||
|
|
51dc2c33a0 | ||
|
|
8aef488ab6 | ||
|
|
0cbf45f9b9 | ||
|
|
c0ae32d654 | ||
|
|
ff1b0e02d6 | ||
|
|
76a8b02fe5 | ||
|
|
43f594393c | ||
|
|
008e11d63f | ||
|
|
9dd610f245 | ||
|
|
c5d087aad6 | ||
|
|
576c5741f9 | ||
|
|
51387c31c4 | ||
|
|
c2a40876e2 | ||
|
|
c06bdf0491 | ||
|
|
f726130c31 | ||
|
|
4033ffeb15 | ||
|
|
f81af8e9fb | ||
|
|
e3f9260299 | ||
|
|
c80ccaf74b | ||
|
|
0e60c976be | ||
|
|
805c7d2701 | ||
|
|
4499f001dd | ||
|
|
71c6a3718b | ||
|
|
6404f9d45c | ||
|
|
ce357540eb | ||
|
|
e56cfd6ad4 | ||
|
|
25e5f7a9f6 | ||
|
|
6d69ac42e5 | ||
|
|
6a71bed821 | ||
|
|
1718758d1c | ||
|
|
7a37078e90 | ||
|
|
26b5ad6a44 | ||
|
|
fa884c9608 | ||
|
|
6927b5fbd3 | ||
|
|
59fca63d4a | ||
|
|
7489d6a912 | ||
|
|
b437fd6021 | ||
|
|
c303ab0765 | ||
|
|
9daff87f2f | ||
|
|
f20b1bcfe9 | ||
|
|
2f71e401be | ||
|
|
0840e0bcbc | ||
|
|
933af7485c | ||
|
|
baddaabd73 | ||
|
|
8028866cee | ||
|
|
242894cec2 | ||
|
|
967ad3a507 | ||
|
|
2dbe049a91 | ||
|
|
c5afc65cbd | ||
|
|
e35bacecd5 | ||
|
|
d84c86b0f6 | ||
|
|
73ae09b041 | ||
|
|
a11318390d | ||
|
|
1714990e2e | ||
|
|
44cd5f52e0 | ||
|
|
59b9dc354e | ||
|
|
591969015f | ||
|
|
6118e235c3 | ||
|
|
228b1a11d0 | ||
|
|
c8a1e59310 | ||
|
|
b0f7a11328 | ||
|
|
b753e50580 | ||
|
|
3002bf4dd2 | ||
|
|
0cbe8f5cdc | ||
|
|
1a03d19469 | ||
|
|
b7c1106744 | ||
|
|
d6c6c999fc | ||
|
|
408703d4a3 | ||
|
|
40a612c327 | ||
|
|
e519fc484b | ||
|
|
e430a3e88b | ||
|
|
316f61bf69 | ||
|
|
750c4441db | ||
|
|
441cee4ee5 | ||
|
|
ebf2f53ae1 | ||
|
|
4e7000efbb | ||
|
|
0679a32659 | ||
|
|
148984ad0e | ||
|
|
dd8804ef3e | ||
|
|
fb0018dda6 | ||
|
|
c14e529c91 | ||
|
|
f6222122c0 | ||
|
|
3a18267ec0 | ||
|
|
ae60040120 | ||
|
|
b04bc74550 | ||
|
|
666d6eb048 | ||
|
|
73a3a8cf94 | ||
|
|
6d66c5b577 | ||
|
|
c3ffe38d4d | ||
|
|
5108dbbeb5 | ||
|
|
cbf56bd9b7 | ||
|
|
67965b09a6 | ||
|
|
a2678d5815 | ||
|
|
36b25e6a08 | ||
|
|
c98c8c8836 | ||
|
|
423b7cf340 | ||
|
|
02acc8bc35 | ||
|
|
664b42f050 | ||
|
|
ca491891dc | ||
|
|
89e3d16f27 | ||
|
|
a02ea64068 | ||
|
|
0f0ace5ddc | ||
|
|
04d94f3bdd | ||
|
|
7d45b68b4f | ||
|
|
ccb47c0120 | ||
|
|
6939bff790 | ||
|
|
8cd0dd4198 | ||
|
|
d6d1f6519a | ||
|
|
906325710b | ||
|
|
05bafeaedf | ||
|
|
babad5a098 | ||
|
|
fe07602a35 | ||
|
|
492533dcdb | ||
|
|
45b044cd6b | ||
|
|
fc65cc3619 | ||
|
|
c6e069331c | ||
|
|
6a8a946ec8 | ||
|
|
d96e4561e2 | ||
|
|
172bc23b2a | ||
|
|
98baf922d6 | ||
|
|
9a7cdc1e74 | ||
|
|
4e22293cda | ||
|
|
f17890b6ce | ||
|
|
66af2de416 | ||
|
|
17e1e6b49b | ||
|
|
e501154ad4 | ||
|
|
c73cf1d7e2 | ||
|
|
a3603f79c8 | ||
|
|
294b4a6bf9 | ||
|
|
f365d93316 | ||
|
|
facd20ba3c | ||
|
|
d0e596c93c | ||
|
|
e20ec4ddf5 | ||
|
|
ba0a1cb1bd | ||
|
|
17438f8c5c | ||
|
|
e0c2ae0f0c | ||
|
|
9ebb211589 | ||
|
|
8a0350c566 | ||
|
|
765d37fd6a | ||
|
|
b3d57b868e | ||
|
|
18e7099848 | ||
|
|
27cb968a18 | ||
|
|
45bf84d448 | ||
|
|
85300b0931 | ||
|
|
ac87c778f4 | ||
|
|
1ed511034c | ||
|
|
ca7f121a21 | ||
|
|
c8e73e17d3 | ||
|
|
3bfc87f1cc | ||
|
|
e0e76bf3fe | ||
|
|
6a3e3f1562 | ||
|
|
59330657b2 | ||
|
|
927d510619 | ||
|
|
80a390ac6c | ||
|
|
cae563ce53 | ||
|
|
0495936ef8 | ||
|
|
34d27fe85b | ||
|
|
0e2c4d74d6 | ||
|
|
bd137de042 | ||
|
|
4a2688b52f | ||
|
|
36acb1daaa | ||
|
|
a0c3b6b26b | ||
|
|
7c93432505 | ||
|
|
2760f25992 | ||
|
|
d199c47666 | ||
|
|
a6550a21ef | ||
|
|
26a321f119 | ||
|
|
7e8f7be905 | ||
|
|
600b6144e4 | ||
|
|
dfb11420e5 | ||
|
|
584c8a2d94 | ||
|
|
536bd9268a | ||
|
|
5ee41b87a2 | ||
|
|
89b2fe10fe | ||
|
|
c180e50164 | ||
|
|
8f7b08afae | ||
|
|
72de8a2192 | ||
|
|
40d99f1dd5 | ||
|
|
ff07841dd6 | ||
|
|
828fc08362 | ||
|
|
3fd043bb9b | ||
|
|
f51c4ebed7 | ||
|
|
9b917cd4c2 | ||
|
|
91eac50ab9 | ||
|
|
f6468ad327 | ||
|
|
fb6c3a9f36 | ||
|
|
eb751bb581 | ||
|
|
f9069bf19b | ||
|
|
ef0c88a3b6 | ||
|
|
f1f8ccb5d6 | ||
|
|
2df113ad38 | ||
|
|
fa03232321 | ||
|
|
04f50284c6 | ||
|
|
9fc950c2ed | ||
|
|
9c1aeb933e | ||
|
|
1cee20134a | ||
|
|
0ca5f5bd89 | ||
|
|
25e0c25bc6 | ||
|
|
3f8453f054 | ||
|
|
cf259af2d1 | ||
|
|
0b70f74553 | ||
|
|
f0bc5d737b | ||
|
|
181d87f68e |
162
.github/workflows/build.yml
vendored
162
.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,158 +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: |
|
|
||||||
Invoke-WebRequest -Uri "http://nginx.org/download/nginx-1.25.2.zip" -OutFile "nginx.zip"
|
|
||||||
Expand-Archive -Path "nginx.zip" -DestinationPath "nginx-1.25.2"
|
|
||||||
Move-Item -Path "nginx-1.25.2/nginx-1.25.2" -Destination "nginx"
|
|
||||||
Remove-Item -Path "nginx.zip"
|
|
||||||
Remove-Item -Path "nginx-1.25.2" -Recurse -Force
|
|
||||||
$FRONTEND_VERSION = (Invoke-WebRequest -Uri "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | ConvertFrom-Json).tag_name
|
|
||||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/$FRONTEND_VERSION/dist.zip" -OutFile "dist.zip"
|
|
||||||
Expand-Archive -Path "dist.zip" -DestinationPath "dist"
|
|
||||||
Move-Item -Path "dist/dist/*" -Destination "nginx/html" -Force
|
|
||||||
Remove-Item -Path "dist.zip"
|
|
||||||
Remove-Item -Path "dist" -Recurse -Force
|
|
||||||
Move-Item -Path "nginx/html/nginx.conf" -Destination "nginx/conf/nginx.conf" -Force
|
|
||||||
New-Item -Path "nginx/temp" -ItemType Directory -Force
|
|
||||||
New-Item -Path "nginx/temp/__keep__.txt" -ItemType File -Force
|
|
||||||
New-Item -Path "nginx/logs" -ItemType Directory -Force
|
|
||||||
New-Item -Path "nginx/logs/__keep__.txt" -ItemType File -Force
|
|
||||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
|
|
||||||
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
|
|
||||||
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force
|
|
||||||
Remove-Item -Path "MoviePilot-Plugins-main.zip"
|
|
||||||
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
|
|
||||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" -OutFile "MoviePilot-Resources-main.zip"
|
|
||||||
Expand-Archive -Path "MoviePilot-Resources-main.zip" -DestinationPath "MoviePilot-Resources-main"
|
|
||||||
Move-Item -Path "MoviePilot-Resources-main/MoviePilot-Resources-main/resources/*" -Destination "app/helper/" -Force
|
|
||||||
Remove-Item -Path "MoviePilot-Resources-main.zip"
|
|
||||||
Remove-Item -Path "MoviePilot-Resources-main" -Recurse -Force
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Pyinstaller
|
|
||||||
run: |
|
|
||||||
pyinstaller frozen.spec
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Upload Windows File
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: windows
|
|
||||||
path: dist/MoviePilot.exe
|
|
||||||
|
|
||||||
Linux-build-amd64:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Build Linux Amd64
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Init Python 3.11.4
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: '3.11.4'
|
|
||||||
cache: 'pip'
|
|
||||||
|
|
||||||
- name: Install Dependent Packages
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install wheel pyinstaller
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
- name: Prepare Frontend
|
|
||||||
run: |
|
|
||||||
wget https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip
|
|
||||||
unzip main.zip
|
|
||||||
mv MoviePilot-Plugins-main/plugins/* app/plugins/
|
|
||||||
rm main.zip
|
|
||||||
rm -rf MoviePilot-Plugins-main
|
|
||||||
|
|
||||||
wget https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip
|
|
||||||
unzip main.zip
|
|
||||||
mv MoviePilot-Resources-main/resources/* app/helper/
|
|
||||||
rm main.zip
|
|
||||||
rm -rf MoviePilot-Resources-main
|
|
||||||
|
|
||||||
- name: Pyinstaller
|
|
||||||
run: |
|
|
||||||
pyinstaller frozen.spec
|
|
||||||
mv dist/MoviePilot dist/MoviePilot_Amd64
|
|
||||||
|
|
||||||
- name: Upload Linux File
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: linux-amd64
|
|
||||||
path: dist/MoviePilot_Amd64
|
|
||||||
|
|
||||||
Create-release:
|
|
||||||
permissions: write-all
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [ Windows-build, Docker-build, Linux-build-amd64]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Release Version
|
|
||||||
id: release_version
|
|
||||||
run: |
|
|
||||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
|
||||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Download Artifact
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
|
|
||||||
- name: get release_informations
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
mkdir releases
|
|
||||||
mv ./windows/MoviePilot.exe ./releases/MoviePilot_Win_v${{ env.app_version }}.exe
|
|
||||||
mv ./linux-amd64/MoviePilot_Amd64 ./releases/MoviePilot_Amd64_v${{ env.app_version }}
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
id: create_release
|
|
||||||
uses: actions/create-release@latest
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
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/
|
|
||||||
|
|||||||
7
.gitignore
vendored
7
.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,10 @@ 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
|
||||||
|
venv
|
||||||
30
Dockerfile
30
Dockerfile
@@ -1,21 +1,22 @@
|
|||||||
FROM python:3.11.4-slim-bullseye
|
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"
|
||||||
RUN apt-get update -y \
|
RUN apt-get update -y \
|
||||||
|
&& apt-get upgrade -y \
|
||||||
&& apt-get -y install \
|
&& apt-get -y install \
|
||||||
musl-dev \
|
musl-dev \
|
||||||
nginx \
|
nginx \
|
||||||
@@ -29,10 +30,10 @@ RUN apt-get update -y \
|
|||||||
busybox \
|
busybox \
|
||||||
dumb-init \
|
dumb-init \
|
||||||
jq \
|
jq \
|
||||||
haproxy \
|
|
||||||
fuse3 \
|
fuse3 \
|
||||||
rsync \
|
rsync \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
|
nano \
|
||||||
&& \
|
&& \
|
||||||
if [ "$(uname -m)" = "x86_64" ]; \
|
if [ "$(uname -m)" = "x86_64" ]; \
|
||||||
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
|
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
|
||||||
@@ -40,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 \
|
||||||
@@ -47,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 \
|
||||||
@@ -66,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" ]
|
||||||
266
README.md
266
README.md
@@ -1,5 +1,14 @@
|
|||||||
# MoviePilot
|
# MoviePilot
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
基于 [NAStool](https://github.com/NAStool/nas-tools) 部分代码重新设计,聚焦自动化核心需求,减少问题同时更易于扩展和维护。
|
基于 [NAStool](https://github.com/NAStool/nas-tools) 部分代码重新设计,聚焦自动化核心需求,减少问题同时更易于扩展和维护。
|
||||||
|
|
||||||
# 仅用于学习交流使用,请勿在任何国内平台宣传该项目!
|
# 仅用于学习交流使用,请勿在任何国内平台宣传该项目!
|
||||||
@@ -7,260 +16,17 @@
|
|||||||
发布频道:https://t.me/moviepilot_channel
|
发布频道:https://t.me/moviepilot_channel
|
||||||
|
|
||||||
## 主要特性
|
## 主要特性
|
||||||
|
|
||||||
- 前后端分离,基于FastApi + Vue3,前端项目地址:[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend),API:http://localhost:3001/docs
|
- 前后端分离,基于FastApi + Vue3,前端项目地址:[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend),API:http://localhost:3001/docs
|
||||||
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
|
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
|
||||||
- 重新设计了用户界面,更加美观易用。
|
- 重新设计了用户界面,更加美观易用。
|
||||||
|
|
||||||
## 安装
|
## 安装使用
|
||||||
|
|
||||||
### 注意:管理员用户不要使用弱密码!如非必要不要暴露到公网。如被盗取管理账号权限,将会导致站点Cookie等敏感数据泄露!
|
访问官方Wiki:https://wiki.movie-pilot.org
|
||||||
|
|
||||||
### 1. **安装CookieCloud插件**
|
## 贡献者
|
||||||
|
|
||||||
站点信息需要通过CookieCloud同步获取,因此需要安装CookieCloud插件,将浏览器中的站点Cookie数据同步到云端后再同步到MoviePilot使用。 插件下载地址请点击 [这里](https://github.com/easychen/CookieCloud/releases)。
|
|
||||||
|
|
||||||
### 2. **安装CookieCloud服务端(可选)**
|
|
||||||
|
|
||||||
通过CookieCloud可以快速同步浏览器中保存的站点数据到MoviePilot,支持以下服务方式:
|
|
||||||
|
|
||||||
- 使用公共CookieCloud远程服务器(默认):服务器地址为:https://movie-pilot.org/cookiecloud
|
|
||||||
- 使用内建的本地Cookie服务:在 `设定` - `站点` 中打开`启用本地CookieCloud服务器`后,将启用内建的CookieCloud提供服务,服务地址为:`http://localhost:${NGINX_PORT}/cookiecloud/`, Cookie数据加密保存在配置文件目录下的`cookies`文件中
|
|
||||||
- 自建服务CookieCloud服务器:参考 [CookieCloud](https://github.com/easychen/CookieCloud) 项目进行搭建,docker镜像请点击 [这里](https://hub.docker.com/r/easychen/cookiecloud)
|
|
||||||
|
|
||||||
**声明:** 本项目不会收集用户敏感数据,Cookie同步也是基于CookieCloud项目实现,非本项目提供的能力。技术角度上CookieCloud采用端到端加密,在个人不泄露`用户KEY`和`端对端加密密码`的情况下第三方无法窃取任何用户信息(包括服务器持有者)。如果你不放心,可以不使用公共服务或者不使用本项目,但如果使用后发生了任何信息泄露与本项目无关!
|
|
||||||
|
|
||||||
### 3. **安装配套管理软件**
|
|
||||||
|
|
||||||
MoviePilot需要配套下载器和媒体服务器配合使用。
|
|
||||||
- 下载器支持:qBittorrent、Transmission,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,推荐使用QB。
|
|
||||||
- 媒体服务器支持:Jellyfin、Emby、Plex,推荐使用Emby。
|
|
||||||
|
|
||||||
### 4. **安装MoviePilot**
|
|
||||||
|
|
||||||
- Docker镜像
|
|
||||||
|
|
||||||
点击 [这里](https://hub.docker.com/r/jxxghp/moviepilot) 或执行命令:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
docker pull jxxghp/moviepilot:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
- Windows
|
|
||||||
|
|
||||||
1. 独立执行文件版本:下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases),双击运行后自动生成配置文件目录,访问:http://localhost:3000
|
|
||||||
2. 安装包版本:[Windows-MoviePilot](https://github.com/developer-wlj/Windows-MoviePilot)
|
|
||||||
|
|
||||||
- 群晖套件
|
|
||||||
|
|
||||||
添加套件源:https://spk7.imnks.com/
|
|
||||||
|
|
||||||
- 本地运行
|
|
||||||
|
|
||||||
1) 将工程 [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins) plugins目录下的所有文件复制到`app/plugins`目录
|
|
||||||
2) 将工程 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) resources目录下的所有文件复制到`app/helper`目录
|
|
||||||
3) 执行命令:`pip install -r requirements.txt` 安装依赖
|
|
||||||
4) 执行命令:`PYTHONPATH=. python app/main.py` 启动服务
|
|
||||||
5) 根据前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend) 说明,启动前端服务
|
|
||||||
|
|
||||||
## 配置
|
|
||||||
|
|
||||||
大部分配置可启动后通过WEB管理界面进行配置,但仍有部分配置需要通过环境变量/配置文件进行配置。
|
|
||||||
|
|
||||||
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件(或通过WEB界面配置) > 默认值。
|
|
||||||
|
|
||||||
> ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
|
|
||||||
|
|
||||||
### 1. **环境变量**
|
|
||||||
|
|
||||||
- **❗NGINX_PORT:** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突
|
|
||||||
- **❗PORT:** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突
|
|
||||||
- **PUID**:运行程序用户的`uid`,默认`0`
|
|
||||||
- **PGID**:运行程序用户的`gid`,默认`0`
|
|
||||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
|
|
||||||
- **PROXY_HOST:** 网络代理,访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`、`socks5://user:pass@host:port`
|
|
||||||
- **MOVIEPILOT_AUTO_UPDATE:** 重启时自动更新,`true`/`release`/`dev`/`false`,默认`release`,需要能正常连接Github **注意:如果出现网络问题可以配置`PROXY_HOST`**
|
|
||||||
- **❗AUTH_SITE:** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
|
|
||||||
|
|
||||||
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
|
|
||||||
认证资源`v1.2.4+`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`/`qingwa`
|
|
||||||
|
|
||||||
| 站点 | 参数 |
|
|
||||||
|:------------:|:-----------------------------------------------------:|
|
|
||||||
| iyuu | `IYUU_SIGN`:IYUU登录令牌 |
|
|
||||||
| hhclub | `HHCLUB_USERNAME`:用户名<br/>`HHCLUB_PASSKEY`:密钥 |
|
|
||||||
| audiences | `AUDIENCES_UID`:用户ID<br/>`AUDIENCES_PASSKEY`:密钥 |
|
|
||||||
| hddolby | `HDDOLBY_ID`:用户ID<br/>`HDDOLBY_PASSKEY`:密钥 |
|
|
||||||
| zmpt | `ZMPT_UID`:用户ID<br/>`ZMPT_PASSKEY`:密钥 |
|
|
||||||
| freefarm | `FREEFARM_UID`:用户ID<br/>`FREEFARM_PASSKEY`:密钥 |
|
|
||||||
| hdfans | `HDFANS_UID`:用户ID<br/>`HDFANS_PASSKEY`:密钥 |
|
|
||||||
| wintersakura | `WINTERSAKURA_UID`:用户ID<br/>`WINTERSAKURA_PASSKEY`:密钥 |
|
|
||||||
| leaves | `LEAVES_UID`:用户ID<br/>`LEAVES_PASSKEY`:密钥 |
|
|
||||||
| ptba | `PTBA_UID`:用户ID<br/>`PTBA_PASSKEY`:密钥 |
|
|
||||||
| icc2022 | `ICC2022_UID`:用户ID<br/>`ICC2022_PASSKEY`:密钥 |
|
|
||||||
| ptlsp | `PTLSP_UID`:用户ID<br/>`PTLSP_PASSKEY`:密钥 |
|
|
||||||
| xingtan | `XINGTAN_UID`:用户ID<br/>`XINGTAN_PASSKEY`:密钥 |
|
|
||||||
| ptvicomo | `PTVICOMO_UID`:用户ID<br/>`PTVICOMO_PASSKEY`:密钥 |
|
|
||||||
| agsvpt | `AGSVPT_UID`:用户ID<br/>`AGSVPT_PASSKEY`:密钥 |
|
|
||||||
| hdkyl | `HDKYL_UID`:用户ID<br/>`HDKYL_PASSKEY`:密钥 |
|
|
||||||
| qingwa | `QINGWA_UID`:用户ID<br/>`QINGWA_PASSKEY`:密钥 |
|
|
||||||
|
|
||||||
|
|
||||||
### 2. **环境变量 / 配置文件**
|
|
||||||
|
|
||||||
配置文件名:`app.env`,放配置文件根目录。
|
|
||||||
|
|
||||||
- **❗SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面,**注意:启动一次后再次修改该值不会生效,除非删除数据库文件!**
|
|
||||||
- **❗API_TOKEN:** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
|
||||||
- **BIG_MEMORY_MODE:** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快
|
|
||||||
- **DOH_ENABLE:** DNS over HTTPS开关,`true`/`false`,默认`true`,开启后会使用DOH对api.themoviedb.org等域名进行解析,以减少被DNS污染的情况,提升网络连通性
|
|
||||||
- **META_CACHE_EXPIRE:** 元数据识别缓存过期时间(小时),数字型,不配置或者配置为0时使用系统默认(大内存模式为7天,否则为3天),调大该值可减少themoviedb的访问次数
|
|
||||||
- **GITHUB_TOKEN:** Github token,提高自动更新、插件安装等请求Github Api的限流阈值,格式:ghp_****
|
|
||||||
- **DEV:** 开发者模式,`true`/`false`,默认`false`,开启后会暂停所有定时任务
|
|
||||||
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`,需要能正常连接Github,仅支持Docker镜像
|
|
||||||
---
|
|
||||||
- **TMDB_API_DOMAIN:** TMDB API地址,默认`api.themoviedb.org`,也可配置为`api.tmdb.org`、`tmdb.movie-pilot.org` 或其它中转代理服务地址,能连通即可
|
|
||||||
- **TMDB_IMAGE_DOMAIN:** TMDB图片地址,默认`image.tmdb.org`,可配置为其它中转代理以加速TMDB图片显示,如:`static-mdb.v.geilijiasu.com`
|
|
||||||
- **WALLPAPER:** 登录首页电影海报,`tmdb`/`bing`,默认`tmdb`
|
|
||||||
- **RECOGNIZE_SOURCE:** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类
|
|
||||||
- **FANART_ENABLE:** Fanart开关,`true`/`false`,默认`true`,关闭后刮削的图片类型会大幅减少
|
|
||||||
- **SCRAP_SOURCE:** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
|
|
||||||
- **SCRAP_FOLLOW_TMDB:** 新增已入库媒体是否跟随TMDB信息变化,`true`/`false`,默认`true`,为`false`时即使TMDB信息变化了也会仍然按历史记录中已入库的信息进行刮削
|
|
||||||
---
|
|
||||||
- **AUTO_DOWNLOAD_USER:** 远程交互搜索时自动择优下载的用户ID(消息通知渠道的用户ID),多个用户使用,分割,设置为 all 代表全部用户自动择优下载,未设置需要手动选择资源或者回复`0`才自动择优下载
|
|
||||||
---
|
|
||||||
- **OCR_HOST:** OCR识别服务器地址,格式:`http(s)://ip:port`,用于识别站点验证码实现自动登录获取Cookie等,不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
|
|
||||||
---
|
|
||||||
- **DOWNLOAD_SUBTITLE:** 下载站点字幕,`true`/`false`,默认`true`
|
|
||||||
---
|
|
||||||
- **SEARCH_MULTIPLE_NAME:** 搜索时是否使用多个名称搜索,`true`/`false`,默认`true`,开启后会使用多个名称进行搜索,搜索结果会更全面,但会增加搜索时间;关闭时只要其中一个名称搜索到结果或全部名称搜索完毕即停止
|
|
||||||
---
|
|
||||||
- **MOVIE_RENAME_FORMAT:** 电影重命名格式,基于jinjia2语法
|
|
||||||
|
|
||||||
`MOVIE_RENAME_FORMAT`支持的配置项:
|
|
||||||
|
|
||||||
> `title`: TMDB/豆瓣中的标题
|
|
||||||
> `en_title`: TMDB中的英文标题 (暂不支持豆瓣)
|
|
||||||
> `original_title`: TMDB/豆瓣中的原语种标题
|
|
||||||
> `name`: 从文件名中识别的名称(同时存在中英文时,优先使用中文)
|
|
||||||
> `en_name`:从文件名中识别的英文名称(可能为空)
|
|
||||||
> `original_name`: 原文件名(包括文件外缀)
|
|
||||||
> `year`: 年份
|
|
||||||
> `resourceType`:资源类型
|
|
||||||
> `effect`:特效
|
|
||||||
> `edition`: 版本(资源类型+特效)
|
|
||||||
> `videoFormat`: 分辨率
|
|
||||||
> `releaseGroup`: 制作组/字幕组
|
|
||||||
> `customization`: 自定义占位符
|
|
||||||
> `videoCodec`: 视频编码
|
|
||||||
> `audioCodec`: 音频编码
|
|
||||||
> `tmdbid`: TMDB ID(非TMDB识别源时为空)
|
|
||||||
> `imdbid`: IMDB ID(可能为空)
|
|
||||||
> `doubanid`:豆瓣ID(非豆瓣识别源时为空)
|
|
||||||
> `part`:段/节
|
|
||||||
> `fileExt`:文件扩展名
|
|
||||||
> `customization`:自定义占位符
|
|
||||||
|
|
||||||
`MOVIE_RENAME_FORMAT`默认配置格式:
|
|
||||||
|
|
||||||
```
|
|
||||||
{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **TV_RENAME_FORMAT:** 电视剧重命名格式,基于jinjia2语法
|
|
||||||
|
|
||||||
`TV_RENAME_FORMAT`额外支持的配置项:
|
|
||||||
|
|
||||||
> `season`: 季号
|
|
||||||
> `episode`: 集号
|
|
||||||
> `season_episode`: 季集 SxxExx
|
|
||||||
> `episode_title`: 集标题
|
|
||||||
|
|
||||||
`TV_RENAME_FORMAT`默认配置格式:
|
|
||||||
|
|
||||||
```
|
|
||||||
{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### 3. **优先级规则**
|
|
||||||
|
|
||||||
- 仅支持使用内置规则进行排列组合,通过设置多层规则来实现优先级顺序匹配
|
|
||||||
- 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高
|
|
||||||
- 不符合过滤规则所有层级规则的资源将不会被选中
|
|
||||||
|
|
||||||
### 4. **插件扩展**
|
|
||||||
|
|
||||||
- **PLUGIN_MARKET:** 插件市场仓库地址,仅支持Github仓库`main`分支,多个地址使用`,`分隔,默认为官方插件仓库:`https://github.com/jxxghp/MoviePilot-Plugins` ,通过查看[MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)项目的fork,或者查看频道置顶了解更多第三方插件仓库。
|
|
||||||
|
|
||||||
|
|
||||||
## 使用
|
|
||||||
|
|
||||||
### 1. **WEB后台管理**
|
|
||||||
- 通过设置的超级管理员用户登录后台管理界面(`SUPERUSER`配置项,默认用户:admin,默认端口:3000)
|
|
||||||
> ❗**注意:超级管理员用户初始密码为自动生成,需要在首次运行时的后台日志中查看!** 如首次运行日志丢失,则需要删除配置文件目录下的`user.db`文件,然后重启服务。
|
|
||||||
### 2. **站点维护**
|
|
||||||
- 通过CookieCloud同步快速添加站点,不需要使用的站点可在WEB管理界面中禁用或删除,无法同步的站点也可手动新增。
|
|
||||||
- 需要通过环境变量设置用户认证信息且认证成功后才能使用站点相关功能,未认证通过时站点相关的插件也会无法显示。
|
|
||||||
### 3. **文件整理**
|
|
||||||
- 默认通过监控下载器实现下载完成后自动整理入库并刮削媒体信息,需要后台打开`下载器监控`开关,且仅会处理通过MoviePilot添加下载的任务。
|
|
||||||
- 使用`目录监控`等插件实现更灵活的自动整理。
|
|
||||||
### 4. **通知交互**
|
|
||||||
- 支持通过`微信`/`Telegram`/`Slack`/`SynologyChat`/`VoceChat`等渠道远程管理和订阅下载,其中 微信/Telegram 将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示)。
|
|
||||||
- `微信`回调地址、`SynologyChat`传入地址地址相对路径均为:`/api/v1/message/`;`VoceChat`的Webhook地址相对路径为:`/api/v1/message/?token=moviepilot`,其中moviepilot为设置的`API_TOKEN`。
|
|
||||||
### 5. **订阅与搜索**
|
|
||||||
- 通过MoviePilot管理后台搜索和订阅。
|
|
||||||
- 将MoviePilot做为`Radarr`或`Sonarr`服务器添加到`Overseerr`或`Jellyseerr`,可使用`Overseerr/Jellyseerr`浏览和添加订阅。
|
|
||||||
- 安装`豆瓣榜单订阅`、`猫眼订阅`等插件,实现自动订阅豆瓣榜单、猫眼榜单等。
|
|
||||||
### 6. **其他**
|
|
||||||
- 通过设置媒体服务器Webhook指向MoviePilot(相对路径为`/api/v1/webhook?token=moviepilot`,其中`moviepilot`为设置的`API_TOKEN`),可实现通过MoviePilot发送播放通知,以及配合各类插件实现播放限速等功能。
|
|
||||||
- 映射宿主机`docker.sock`文件到容器`/var/run/docker.sock`,可支持应用内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`。
|
|
||||||
- 将WEB页面添加到手机桌面图标可获得与App一样的使用体验。
|
|
||||||
|
|
||||||
### **注意**
|
|
||||||
- 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
|
|
||||||
- 使用反向代理时,需要添加以下配置,否则可能会导致部分功能无法访问(`ip:port`修改为实际值):
|
|
||||||
```nginx configuration
|
|
||||||
location / {
|
|
||||||
proxy_pass http://ip:port;
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- 反代使用ssl时,需要开启`http2`,否则会导致日志加载时间过长或不可用。以`Nginx`为例:
|
|
||||||
```nginx configuration
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
http2 on;
|
|
||||||
# ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- 新建的企业微信应用需要固定公网IP的代理才能收到消息,代理添加以下代码:
|
|
||||||
```nginx configuration
|
|
||||||
location /cgi-bin/gettoken {
|
|
||||||
proxy_pass https://qyapi.weixin.qq.com;
|
|
||||||
}
|
|
||||||
location /cgi-bin/message/send {
|
|
||||||
proxy_pass https://qyapi.weixin.qq.com;
|
|
||||||
}
|
|
||||||
location /cgi-bin/menu/create {
|
|
||||||
proxy_pass https://qyapi.weixin.qq.com;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 部分插件功能基于文件系统监控实现(如`目录监控`等),需在宿主机上(不是docker容器内)执行以下命令并重启:
|
|
||||||
```shell
|
|
||||||
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
|
|
||||||
echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
|
|
||||||
sudo sysctl -p
|
|
||||||
```
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
<a href="https://github.com/jxxghp/MoviePilot/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=jxxghp/MoviePilot" />
|
||||||
|
</a>
|
||||||
|
|||||||
@@ -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, \
|
||||||
filebrowser, transfer, mediaserver, bangumi
|
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,8 +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(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
|
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"])
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,13 @@ def calendar(page: int = 1,
|
|||||||
"""
|
"""
|
||||||
浏览Bangumi每日放送
|
浏览Bangumi每日放送
|
||||||
"""
|
"""
|
||||||
infos = BangumiChain().calendar(page=page, count=count)
|
medias = BangumiChain().calendar()
|
||||||
if not infos:
|
if medias:
|
||||||
return []
|
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||||
medias = [MediaInfo(bangumi_info=info) for info in infos]
|
return []
|
||||||
return [media.to_dict() for media in medias]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.BangumiPerson])
|
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson])
|
||||||
def bangumi_credits(bangumiid: int,
|
def bangumi_credits(bangumiid: int,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
count: int = 20,
|
count: int = 20,
|
||||||
@@ -32,23 +31,46 @@ def bangumi_credits(bangumiid: int,
|
|||||||
"""
|
"""
|
||||||
查询Bangumi演职员表
|
查询Bangumi演职员表
|
||||||
"""
|
"""
|
||||||
persons = BangumiChain().bangumi_credits(bangumiid, page=page, count=count)
|
persons = BangumiChain().bangumi_credits(bangumiid)
|
||||||
if not persons:
|
if persons:
|
||||||
return []
|
return persons[(page - 1) * count: page * count]
|
||||||
return [schemas.BangumiPerson(**person) for person in persons]
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recommend/{bangumiid}", summary="查询Bangumi推荐", response_model=List[schemas.MediaInfo])
|
@router.get("/recommend/{bangumiid}", summary="查询Bangumi推荐", response_model=List[schemas.MediaInfo])
|
||||||
def bangumi_recommend(bangumiid: int,
|
def bangumi_recommend(bangumiid: int,
|
||||||
|
page: int = 1,
|
||||||
|
count: int = 20,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询Bangumi推荐
|
查询Bangumi推荐
|
||||||
"""
|
"""
|
||||||
infos = BangumiChain().bangumi_recommend(bangumiid)
|
medias = BangumiChain().bangumi_recommend(bangumiid)
|
||||||
if not infos:
|
if medias:
|
||||||
return []
|
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||||
medias = [MediaInfo(bangumi_info=info) for info in infos]
|
return []
|
||||||
return [media.to_dict() for media in medias]
|
|
||||||
|
|
||||||
|
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||||
|
def bangumi_person(person_id: int,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
根据人物ID查询人物详情
|
||||||
|
"""
|
||||||
|
return BangumiChain().person_detail(person_id=person_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||||
|
def bangumi_person_credits(person_id: int,
|
||||||
|
page: int = 1,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
根据人物ID查询人物参演作品
|
||||||
|
"""
|
||||||
|
medias = BangumiChain().person_credits(person_id=person_id)
|
||||||
|
if medias:
|
||||||
|
return [media.to_dict() for media in medias[(page - 1) * 20: page * 20]]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo)
|
@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from pathlib import Path
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@@ -5,10 +6,11 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.dashboard import DashboardChain
|
from app.chain.dashboard import DashboardChain
|
||||||
from app.core.config import settings
|
from app.chain.storage import StorageChain
|
||||||
from app.core.security import verify_token, verify_uri_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.transferhistory import TransferHistory
|
from app.db.models.transferhistory import TransferHistory
|
||||||
|
from app.helper.directory import DirectoryHelper
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
|
|
||||||
@@ -16,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()
|
||||||
@@ -35,29 +37,38 @@ def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic)
|
@router.get("/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic)
|
||||||
def statistic2(_: str = Depends(verify_uri_token)) -> Any:
|
def statistic2(_: str = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询媒体数量统计信息 API_TOKEN认证(?token=xxx)
|
查询媒体数量统计信息 API_TOKEN认证(?token=xxx)
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
"""
|
"""
|
||||||
查询存储空间信息
|
查询本地存储空间信息
|
||||||
"""
|
"""
|
||||||
total_storage, free_storage = SystemUtils.space_usage(settings.LIBRARY_PATHS)
|
total, available = 0, 0
|
||||||
|
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_uri_token)) -> Any:
|
def storage2(_: str = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询存储空间信息 API_TOKEN认证(?token=xxx)
|
查询本地存储空间信息 API_TOKEN认证(?token=xxx)
|
||||||
"""
|
"""
|
||||||
return storage()
|
return storage()
|
||||||
|
|
||||||
@@ -71,24 +82,28 @@ 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_local_download_dirs()
|
||||||
|
_, 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
|
||||||
downloader_info.upload_speed += transfer_info.upload_speed
|
downloader_info.upload_speed += transfer_info.upload_speed
|
||||||
downloader_info.download_size += transfer_info.download_size
|
downloader_info.download_size += transfer_info.download_size
|
||||||
downloader_info.upload_size += transfer_info.upload_size
|
downloader_info.upload_size += transfer_info.upload_size
|
||||||
downloader_info.free_space = SystemUtils.free_space(settings.SAVE_PATH)
|
downloader_info.free_space = free_space
|
||||||
return downloader_info
|
return downloader_info
|
||||||
|
|
||||||
|
|
||||||
@router.get("/downloader2", summary="下载器信息(API_TOKEN)", response_model=schemas.DownloaderInfo)
|
@router.get("/downloader2", summary="下载器信息(API_TOKEN)", response_model=schemas.DownloaderInfo)
|
||||||
def downloader2(_: str = Depends(verify_uri_token)) -> Any:
|
def downloader2(_: str = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询下载器信息 API_TOKEN认证(?token=xxx)
|
查询下载器信息 API_TOKEN认证(?token=xxx)
|
||||||
"""
|
"""
|
||||||
@@ -104,7 +119,7 @@ def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/schedule2", summary="后台服务(API_TOKEN)", response_model=List[schemas.ScheduleInfo])
|
@router.get("/schedule2", summary="后台服务(API_TOKEN)", response_model=List[schemas.ScheduleInfo])
|
||||||
def schedule2(_: str = Depends(verify_uri_token)) -> Any:
|
def schedule2(_: str = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询下载器信息 API_TOKEN认证(?token=xxx)
|
查询下载器信息 API_TOKEN认证(?token=xxx)
|
||||||
"""
|
"""
|
||||||
@@ -130,7 +145,7 @@ def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/cpu2", summary="获取当前CPU使用率(API_TOKEN)", response_model=int)
|
@router.get("/cpu2", summary="获取当前CPU使用率(API_TOKEN)", response_model=int)
|
||||||
def cpu2(_: str = Depends(verify_uri_token)) -> Any:
|
def cpu2(_: str = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
获取当前CPU使用率 API_TOKEN认证(?token=xxx)
|
获取当前CPU使用率 API_TOKEN认证(?token=xxx)
|
||||||
"""
|
"""
|
||||||
@@ -146,7 +161,7 @@ def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/memory2", summary="获取当前内存使用量和使用率(API_TOKEN)", response_model=List[int])
|
@router.get("/memory2", summary="获取当前内存使用量和使用率(API_TOKEN)", response_model=List[int])
|
||||||
def memory2(_: str = Depends(verify_uri_token)) -> Any:
|
def memory2(_: str = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
获取当前内存使用率 API_TOKEN认证(?token=xxx)
|
获取当前内存使用率 API_TOKEN认证(?token=xxx)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,31 +1,36 @@
|
|||||||
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/{imgurl:path}", summary="豆瓣图片代理")
|
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||||
def douban_img(imgurl: str) -> Any:
|
def douban_person(person_id: int,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
豆瓣图片代理
|
根据人物ID查询人物详情
|
||||||
"""
|
"""
|
||||||
if not imgurl:
|
return DoubanChain().person_detail(person_id=person_id)
|
||||||
return None
|
|
||||||
response = RequestUtils(headers={
|
|
||||||
'Referer': "https://movie.douban.com/"
|
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||||
}, ua=settings.USER_AGENT).get_res(url=imgurl)
|
def douban_person_credits(person_id: int,
|
||||||
if response:
|
page: int = 1,
|
||||||
return Response(content=response.content, media_type="image/jpeg")
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
return None
|
"""
|
||||||
|
根据人物ID查询人物参演作品
|
||||||
|
"""
|
||||||
|
medias = DoubanChain().person_credits(person_id=person_id, page=page)
|
||||||
|
if medias:
|
||||||
|
return [media.to_dict() for media in medias]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
|
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
|
||||||
@@ -36,10 +41,9 @@ def movie_showing(page: int = 1,
|
|||||||
浏览豆瓣正在热映
|
浏览豆瓣正在热映
|
||||||
"""
|
"""
|
||||||
movies = DoubanChain().movie_showing(page=page, count=count)
|
movies = DoubanChain().movie_showing(page=page, count=count)
|
||||||
if not movies:
|
if movies:
|
||||||
return []
|
return [media.to_dict() for media in movies]
|
||||||
medias = [MediaInfo(douban_info=movie) for movie in movies]
|
return []
|
||||||
return [media.to_dict() for media in medias]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
|
@router.get("/movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
|
||||||
@@ -53,13 +57,9 @@ def douban_movies(sort: str = "R",
|
|||||||
"""
|
"""
|
||||||
movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
|
movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
|
||||||
sort=sort, tags=tags, page=page, count=count)
|
sort=sort, tags=tags, page=page, count=count)
|
||||||
if not movies:
|
if movies:
|
||||||
return []
|
return [media.to_dict() for media in movies]
|
||||||
medias = [MediaInfo(douban_info=movie) for movie in movies]
|
return []
|
||||||
return [media.to_dict() for media in medias
|
|
||||||
if media.poster_path
|
|
||||||
and "movie_large.jpg" not in media.poster_path
|
|
||||||
and "tv_normal.png" not in media.poster_path]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
|
@router.get("/tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
|
||||||
@@ -73,14 +73,9 @@ def douban_tvs(sort: str = "R",
|
|||||||
"""
|
"""
|
||||||
tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
|
tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
|
||||||
sort=sort, tags=tags, page=page, count=count)
|
sort=sort, tags=tags, page=page, count=count)
|
||||||
if not tvs:
|
if tvs:
|
||||||
return []
|
return [media.to_dict() for media in tvs]
|
||||||
medias = [MediaInfo(douban_info=tv) for tv in tvs]
|
return []
|
||||||
return [media.to_dict() for media in medias
|
|
||||||
if media.poster_path
|
|
||||||
and "movie_large.jpg" not in media.poster_path
|
|
||||||
and "tv_normal.jpg" not in media.poster_path
|
|
||||||
and "tv_large.jpg" not in media.poster_path]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
||||||
@@ -90,8 +85,10 @@ def movie_top250(page: int = 1,
|
|||||||
"""
|
"""
|
||||||
浏览豆瓣剧集信息
|
浏览豆瓣剧集信息
|
||||||
"""
|
"""
|
||||||
movies = DoubanChain().movie_top250(page=page, count=count) or []
|
movies = DoubanChain().movie_top250(page=page, count=count)
|
||||||
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
|
if movies:
|
||||||
|
return [media.to_dict() for media in movies]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
||||||
@@ -101,8 +98,10 @@ def tv_weekly_chinese(page: int = 1,
|
|||||||
"""
|
"""
|
||||||
中国每周剧集口碑榜
|
中国每周剧集口碑榜
|
||||||
"""
|
"""
|
||||||
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count) or []
|
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
|
||||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
if tvs:
|
||||||
|
return [media.to_dict() for media in tvs]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
||||||
@@ -112,8 +111,10 @@ def tv_weekly_global(page: int = 1,
|
|||||||
"""
|
"""
|
||||||
全球每周剧集口碑榜
|
全球每周剧集口碑榜
|
||||||
"""
|
"""
|
||||||
tvs = DoubanChain().tv_weekly_global(page=page, count=count) or []
|
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
|
||||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
if tvs:
|
||||||
|
return [media.to_dict() for media in tvs]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
|
@router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
|
||||||
@@ -123,8 +124,10 @@ def tv_animation(page: int = 1,
|
|||||||
"""
|
"""
|
||||||
热门动画剧集
|
热门动画剧集
|
||||||
"""
|
"""
|
||||||
tvs = DoubanChain().tv_animation(page=page, count=count) or []
|
tvs = DoubanChain().tv_animation(page=page, count=count)
|
||||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
if tvs:
|
||||||
|
return [media.to_dict() for media in tvs]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
|
@router.get("/movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
|
||||||
@@ -134,8 +137,10 @@ def movie_hot(page: int = 1,
|
|||||||
"""
|
"""
|
||||||
热门电影
|
热门电影
|
||||||
"""
|
"""
|
||||||
movies = DoubanChain().movie_hot(page=page, count=count) or []
|
movies = DoubanChain().movie_hot(page=page, count=count)
|
||||||
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
|
if movies:
|
||||||
|
return [media.to_dict() for media in movies]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
|
@router.get("/tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
|
||||||
@@ -145,29 +150,25 @@ def tv_hot(page: int = 1,
|
|||||||
"""
|
"""
|
||||||
热门电视剧
|
热门电视剧
|
||||||
"""
|
"""
|
||||||
tvs = DoubanChain().tv_hot(page=page, count=count) or []
|
tvs = DoubanChain().tv_hot(page=page, count=count)
|
||||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
if tvs:
|
||||||
|
return [media.to_dict() for media in tvs]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.DoubanPerson])
|
@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:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询演员阵容,type_name: 电影/电视剧
|
根据豆瓣ID查询演员阵容,type_name: 电影/电视剧
|
||||||
"""
|
"""
|
||||||
mediatype = MediaType(type_name)
|
mediatype = MediaType(type_name)
|
||||||
if mediatype == MediaType.MOVIE:
|
if mediatype == MediaType.MOVIE:
|
||||||
doubaninfos = DoubanChain().movie_credits(doubanid=doubanid, page=page)
|
return DoubanChain().movie_credits(doubanid=doubanid)
|
||||||
elif mediatype == MediaType.TV:
|
elif mediatype == MediaType.TV:
|
||||||
doubaninfos = DoubanChain().tv_credits(doubanid=doubanid, page=page)
|
return DoubanChain().tv_credits(doubanid=doubanid)
|
||||||
else:
|
return []
|
||||||
return []
|
|
||||||
if not doubaninfos:
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
return [schemas.DoubanPerson(**doubaninfo) for doubaninfo in doubaninfos]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
@router.get("/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||||
@@ -179,15 +180,14 @@ def douban_recommend(doubanid: str,
|
|||||||
"""
|
"""
|
||||||
mediatype = MediaType(type_name)
|
mediatype = MediaType(type_name)
|
||||||
if mediatype == MediaType.MOVIE:
|
if mediatype == MediaType.MOVIE:
|
||||||
doubaninfos = DoubanChain().movie_recommend(doubanid=doubanid)
|
medias = DoubanChain().movie_recommend(doubanid=doubanid)
|
||||||
elif mediatype == MediaType.TV:
|
elif mediatype == MediaType.TV:
|
||||||
doubaninfos = DoubanChain().tv_recommend(doubanid=doubanid)
|
medias = DoubanChain().tv_recommend(doubanid=doubanid)
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
if not doubaninfos:
|
if medias:
|
||||||
return []
|
return [media.to_dict() for media in medias]
|
||||||
else:
|
return []
|
||||||
return [MediaInfo(douban_info=doubaninfo).to_dict() for doubaninfo in doubaninfos]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
|
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
|
||||||
|
|||||||
@@ -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,27 +9,30 @@ 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:
|
||||||
"""
|
"""
|
||||||
添加下载任务
|
添加下载任务(含媒体信息)
|
||||||
"""
|
"""
|
||||||
# 元数据
|
# 元数据
|
||||||
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
||||||
@@ -45,18 +48,23 @@ 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,
|
||||||
return schemas.Response(success=True if did else False, data={
|
downloader=downloader, save_path=save_path)
|
||||||
|
if not did:
|
||||||
|
return schemas.Response(success=False, message="任务添加失败")
|
||||||
|
return schemas.Response(success=True, data={
|
||||||
"download_id": did
|
"download_id": did
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@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:
|
||||||
"""
|
"""
|
||||||
添加下载任
|
添加下载任务(不含媒体信息)
|
||||||
"""
|
"""
|
||||||
# 元数据
|
# 元数据
|
||||||
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
||||||
@@ -73,8 +81,11 @@ 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,
|
||||||
return schemas.Response(success=True if did else False, data={
|
downloader=downloader, save_path=save_path)
|
||||||
|
if not did:
|
||||||
|
return schemas.Response(success=False, message="任务添加失败")
|
||||||
|
return schemas.Response(success=True, data={
|
||||||
"download_id": did
|
"download_id": did
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -91,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:
|
|
||||||
"""
|
"""
|
||||||
暂停下载任务
|
暂停下载任务
|
||||||
"""
|
"""
|
||||||
@@ -102,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,189 +0,0 @@
|
|||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, List
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from starlette.responses import FileResponse, Response
|
|
||||||
|
|
||||||
from app import schemas
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.security import verify_token
|
|
||||||
from app.log import logger
|
|
||||||
from app.utils.system import SystemUtils
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
IMAGE_TYPES = [".jpg", ".png", ".gif", ".bmp", ".jpeg", ".webp"]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
|
|
||||||
def list_path(path: str,
|
|
||||||
sort: str = 'time',
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
查询当前目录下所有目录和文件
|
|
||||||
:param path: 目录路径
|
|
||||||
:param sort: 排序方式,name:按名称排序,time:按修改时间排序
|
|
||||||
:param _: token
|
|
||||||
:return: 所有目录和文件
|
|
||||||
"""
|
|
||||||
# 返回结果
|
|
||||||
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,
|
|
||||||
basename=partition
|
|
||||||
))
|
|
||||||
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.error(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("/mkdir", summary="创建目录", response_model=schemas.Response)
|
|
||||||
def mkdir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
创建目录
|
|
||||||
"""
|
|
||||||
if not path:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
path_obj = Path(path)
|
|
||||||
if path_obj.exists():
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
path_obj.mkdir(parents=True, exist_ok=True)
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/delete", summary="删除文件或目录", response_model=schemas.Response)
|
|
||||||
def delete(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
删除文件或目录
|
|
||||||
"""
|
|
||||||
if not path:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
path_obj = Path(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(path: str, token: str) -> Any:
|
|
||||||
"""
|
|
||||||
下载文件或目录
|
|
||||||
"""
|
|
||||||
if not path:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
# 认证token
|
|
||||||
if not verify_token(token):
|
|
||||||
return None
|
|
||||||
path_obj = Path(path)
|
|
||||||
if not path_obj.exists():
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
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.get("/rename", summary="重命名文件或目录", response_model=schemas.Response)
|
|
||||||
def rename(path: str, new_name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
重命名文件或目录
|
|
||||||
"""
|
|
||||||
if not path or not new_name:
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
path_obj = Path(path)
|
|
||||||
if not path_obj.exists():
|
|
||||||
return schemas.Response(success=False)
|
|
||||||
path_obj.rename(path_obj.parent / new_name)
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/image", summary="读取图片")
|
|
||||||
def image(path: str, token: str) -> Any:
|
|
||||||
"""
|
|
||||||
读取图片
|
|
||||||
"""
|
|
||||||
if not path:
|
|
||||||
return None
|
|
||||||
# 认证token
|
|
||||||
if not verify_token(token):
|
|
||||||
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:
|
|
||||||
return None
|
|
||||||
return Response(content=path_obj.read_bytes(), media_type="image/jpeg")
|
|
||||||
@@ -1,17 +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.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.schemas.types import EventType
|
from app.db.user_oper import get_current_active_superuser
|
||||||
|
from app.schemas.types import EventType, MediaType
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -75,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,
|
||||||
@@ -103,3 +107,13 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
|||||||
# 删除记录
|
# 删除记录
|
||||||
TransferHistory.delete(db, history_in.id)
|
TransferHistory.delete(db, history_in.id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/empty/transfer", summary="清空转移历史记录", response_model=schemas.Response)
|
||||||
|
def delete_transfer_history(db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
清空转移历史记录
|
||||||
|
"""
|
||||||
|
TransferHistory.truncate(db)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|||||||
@@ -1,73 +1,51 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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.helper.sites import SitesHelper
|
||||||
from app.db import get_db
|
|
||||||
from app.db.models.user import User
|
|
||||||
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)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
获取认证Token
|
获取认证Token
|
||||||
"""
|
"""
|
||||||
# 检查数据库
|
success, user_or_message = UserChain().user_authenticate(username=form_data.username,
|
||||||
user = User.authenticate(
|
password=form_data.password,
|
||||||
db=db,
|
mfa_code=otp_password)
|
||||||
name=form_data.username,
|
|
||||||
password=form_data.password
|
if not success:
|
||||||
)
|
raise HTTPException(status_code=401, detail=user_or_message)
|
||||||
if not user:
|
|
||||||
# 请求协助认证
|
level = SitesHelper().auth_level
|
||||||
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},以普通用户登录...")
|
|
||||||
# 加入用户信息表
|
|
||||||
user = User.get_by_name(db=db, name=form_data.username)
|
|
||||||
if not user:
|
|
||||||
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:
|
|
||||||
# 辅助验证用户若未启用,则禁止登录
|
|
||||||
if not user.is_active:
|
|
||||||
raise HTTPException(status_code=403, detail="用户未启用")
|
|
||||||
# 普通用户权限
|
|
||||||
user.is_superuser = False
|
|
||||||
elif not user.is_active:
|
|
||||||
raise HTTPException(status_code=403, detail="用户未启用")
|
|
||||||
logger.info(f"用户 {user.name} 登录成功!")
|
|
||||||
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
|
||||||
),
|
),
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,19 +54,12 @@ def wallpaper() -> Any:
|
|||||||
"""
|
"""
|
||||||
获取登录页面电影海报
|
获取登录页面电影海报
|
||||||
"""
|
"""
|
||||||
if settings.WALLPAPER == "tmdb":
|
if settings.WALLPAPER == "bing":
|
||||||
return tmdb_wallpaper()
|
url = WebUtils.get_bing_wallpaper()
|
||||||
elif settings.WALLPAPER == "bing":
|
elif settings.WALLPAPER == "mediaserver":
|
||||||
return bing_wallpaper()
|
url = MediaServerChain().get_latest_wallpaper()
|
||||||
return schemas.Response(success=False)
|
else:
|
||||||
|
url = TmdbChain().get_random_wallpager()
|
||||||
|
|
||||||
@router.get("/bing", summary="Bing每日壁纸", response_model=schemas.Response)
|
|
||||||
def bing_wallpaper() -> Any:
|
|
||||||
"""
|
|
||||||
获取Bing每日壁纸
|
|
||||||
"""
|
|
||||||
url = WebUtils.get_bing_wallpaper()
|
|
||||||
if url:
|
if url:
|
||||||
return schemas.Response(
|
return schemas.Response(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -97,15 +68,14 @@ def bing_wallpaper() -> Any:
|
|||||||
return schemas.Response(success=False)
|
return schemas.Response(success=False)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tmdb", summary="TMDB电影海报", response_model=schemas.Response)
|
@router.get("/wallpapers", summary="登录页面电影海报列表", response_model=List[str])
|
||||||
def tmdb_wallpaper() -> Any:
|
def wallpapers() -> Any:
|
||||||
"""
|
"""
|
||||||
获取TMDB电影海报
|
获取登录页面电影海报
|
||||||
"""
|
"""
|
||||||
wallpager = TmdbChain().get_random_wallpager()
|
if settings.WALLPAPER == "bing":
|
||||||
if wallpager:
|
return WebUtils.get_bing_wallpapers()
|
||||||
return schemas.Response(
|
elif settings.WALLPAPER == "mediaserver":
|
||||||
success=True,
|
return MediaServerChain().get_latest_wallpapers()
|
||||||
message=wallpager
|
else:
|
||||||
)
|
return TmdbChain().get_trending_wallpapers()
|
||||||
return schemas.Response(success=False)
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Any
|
from typing import List, Any, Union
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ from app.chain.media import MediaChain
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import Context
|
from app.core.context import Context
|
||||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||||
from app.core.security import verify_token, verify_uri_token
|
from app.core.security import verify_token, verify_apitoken
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -32,7 +32,7 @@ def recognize(title: str,
|
|||||||
@router.get("/recognize2", summary="识别种子媒体信息(API_TOKEN)", response_model=schemas.Context)
|
@router.get("/recognize2", summary="识别种子媒体信息(API_TOKEN)", response_model=schemas.Context)
|
||||||
def recognize2(title: str,
|
def recognize2(title: str,
|
||||||
subtitle: str = None,
|
subtitle: str = None,
|
||||||
_: str = Depends(verify_uri_token)) -> Any:
|
_: str = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx)
|
根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||||
"""
|
"""
|
||||||
@@ -55,7 +55,7 @@ def recognize_file(path: str,
|
|||||||
|
|
||||||
@router.get("/recognize_file2", summary="识别文件媒体信息(API_TOKEN)", response_model=schemas.Context)
|
@router.get("/recognize_file2", summary="识别文件媒体信息(API_TOKEN)", response_model=schemas.Context)
|
||||||
def recognize_file2(path: str,
|
def recognize_file2(path: str,
|
||||||
_: str = Depends(verify_uri_token)) -> Any:
|
_: str = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx)
|
根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||||
"""
|
"""
|
||||||
@@ -63,40 +63,73 @@ def recognize_file2(path: str,
|
|||||||
return recognize_file(path)
|
return recognize_file(path)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search", summary="搜索媒体信息", response_model=List[schemas.MediaInfo])
|
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
|
||||||
def search_by_title(title: str,
|
def search(title: str,
|
||||||
page: int = 1,
|
type: str = "media",
|
||||||
count: int = 8,
|
page: int = 1,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
count: int = 8,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
模糊搜索媒体信息列表
|
模糊搜索媒体/人物信息列表 media:媒体信息,person:人物信息
|
||||||
"""
|
"""
|
||||||
_, medias = MediaChain().search(title=title)
|
def __get_source(obj: Union[dict, schemas.MediaPerson]):
|
||||||
if medias:
|
"""
|
||||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
获取对象属性
|
||||||
return []
|
"""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get("source")
|
||||||
|
return obj.source
|
||||||
|
|
||||||
|
result = []
|
||||||
|
if type == "media":
|
||||||
|
_, medias = MediaChain().search(title=title)
|
||||||
|
if medias:
|
||||||
|
result = [media.to_dict() for media in medias]
|
||||||
|
else:
|
||||||
|
result = MediaChain().search_persons(name=title)
|
||||||
|
if result:
|
||||||
|
# 按设置的顺序对结果进行排序
|
||||||
|
setting_order = settings.SEARCH_SOURCE.split(',') or []
|
||||||
|
sort_order = {}
|
||||||
|
for index, source in enumerate(setting_order):
|
||||||
|
sort_order[source] = index
|
||||||
|
result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4))
|
||||||
|
return result[(page - 1) * count:page * count]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scrape", summary="刮削媒体信息", response_model=schemas.Response)
|
@router.post("/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response)
|
||||||
def scrape(path: str,
|
def scrape(fileitem: schemas.FileItem,
|
||||||
|
storage: str = "local",
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
刮削媒体信息
|
刮削媒体信息
|
||||||
"""
|
"""
|
||||||
if not path:
|
if not fileitem or not fileitem.path:
|
||||||
return schemas.Response(success=False, message="刮削路径无效")
|
return schemas.Response(success=False, message="刮削路径无效")
|
||||||
scrape_path = Path(path)
|
|
||||||
if not scrape_path.exists():
|
|
||||||
return schemas.Response(success=False, message="刮削路径不存在")
|
|
||||||
# 识别
|
|
||||||
chain = MediaChain()
|
chain = MediaChain()
|
||||||
|
# 识别媒体信息
|
||||||
|
scrape_path = Path(fileitem.path)
|
||||||
meta = MetaInfoPath(scrape_path)
|
meta = MetaInfoPath(scrape_path)
|
||||||
mediainfo = chain.recognize_media(meta)
|
mediainfo = chain.recognize_by_meta(meta)
|
||||||
if not media_info:
|
if not media_info:
|
||||||
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
|
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
|
||||||
# 刮削
|
if storage == "local":
|
||||||
chain.scrape_metadata(path=scrape_path, mediainfo=mediainfo, transfer_type=settings.TRANSFER_TYPE)
|
if not scrape_path.exists():
|
||||||
return schemas.Response(success=True, message="刮削完成")
|
return schemas.Response(success=False, message="刮削路径不存在")
|
||||||
|
else:
|
||||||
|
if not fileitem.fileid:
|
||||||
|
return schemas.Response(success=False, message="刮削文件ID无效")
|
||||||
|
# 手动刮削
|
||||||
|
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||||
|
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/category", summary="查询自动分类配置", response_model=dict)
|
||||||
|
def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询自动分类配置
|
||||||
|
"""
|
||||||
|
return MediaChain().media_category() or {}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||||
|
|||||||
@@ -1,50 +1,53 @@
|
|||||||
from typing import Any, List
|
from typing import Any, List, Dict
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.chain.media import MediaChain
|
|
||||||
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.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:
|
||||||
mediaserver = settings.MEDIASERVER.split(",")[0]
|
return schemas.Response(success=False, message="未配置媒体服务器")
|
||||||
play_url = MediaServerChain().get_play_url(server=mediaserver, item_id=itemid)
|
media_chain = MediaServerChain()
|
||||||
# 重定向到play_url
|
for name in configs.keys():
|
||||||
if not play_url:
|
item = media_chain.iteminfo(server=name, item_id=itemid)
|
||||||
return schemas.Response(success=False, msg="未找到播放地址")
|
if item:
|
||||||
return schemas.Response(success=True, data={
|
play_url = media_chain.get_play_url(server=name, item_id=itemid)
|
||||||
"url": play_url
|
if play_url:
|
||||||
})
|
return schemas.Response(success=True, data={
|
||||||
|
"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)
|
||||||
def exists(title: str = None,
|
def exists_local(title: str = None,
|
||||||
year: int = None,
|
year: int = None,
|
||||||
mtype: str = None,
|
mtype: str = None,
|
||||||
tmdbid: int = None,
|
tmdbid: int = None,
|
||||||
season: int = None,
|
season: int = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
判断本地是否存在
|
判断本地是否存在
|
||||||
"""
|
"""
|
||||||
@@ -61,35 +64,35 @@ def exists(title: str = None,
|
|||||||
ret_info = {
|
ret_info = {
|
||||||
"id": exist.item_id
|
"id": exist.item_id
|
||||||
}
|
}
|
||||||
"""
|
|
||||||
else:
|
|
||||||
# 服务器是否存在
|
|
||||||
mediainfo = MediaInfo()
|
|
||||||
mediainfo.from_dict({
|
|
||||||
"title": meta.name,
|
|
||||||
"year": year or meta.year,
|
|
||||||
"type": mtype or meta.type,
|
|
||||||
"tmdb_id": tmdbid,
|
|
||||||
"season": season
|
|
||||||
})
|
|
||||||
exist: schemas.ExistMediaInfo = MediaServerChain().media_exists(
|
|
||||||
mediainfo=mediainfo
|
|
||||||
)
|
|
||||||
if exist:
|
|
||||||
ret_info = {
|
|
||||||
"id": exist.itemid
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
return schemas.Response(success=True if exist else False, data={
|
return schemas.Response(success=True if exist else False, data={
|
||||||
"item": ret_info
|
"item": ret_info
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[schemas.NotExistMediaInfo])
|
@router.post("/exists_remote", summary="查询已存在的剧集信息(媒体服务器)", response_model=Dict[int, list])
|
||||||
|
def exists(media_in: schemas.MediaInfo,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
根据媒体信息查询媒体库已存在的剧集信息
|
||||||
|
"""
|
||||||
|
# 转化为媒体信息对象
|
||||||
|
mediainfo = MediaInfo()
|
||||||
|
mediainfo.from_dict(media_in.dict())
|
||||||
|
existsinfo: schemas.ExistMediaInfo = MediaServerChain().media_exists(mediainfo=mediainfo)
|
||||||
|
if not existsinfo:
|
||||||
|
return []
|
||||||
|
if media_in.season:
|
||||||
|
return {
|
||||||
|
media_in.season: existsinfo.seasons.get(media_in.season) or []
|
||||||
|
}
|
||||||
|
return existsinfo.seasons
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notexists", summary="查询媒体库缺失信息(媒体服务器)", response_model=List[schemas.NotExistMediaInfo])
|
||||||
def not_exists(media_in: schemas.MediaInfo,
|
def not_exists(media_in: schemas.MediaInfo,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询缺失媒体信息
|
根据媒体信息查询缺失电影/剧集
|
||||||
"""
|
"""
|
||||||
# 媒体信息
|
# 媒体信息
|
||||||
meta = MetaInfo(title=media_in.title)
|
meta = MetaInfo(title=media_in.title)
|
||||||
@@ -101,18 +104,13 @@ def not_exists(media_in: schemas.MediaInfo,
|
|||||||
meta.type = MediaType.TV
|
meta.type = MediaType.TV
|
||||||
if media_in.year:
|
if media_in.year:
|
||||||
meta.year = media_in.year
|
meta.year = media_in.year
|
||||||
if media_in.tmdb_id or media_in.douban_id:
|
# 转化为媒体信息对象
|
||||||
mediainfo = MediaChain().recognize_media(meta=meta, mtype=mtype,
|
mediainfo = MediaInfo()
|
||||||
tmdbid=media_in.tmdb_id, doubanid=media_in.douban_id)
|
mediainfo.from_dict(media_in.dict())
|
||||||
else:
|
|
||||||
mediainfo = MediaChain().recognize_by_meta(metainfo=meta)
|
|
||||||
# 查询缺失信息
|
|
||||||
if not mediainfo:
|
|
||||||
raise HTTPException(status_code=404, detail="媒体信息不存在")
|
|
||||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
|
||||||
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)
|
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)
|
||||||
|
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||||
if mediainfo.type == MediaType.MOVIE:
|
if mediainfo.type == MediaType.MOVIE:
|
||||||
# 电影已存在时返回空列表,存在时返回空对像列表
|
# 电影已存在时返回空列表,不存在时返回空对像列表
|
||||||
return [] if exist_flag else [NotExistMediaInfo()]
|
return [] if exist_flag else [NotExistMediaInfo()]
|
||||||
elif no_exists and no_exists.get(mediakey):
|
elif no_exists and no_exists.get(mediakey):
|
||||||
# 电视剧返回缺失的剧集
|
# 电视剧返回缺失的剧集
|
||||||
@@ -121,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,24 +1,23 @@
|
|||||||
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 sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.responses import PlainTextResponse
|
from starlette.responses import PlainTextResponse
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.message import MessageChain
|
from app.chain.message import MessageChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings, 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()
|
||||||
|
|
||||||
@@ -31,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()
|
||||||
@@ -49,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
|
||||||
@@ -75,79 +76,85 @@ 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])
|
@router.post("/webpush/subscribe", summary="客户端webpush通知订阅", response_model=schemas.Response)
|
||||||
def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)):
|
||||||
"""
|
"""
|
||||||
查询通知消息渠道开关
|
客户端webpush通知订阅
|
||||||
"""
|
"""
|
||||||
return_list = []
|
subinfo = subscription.dict()
|
||||||
# 读取数据库
|
if subinfo not in global_vars.get_subscriptions():
|
||||||
switchs = SystemConfigOper().get(SystemConfigKey.NotificationChannels)
|
global_vars.push_subscription(subinfo)
|
||||||
if not switchs:
|
logger.debug(f"通知订阅成功: {subinfo}")
|
||||||
for noti in NotificationType:
|
return schemas.Response(success=True)
|
||||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
|
||||||
telegram=True, slack=True,
|
|
||||||
synologychat=True, vocechat=True))
|
@router.post("/webpush/send", summary="发送webpush通知", response_model=schemas.Response)
|
||||||
else:
|
def send_notification(payload: schemas.SubscriptionMessage, _: schemas.TokenPayload = Depends(verify_token)):
|
||||||
for switch in switchs:
|
"""
|
||||||
return_list.append(NotificationSwitch(**switch))
|
发送webpush通知
|
||||||
return return_list
|
"""
|
||||||
|
for sub in global_vars.get_subscriptions():
|
||||||
|
try:
|
||||||
@router.post("/switchs", summary="设置通知消息渠道开关", response_model=schemas.Response)
|
webpush(
|
||||||
def set_switchs(switchs: List[NotificationSwitch],
|
subscription_info=sub,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
data=json.dumps(payload.dict()),
|
||||||
"""
|
vapid_private_key=settings.VAPID.get("privateKey"),
|
||||||
设置通知消息渠道开关
|
vapid_claims={
|
||||||
"""
|
"sub": settings.VAPID.get("subject")
|
||||||
switch_list = []
|
},
|
||||||
for switch in switchs:
|
)
|
||||||
switch_list.append(switch.dict())
|
except WebPushException as err:
|
||||||
# 存入数据库
|
logger.error(f"WebPush发送失败: {str(err)}")
|
||||||
SystemConfigOper().set(SystemConfigKey.NotificationChannels, switch_list)
|
continue
|
||||||
|
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|||||||
@@ -1,29 +1,133 @@
|
|||||||
from typing import Any, List
|
from typing import Annotated, Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
动态注册插件 API
|
||||||
|
:param plugin_id: 插件 ID,如果为 None,则注册所有插件
|
||||||
|
"""
|
||||||
|
_update_plugin_api_routes(plugin_id, action="add")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_plugin_api(plugin_id: str):
|
||||||
|
"""
|
||||||
|
动态移除单个插件的 API
|
||||||
|
:param plugin_id: 插件 ID
|
||||||
|
"""
|
||||||
|
_update_plugin_api_routes(plugin_id, action="remove")
|
||||||
|
|
||||||
|
|
||||||
|
def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
|
||||||
|
"""
|
||||||
|
插件 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") -> Any:
|
def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||||
|
state: str = "all") -> List[schemas.Plugin]:
|
||||||
"""
|
"""
|
||||||
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
||||||
"""
|
"""
|
||||||
# 本地插件
|
# 本地插件
|
||||||
local_plugins = PluginManager().get_local_plugins()
|
local_plugins = PluginManager().get_local_plugins()
|
||||||
# 已安装插件
|
# 已安装插件
|
||||||
installed_plugins = [plugin for plugin in local_plugins if plugin.get("installed")]
|
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
|
||||||
# 未安装的本地插件
|
# 未安装的本地插件
|
||||||
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.get("installed")]
|
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
|
||||||
if state == "installed":
|
if state == "installed":
|
||||||
return installed_plugins
|
return installed_plugins
|
||||||
|
|
||||||
@@ -39,17 +143,17 @@ def all_plugins(_: schemas.TokenPayload = Depends(verify_token), state: str = "a
|
|||||||
# 插件市场插件清单
|
# 插件市场插件清单
|
||||||
market_plugins = []
|
market_plugins = []
|
||||||
# 已安装插件IDS
|
# 已安装插件IDS
|
||||||
_installed_ids = [plugin["id"] for plugin in installed_plugins]
|
_installed_ids = [plugin.id for plugin in installed_plugins]
|
||||||
# 未安装的线上插件或者有更新的插件
|
# 未安装的线上插件或者有更新的插件
|
||||||
for plugin in online_plugins:
|
for plugin in online_plugins:
|
||||||
if plugin["id"] not in _installed_ids:
|
if plugin.id not in _installed_ids:
|
||||||
market_plugins.append(plugin)
|
market_plugins.append(plugin)
|
||||||
elif plugin.get("has_update"):
|
elif plugin.has_update:
|
||||||
market_plugins.append(plugin)
|
market_plugins.append(plugin)
|
||||||
# 未安装的本地插件,且不在线上插件中
|
# 未安装的本地插件,且不在线上插件中
|
||||||
_plugin_ids = [plugin["id"] for plugin in market_plugins]
|
_plugin_ids = [plugin.id for plugin in market_plugins]
|
||||||
for plugin in not_installed_plugins:
|
for plugin in not_installed_plugins:
|
||||||
if plugin["id"] not in _plugin_ids:
|
if plugin.id not in _plugin_ids:
|
||||||
market_plugins.append(plugin)
|
market_plugins.append(plugin)
|
||||||
# 返回插件清单
|
# 返回插件清单
|
||||||
if state == "market":
|
if state == "market":
|
||||||
@@ -60,45 +164,63 @@ 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:
|
||||||
"""
|
"""
|
||||||
查询用户已安装插件清单
|
查询用户已安装插件清单
|
||||||
"""
|
"""
|
||||||
return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistic", summary="插件安装统计", response_model=dict)
|
||||||
|
def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
插件安装统计
|
||||||
|
"""
|
||||||
|
return PluginHelper().get_statistic()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
||||||
def install(plugin_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)
|
||||||
# 保存设置
|
# 保存设置
|
||||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||||
# 重载插件管理器
|
# 加载插件到内存
|
||||||
PluginManager().init_config()
|
PluginManager().reload_plugin(plugin_id)
|
||||||
# 注册插件服务
|
# 注册插件服务
|
||||||
Scheduler().update_plugin_job(plugin_id)
|
Scheduler().update_plugin_job(plugin_id)
|
||||||
|
# 注册菜单命令
|
||||||
|
CommandChain().init_commands(plugin_id)
|
||||||
|
# 注册插件API
|
||||||
|
register_plugin_api(plugin_id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@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获取插件配置表单
|
||||||
"""
|
"""
|
||||||
@@ -110,29 +232,63 @@ 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获取插件数据页面
|
||||||
"""
|
"""
|
||||||
return PluginManager().get_plugin_page(plugin_id)
|
return PluginManager().get_plugin_page(plugin_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/reset/{plugin_id}", summary="重置插件配置", response_model=schemas.Response)
|
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
|
||||||
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
根据插件ID重置插件配置
|
获取所有插件仪表板元信息
|
||||||
|
"""
|
||||||
|
return PluginManager().get_plugin_dashboard_meta()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置")
|
||||||
|
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
||||||
|
"""
|
||||||
|
根据插件ID获取插件仪表板
|
||||||
|
"""
|
||||||
|
return PluginManager().get_plugin_dashboard(plugin_id, user_agent=user_agent)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
|
||||||
|
def plugin_dashboard(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
||||||
|
"""
|
||||||
|
根据插件ID获取插件仪表板
|
||||||
|
"""
|
||||||
|
return PluginManager().get_plugin_dashboard(plugin_id, key=key, user_agent=user_agent)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
|
||||||
|
def reset_plugin(plugin_id: str,
|
||||||
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
根据插件ID重置插件配置及数据
|
||||||
"""
|
"""
|
||||||
# 删除配置
|
# 删除配置
|
||||||
PluginManager().delete_plugin_config(plugin_id)
|
PluginManager().delete_plugin_config(plugin_id)
|
||||||
|
# 删除插件所有数据
|
||||||
|
PluginManager().delete_plugin_data(plugin_id)
|
||||||
# 重新生效插件
|
# 重新生效插件
|
||||||
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
|
||||||
|
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获取插件配置信息
|
||||||
"""
|
"""
|
||||||
@@ -141,22 +297,26 @@ 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:
|
||||||
"""
|
"""
|
||||||
更新插件配置
|
更新插件配置
|
||||||
"""
|
"""
|
||||||
# 保存配置
|
# 保存配置
|
||||||
PluginManager().save_plugin_config(plugin_id, conf)
|
PluginManager().save_plugin_config(plugin_id, conf)
|
||||||
# 重新生效插件
|
# 重新生效插件
|
||||||
PluginManager().reload_plugin(plugin_id, conf)
|
PluginManager().init_plugin(plugin_id, conf)
|
||||||
# 注册插件服务
|
# 注册插件服务
|
||||||
Scheduler().update_plugin_job(plugin_id)
|
Scheduler().update_plugin_job(plugin_id)
|
||||||
|
# 注册菜单命令
|
||||||
|
CommandChain().init_commands(plugin_id)
|
||||||
|
# 注册插件API
|
||||||
|
register_plugin_api(plugin_id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@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:
|
||||||
"""
|
"""
|
||||||
卸载插件
|
卸载插件
|
||||||
"""
|
"""
|
||||||
@@ -168,13 +328,14 @@ def uninstall_plugin(plugin_id: str,
|
|||||||
break
|
break
|
||||||
# 保存
|
# 保存
|
||||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||||
# 重载插件管理器
|
# 移除插件API
|
||||||
PluginManager().init_config()
|
remove_plugin_api(plugin_id)
|
||||||
# 移除插件服务
|
# 移除插件服务
|
||||||
Scheduler().remove_plugin_job(plugin_id)
|
Scheduler().remove_plugin_job(plugin_id)
|
||||||
|
# 移除插件
|
||||||
|
PluginManager().remove_plugin(plugin_id)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
# 注册插件API
|
# 注册全部插件API
|
||||||
for api in PluginManager().get_plugin_apis():
|
register_plugin_api()
|
||||||
router.add_api_route(**api)
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
|
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
|
||||||
async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询搜索结果
|
查询搜索结果
|
||||||
"""
|
"""
|
||||||
@@ -21,17 +21,19 @@ async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
return [torrent.to_dict() for torrent in torrents]
|
return [torrent.to_dict() for torrent in torrents]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=List[schemas.Context])
|
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=schemas.Response)
|
||||||
def search_by_id(mediaid: str,
|
def search_by_id(mediaid: str,
|
||||||
mtype: str = None,
|
mtype: str = None,
|
||||||
area: str = "title",
|
area: str = "title",
|
||||||
|
season: str = None,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/
|
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:
|
||||||
"""
|
"""
|
||||||
torrents = []
|
|
||||||
if mtype:
|
if mtype:
|
||||||
mtype = MediaType(mtype)
|
mtype = MediaType(mtype)
|
||||||
|
if season:
|
||||||
|
season = int(season)
|
||||||
if mediaid.startswith("tmdb:"):
|
if mediaid.startswith("tmdb:"):
|
||||||
tmdbid = int(mediaid.replace("tmdb:", ""))
|
tmdbid = int(mediaid.replace("tmdb:", ""))
|
||||||
if settings.RECOGNIZE_SOURCE == "douban":
|
if settings.RECOGNIZE_SOURCE == "douban":
|
||||||
@@ -39,19 +41,25 @@ def search_by_id(mediaid: str,
|
|||||||
doubaninfo = MediaChain().get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=mtype)
|
doubaninfo = MediaChain().get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=mtype)
|
||||||
if doubaninfo:
|
if doubaninfo:
|
||||||
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
|
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
|
||||||
mtype=mtype, area=area)
|
mtype=mtype, area=area, season=season)
|
||||||
|
else:
|
||||||
|
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
|
||||||
else:
|
else:
|
||||||
torrents = SearchChain().search_by_id(tmdbid=tmdbid, mtype=mtype, area=area)
|
torrents = SearchChain().search_by_id(tmdbid=tmdbid, mtype=mtype, area=area, season=season)
|
||||||
elif mediaid.startswith("douban:"):
|
elif mediaid.startswith("douban:"):
|
||||||
doubanid = mediaid.replace("douban:", "")
|
doubanid = mediaid.replace("douban:", "")
|
||||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||||
# 通过豆瓣ID识别TMDBID
|
# 通过豆瓣ID识别TMDBID
|
||||||
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
|
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
|
||||||
if tmdbinfo:
|
if tmdbinfo:
|
||||||
|
if tmdbinfo.get('season') and not season:
|
||||||
|
season = tmdbinfo.get('season')
|
||||||
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
|
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
|
||||||
mtype=mtype, area=area)
|
mtype=mtype, area=area, season=season)
|
||||||
|
else:
|
||||||
|
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
|
||||||
else:
|
else:
|
||||||
torrents = SearchChain().search_by_id(doubanid=doubanid, mtype=mtype, area=area)
|
torrents = SearchChain().search_by_id(doubanid=doubanid, mtype=mtype, area=area, season=season)
|
||||||
elif mediaid.startswith("bangumi:"):
|
elif mediaid.startswith("bangumi:"):
|
||||||
bangumiid = int(mediaid.replace("bangumi:", ""))
|
bangumiid = int(mediaid.replace("bangumi:", ""))
|
||||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||||
@@ -59,25 +67,35 @@ def search_by_id(mediaid: str,
|
|||||||
tmdbinfo = MediaChain().get_tmdbinfo_by_bangumiid(bangumiid=bangumiid)
|
tmdbinfo = MediaChain().get_tmdbinfo_by_bangumiid(bangumiid=bangumiid)
|
||||||
if tmdbinfo:
|
if tmdbinfo:
|
||||||
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
|
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
|
||||||
mtype=mtype, area=area)
|
mtype=mtype, area=area, season=season)
|
||||||
|
else:
|
||||||
|
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
|
||||||
else:
|
else:
|
||||||
# 通过BangumiID识别豆瓣ID
|
# 通过BangumiID识别豆瓣ID
|
||||||
doubaninfo = MediaChain().get_doubaninfo_by_bangumiid(bangumiid=bangumiid)
|
doubaninfo = MediaChain().get_doubaninfo_by_bangumiid(bangumiid=bangumiid)
|
||||||
if doubaninfo:
|
if doubaninfo:
|
||||||
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
|
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
|
||||||
mtype=mtype, area=area)
|
mtype=mtype, area=area, season=season)
|
||||||
|
else:
|
||||||
|
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
|
||||||
else:
|
else:
|
||||||
return []
|
return schemas.Response(success=False, message="未知的媒体ID")
|
||||||
return [torrent.to_dict() for torrent in torrents]
|
|
||||||
|
if not torrents:
|
||||||
|
return schemas.Response(success=False, message="未搜索到任何资源")
|
||||||
|
else:
|
||||||
|
return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/title", summary="模糊搜索资源", response_model=List[schemas.TorrentInfo])
|
@router.get("/title", summary="模糊搜索资源", response_model=schemas.Response)
|
||||||
async def search_by_title(keyword: str = None,
|
def search_by_title(keyword: str = None,
|
||||||
page: int = 0,
|
page: int = 0,
|
||||||
site: int = None,
|
site: int = None,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
|
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
|
||||||
"""
|
"""
|
||||||
torrents = SearchChain().search_by_title(title=keyword, page=page, site=site)
|
torrents = SearchChain().search_by_title(title=keyword, page=page, site=site)
|
||||||
return [torrent.to_dict() for torrent in torrents]
|
if not torrents:
|
||||||
|
return schemas.Response(success=False, message="未搜索到任何资源")
|
||||||
|
return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ from app.chain.torrents import TorrentsChain
|
|||||||
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.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.siteuserdata import SiteUserData
|
||||||
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.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
|
||||||
@@ -23,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]:
|
||||||
"""
|
"""
|
||||||
获取站点列表
|
获取站点列表
|
||||||
"""
|
"""
|
||||||
@@ -35,17 +39,19 @@ 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:
|
||||||
"""
|
"""
|
||||||
新增站点
|
新增站点
|
||||||
"""
|
"""
|
||||||
if not site_in.url:
|
if not site_in.url:
|
||||||
return schemas.Response(success=False, message="站点地址不能为空")
|
return schemas.Response(success=False, message="站点地址不能为空")
|
||||||
|
if SitesHelper().auth_level < 2:
|
||||||
|
return schemas.Response(success=False, message="用户未通过认证,无法使用站点功能!")
|
||||||
domain = StringUtils.get_url_domain(site_in.url)
|
domain = StringUtils.get_url_domain(site_in.url)
|
||||||
site_info = SitesHelper().get_indexer(domain)
|
site_info = SitesHelper().get_indexer(domain)
|
||||||
if not site_info:
|
if not site_info:
|
||||||
return schemas.Response(success=False, message="该站点不支持或用户未通过认证")
|
return schemas.Response(success=False, message="该站点不支持,请检查站点域名是否正确")
|
||||||
if Site.get_by_domain(db, domain):
|
if Site.get_by_domain(db, domain):
|
||||||
return schemas.Response(success=False, message=f"{domain} 站点己存在")
|
return schemas.Response(success=False, message=f"{domain} 站点己存在")
|
||||||
# 保存站点信息
|
# 保存站点信息
|
||||||
@@ -55,6 +61,7 @@ def add_site(
|
|||||||
site_in.url = f"{_scheme}://{_netloc}/"
|
site_in.url = f"{_scheme}://{_netloc}/"
|
||||||
site_in.name = site_info.get("name")
|
site_in.name = site_info.get("name")
|
||||||
site_in.id = None
|
site_in.id = None
|
||||||
|
site_in.public = 1 if site_info.get("public") else 0
|
||||||
site = Site(**site_in.dict())
|
site = Site(**site_in.dict())
|
||||||
site.create(db)
|
site.create(db)
|
||||||
# 通知站点更新
|
# 通知站点更新
|
||||||
@@ -69,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:
|
||||||
"""
|
"""
|
||||||
更新站点信息
|
更新站点信息
|
||||||
@@ -88,27 +95,9 @@ def update_site(
|
|||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
|
|
||||||
def delete_site(
|
|
||||||
site_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
删除站点
|
|
||||||
"""
|
|
||||||
Site.delete(db, site_id)
|
|
||||||
# 插件站点删除
|
|
||||||
EventManager().send_event(EventType.SiteDeleted,
|
|
||||||
{
|
|
||||||
"site_id": site_id
|
|
||||||
})
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
|
|
||||||
|
|
||||||
@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同步站点信息
|
||||||
"""
|
"""
|
||||||
@@ -118,7 +107,7 @@ def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
|||||||
|
|
||||||
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
|
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
|
||||||
def reset(db: Session = Depends(get_db),
|
def reset(db: Session = Depends(get_db),
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
清空所有站点数据并重新同步CookieCloud站点信息
|
清空所有站点数据并重新同步CookieCloud站点信息
|
||||||
"""
|
"""
|
||||||
@@ -130,11 +119,26 @@ def reset(db: Session = Depends(get_db),
|
|||||||
# 插件站点删除
|
# 插件站点删除
|
||||||
EventManager().send_event(EventType.SiteDeleted,
|
EventManager().send_event(EventType.SiteDeleted,
|
||||||
{
|
{
|
||||||
"site_id": None
|
"site_id": "*"
|
||||||
})
|
})
|
||||||
return schemas.Response(success=True, message="站点已重置!")
|
return schemas.Response(success=True, message="站点已重置!")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/priorities", summary="批量更新站点优先级", response_model=schemas.Response)
|
||||||
|
def update_sites_priority(
|
||||||
|
priorities: List[dict],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||||
|
"""
|
||||||
|
批量更新站点优先级
|
||||||
|
"""
|
||||||
|
for priority in priorities:
|
||||||
|
site = Site.get(db, priority.get("id"))
|
||||||
|
if site:
|
||||||
|
site.update(db, {"pri": priority.get("pri")})
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response)
|
@router.get("/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response)
|
||||||
def update_cookie(
|
def update_cookie(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
@@ -142,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
|
||||||
"""
|
"""
|
||||||
@@ -161,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),
|
||||||
@@ -202,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:
|
||||||
"""
|
"""
|
||||||
浏览站点资源
|
浏览站点资源
|
||||||
"""
|
"""
|
||||||
@@ -237,8 +296,25 @@ def read_site_by_domain(
|
|||||||
return site
|
return site
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistic/{site_url}", summary="站点统计信息", response_model=schemas.SiteStatistic)
|
||||||
|
def read_site_by_domain(
|
||||||
|
site_url: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
通过域名获取站点统计信息
|
||||||
|
"""
|
||||||
|
domain = StringUtils.get_url_domain(site_url)
|
||||||
|
sitestatistic = SiteStatistic.get_by_domain(db, domain)
|
||||||
|
if sitestatistic:
|
||||||
|
return sitestatistic
|
||||||
|
return schemas.SiteStatistic(domain=domain)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
|
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
|
||||||
def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
|
def read_rss_sites(db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
获取站点列表
|
获取站点列表
|
||||||
"""
|
"""
|
||||||
@@ -259,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获取站点信息
|
||||||
@@ -271,3 +347,21 @@ def read_site(
|
|||||||
detail=f"站点 {site_id} 不存在",
|
detail=f"站点 {site_id} 不存在",
|
||||||
)
|
)
|
||||||
return site
|
return site
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
|
||||||
|
def delete_site(
|
||||||
|
site_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_active_superuser)
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
删除站点
|
||||||
|
"""
|
||||||
|
Site.delete(db, site_id)
|
||||||
|
# 插件站点删除
|
||||||
|
EventManager().send_event(EventType.SiteDeleted,
|
||||||
|
{
|
||||||
|
"site_id": site_id
|
||||||
|
})
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|||||||
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,18 +1,21 @@
|
|||||||
import json
|
|
||||||
from typing import List, Any
|
from typing import List, Any
|
||||||
|
|
||||||
|
import cn2an
|
||||||
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
|
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.subscribe import SubscribeChain
|
from app.chain.subscribe import SubscribeChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.context import MediaInfo
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.core.security import verify_token, verify_uri_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.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
|
from app.db.models.subscribehistory import SubscribeHistory
|
||||||
from app.db.models.user import User
|
from app.db.models.user import User
|
||||||
from app.db.userauth import get_current_active_user
|
from app.db.user_oper import get_current_active_user
|
||||||
|
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
|
||||||
|
|
||||||
@@ -35,15 +38,11 @@ def read_subscribes(
|
|||||||
"""
|
"""
|
||||||
查询所有订阅
|
查询所有订阅
|
||||||
"""
|
"""
|
||||||
subscribes = Subscribe.list(db)
|
return Subscribe.list(db)
|
||||||
for subscribe in subscribes:
|
|
||||||
if subscribe.sites:
|
|
||||||
subscribe.sites = json.loads(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])
|
||||||
def list_subscribes(_: str = Depends(verify_uri_token)) -> Any:
|
def list_subscribes(_: str = Depends(verify_apitoken)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询所有订阅 API_TOKEN认证(?token=xxx)
|
查询所有订阅 API_TOKEN认证(?token=xxx)
|
||||||
"""
|
"""
|
||||||
@@ -55,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:
|
||||||
"""
|
"""
|
||||||
新增订阅
|
新增订阅
|
||||||
"""
|
"""
|
||||||
@@ -85,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}
|
||||||
@@ -104,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:
|
||||||
@@ -161,8 +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:
|
|
||||||
result.sites = json.loads(result.sites)
|
|
||||||
|
|
||||||
return result if result else Subscribe()
|
return result if result else Subscribe()
|
||||||
|
|
||||||
@@ -177,6 +175,24 @@ def refresh_subscribes(
|
|||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response)
|
||||||
|
def reset_subscribes(
|
||||||
|
subid: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
重置订阅
|
||||||
|
"""
|
||||||
|
subscribe = Subscribe.get(db, subid)
|
||||||
|
if subscribe:
|
||||||
|
subscribe.update(db, {
|
||||||
|
"note": "",
|
||||||
|
"lack_episode": subscribe.total_episode
|
||||||
|
})
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
return schemas.Response(success=False, message="订阅不存在")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
|
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
|
||||||
def check_subscribes(
|
def check_subscribes(
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
@@ -197,9 +213,11 @@ def search_subscribes(
|
|||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
Scheduler().start,
|
Scheduler().start,
|
||||||
job_id="subscribe_search",
|
job_id="subscribe_search",
|
||||||
sid=None,
|
**{
|
||||||
state='R',
|
"sid": None,
|
||||||
manual=True
|
"state": 'R',
|
||||||
|
"manual": True
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
@@ -215,29 +233,15 @@ def search_subscribe(
|
|||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
Scheduler().start,
|
Scheduler().start,
|
||||||
job_id="subscribe_search",
|
job_id="subscribe_search",
|
||||||
sid=subscribe_id,
|
**{
|
||||||
state=None,
|
"sid": subscribe_id,
|
||||||
manual=True
|
"state": None,
|
||||||
|
"manual": True
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
|
|
||||||
def read_subscribe(
|
|
||||||
subscribe_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|
||||||
"""
|
|
||||||
根据订阅编号查询订阅信息
|
|
||||||
"""
|
|
||||||
if not subscribe_id:
|
|
||||||
return Subscribe()
|
|
||||||
subscribe = Subscribe.get(db, subscribe_id)
|
|
||||||
if subscribe and subscribe.sites:
|
|
||||||
subscribe.sites = json.loads(subscribe.sites)
|
|
||||||
return subscribe
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/media/{mediaid}", summary="删除订阅", response_model=schemas.Response)
|
@router.delete("/media/{mediaid}", summary="删除订阅", response_model=schemas.Response)
|
||||||
def delete_subscribe_by_mediaid(
|
def delete_subscribe_by_mediaid(
|
||||||
mediaid: str,
|
mediaid: str,
|
||||||
@@ -262,19 +266,6 @@ def delete_subscribe_by_mediaid(
|
|||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
|
|
||||||
def delete_subscribe(
|
|
||||||
subscribe_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_: schemas.TokenPayload = Depends(verify_token)
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
删除订阅信息
|
|
||||||
"""
|
|
||||||
Subscribe.delete(db, subscribe_id)
|
|
||||||
return schemas.Response(success=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response)
|
@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response)
|
||||||
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||||
authorization: str = Header(None)) -> Any:
|
authorization: str = Header(None)) -> Any:
|
||||||
@@ -326,3 +317,177 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
|||||||
username=user_name)
|
username=user_name)
|
||||||
|
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe])
|
||||||
|
def subscribe_history(
|
||||||
|
mtype: str,
|
||||||
|
page: int = 1,
|
||||||
|
count: int = 30,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询电影/电视剧订阅历史
|
||||||
|
"""
|
||||||
|
return SubscribeHistory.list_by_type(db, mtype=mtype, page=page, count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response)
|
||||||
|
def delete_subscribe(
|
||||||
|
history_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
删除订阅历史
|
||||||
|
"""
|
||||||
|
SubscribeHistory.delete(db, history_id)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/popular", summary="热门订阅(基于用户共享数据)", response_model=List[schemas.MediaInfo])
|
||||||
|
def popular_subscribes(
|
||||||
|
stype: str,
|
||||||
|
page: int = 1,
|
||||||
|
count: int = 30,
|
||||||
|
min_sub: int = None,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询热门订阅
|
||||||
|
"""
|
||||||
|
subscribes = SubscribeHelper().get_statistic(stype=stype, page=page, count=count)
|
||||||
|
if subscribes:
|
||||||
|
ret_medias = []
|
||||||
|
for sub in subscribes:
|
||||||
|
# 订阅人数
|
||||||
|
count = sub.get("count")
|
||||||
|
if min_sub and count < min_sub:
|
||||||
|
continue
|
||||||
|
media = MediaInfo()
|
||||||
|
media.type = MediaType(sub.get("type"))
|
||||||
|
media.tmdb_id = sub.get("tmdbid")
|
||||||
|
# 处理标题
|
||||||
|
title = sub.get("name")
|
||||||
|
season = sub.get("season")
|
||||||
|
if season and int(season) > 1 and media.tmdb_id:
|
||||||
|
# 小写数据转大写
|
||||||
|
season_str = cn2an.an2cn(season, "low")
|
||||||
|
title = f"{title} 第{season_str}季"
|
||||||
|
media.title = title
|
||||||
|
media.year = sub.get("year")
|
||||||
|
media.douban_id = sub.get("doubanid")
|
||||||
|
media.bangumi_id = sub.get("bangumiid")
|
||||||
|
media.tvdb_id = sub.get("tvdbid")
|
||||||
|
media.imdb_id = sub.get("imdbid")
|
||||||
|
media.season = sub.get("season")
|
||||||
|
media.overview = sub.get("description")
|
||||||
|
media.vote_average = sub.get("vote")
|
||||||
|
media.poster_path = sub.get("poster")
|
||||||
|
media.backdrop_path = sub.get("backdrop")
|
||||||
|
media.popularity = count
|
||||||
|
ret_medias.append(media)
|
||||||
|
return [media.to_dict() for media in ret_medias]
|
||||||
|
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)
|
||||||
|
def read_subscribe(
|
||||||
|
subscribe_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
根据订阅编号查询订阅信息
|
||||||
|
"""
|
||||||
|
if not subscribe_id:
|
||||||
|
return Subscribe()
|
||||||
|
return Subscribe.get(db, subscribe_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
|
||||||
|
def delete_subscribe(
|
||||||
|
subscribe_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
删除订阅信息
|
||||||
|
"""
|
||||||
|
subscribe = Subscribe.get(db, subscribe_id)
|
||||||
|
if subscribe:
|
||||||
|
subscribe.delete(db, subscribe_id)
|
||||||
|
# 统计订阅
|
||||||
|
SubscribeHelper().sub_done_async({
|
||||||
|
"tmdbid": subscribe.tmdbid,
|
||||||
|
"doubanid": subscribe.doubanid
|
||||||
|
})
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|||||||
@@ -1,112 +1,255 @@
|
|||||||
|
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.core.config import settings
|
from app.chain.system import SystemChain
|
||||||
|
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.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/img/{imgurl:path}/{proxy}", summary="图片代理")
|
def fetch_image(
|
||||||
def get_img(imgurl: str, proxy: bool = False) -> Any:
|
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 imgurl:
|
|
||||||
return None
|
if not url:
|
||||||
if proxy:
|
raise HTTPException(status_code=404, detail="URL not provided")
|
||||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=settings.PROXY).get_res(url=imgurl)
|
|
||||||
else:
|
if allowed_domains is None:
|
||||||
response = RequestUtils(ua=settings.USER_AGENT).get_res(url=imgurl)
|
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS)
|
||||||
if response:
|
|
||||||
return Response(content=response.content, media_type="image/jpeg")
|
# 验证URL安全性
|
||||||
return None
|
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("/env", summary="查询系统环境变量", response_model=schemas.Response)
|
@router.get("/img/{proxy}", summary="图片代理")
|
||||||
def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
def proxy_img(
|
||||||
|
imgurl: str,
|
||||||
|
proxy: bool = False,
|
||||||
|
if_none_match: Optional[str] = Header(None),
|
||||||
|
_: schemas.TokenPayload = Depends(verify_resource_token)
|
||||||
|
) -> Response:
|
||||||
"""
|
"""
|
||||||
查询系统环境变量,包括当前版本号
|
图片代理,可选是否使用代理服务器,支持 HTTP 缓存
|
||||||
|
"""
|
||||||
|
# 媒体服务器添加图片代理支持
|
||||||
|
hosts = [config.config.get("host") for config in MediaServerHelper().get_configs().values() if
|
||||||
|
config and config.config and config.config.get("host")]
|
||||||
|
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts)
|
||||||
|
return fetch_image(url=imgurl, proxy=proxy, use_disk_cache=False,
|
||||||
|
if_none_match=if_none_match, allowed_domains=allowed_domains)
|
||||||
|
|
||||||
|
|
||||||
|
@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)):
|
||||||
|
"""
|
||||||
|
查询系统环境变量,包括当前版本号(仅管理员)
|
||||||
"""
|
"""
|
||||||
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,
|
||||||
"AUTH_VERSION": SitesHelper().auth_version,
|
"AUTH_VERSION": SitesHelper().auth_version,
|
||||||
"INDEXER_VERSION": SitesHelper().indexer_version,
|
"INDEXER_VERSION": SitesHelper().indexer_version,
|
||||||
|
"FRONTEND_VERSION": SystemChain().get_frontend_version()
|
||||||
})
|
})
|
||||||
return schemas.Response(success=True,
|
return schemas.Response(success=True,
|
||||||
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,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)):
|
_: 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:
|
||||||
detail = progress.get(process_type)
|
while not global_vars.is_system_stopped:
|
||||||
yield 'data: %s\n\n' % json.dumps(detail)
|
if await request.is_disconnected():
|
||||||
time.sleep(0.2)
|
break
|
||||||
|
detail = progress.get(process_type)
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
|
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
|
||||||
def get_setting(key: str,
|
def get_setting(key: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)):
|
_: User = Depends(get_current_active_superuser)):
|
||||||
"""
|
"""
|
||||||
查询系统设置
|
查询系统设置(仅管理员)
|
||||||
"""
|
"""
|
||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
value = getattr(settings, key)
|
value = getattr(settings, key)
|
||||||
@@ -119,80 +262,91 @@ def get_setting(key: str,
|
|||||||
|
|
||||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
||||||
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
|
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)):
|
_: 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 = "sys"):
|
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:
|
||||||
detail = message.get(role)
|
while not global_vars.is_system_stopped:
|
||||||
yield 'data: %s\n\n' % (detail or '')
|
if await request.is_disconnected():
|
||||||
time.sleep(3)
|
break
|
||||||
|
detail = message.get(role)
|
||||||
|
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:
|
|
||||||
for t in tailer.follow(open(log_path, 'r', encoding='utf-8')):
|
async def log_generator():
|
||||||
yield 'data: %s\n\n' % (t or '')
|
try:
|
||||||
time.sleep(1)
|
# 使用固定大小的双向队列来限制内存使用
|
||||||
|
lines_queue = deque(maxlen=max(length, 50))
|
||||||
|
# 使用 aiofiles 异步读取文件
|
||||||
|
async with aiofiles.open(log_path, mode="r", encoding="utf-8") as f:
|
||||||
|
# 逐行读取文件,将每一行存入队列
|
||||||
|
file_content = await f.read()
|
||||||
|
for line in file_content.splitlines():
|
||||||
|
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流响应
|
||||||
@@ -213,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-搜索
|
||||||
@@ -225,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
|
||||||
})
|
})
|
||||||
@@ -276,9 +426,12 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
|||||||
"""
|
"""
|
||||||
查询已加载的模块ID列表
|
查询已加载的模块ID列表
|
||||||
"""
|
"""
|
||||||
module_ids = [module.__name__ for module in ModuleManager().get_modules("test")]
|
modules = [{
|
||||||
|
"id": k,
|
||||||
|
"name": v.get_name(),
|
||||||
|
} for k, v in ModuleManager().get_modules().items()]
|
||||||
return schemas.Response(success=True, data={
|
return schemas.Response(success=True, data={
|
||||||
"ids": module_ids
|
"modules": modules
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -292,34 +445,50 @@ 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(_: schemas.TokenPayload = Depends(verify_token)):
|
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="当前运行环境不支持重启操作!")
|
||||||
|
# 标识停止事件
|
||||||
|
global_vars.stop_system()
|
||||||
# 执行重启
|
# 执行重启
|
||||||
ret, msg = SystemUtils.restart()
|
ret, msg = SystemUtils.restart()
|
||||||
return schemas.Response(success=ret, message=msg)
|
return schemas.Response(success=ret, message=msg)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
|
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
|
||||||
def reload_module(_: schemas.TokenPayload = Depends(verify_token)):
|
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,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)):
|
_: 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)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends
|
|||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.tmdb import TmdbChain
|
from app.chain.tmdb import TmdbChain
|
||||||
from app.core.context import MediaInfo
|
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
|
|
||||||
@@ -17,10 +16,9 @@ def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -
|
|||||||
根据TMDBID查询themoviedb所有季信息
|
根据TMDBID查询themoviedb所有季信息
|
||||||
"""
|
"""
|
||||||
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
|
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
|
||||||
if not seasons_info:
|
if seasons_info:
|
||||||
return []
|
|
||||||
else:
|
|
||||||
return seasons_info
|
return seasons_info
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo])
|
@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||||
@@ -32,15 +30,14 @@ def tmdb_similar(tmdbid: int,
|
|||||||
"""
|
"""
|
||||||
mediatype = MediaType(type_name)
|
mediatype = MediaType(type_name)
|
||||||
if mediatype == MediaType.MOVIE:
|
if mediatype == MediaType.MOVIE:
|
||||||
tmdbinfos = TmdbChain().movie_similar(tmdbid=tmdbid)
|
medias = TmdbChain().movie_similar(tmdbid=tmdbid)
|
||||||
elif mediatype == MediaType.TV:
|
elif mediatype == MediaType.TV:
|
||||||
tmdbinfos = TmdbChain().tv_similar(tmdbid=tmdbid)
|
medias = TmdbChain().tv_similar(tmdbid=tmdbid)
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
if not tmdbinfos:
|
if medias:
|
||||||
return []
|
return [media.to_dict() for media in medias]
|
||||||
else:
|
return []
|
||||||
return [MediaInfo(tmdb_info=tmdbinfo).to_dict() for tmdbinfo in tmdbinfos]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||||
@@ -52,18 +49,17 @@ def tmdb_recommend(tmdbid: int,
|
|||||||
"""
|
"""
|
||||||
mediatype = MediaType(type_name)
|
mediatype = MediaType(type_name)
|
||||||
if mediatype == MediaType.MOVIE:
|
if mediatype == MediaType.MOVIE:
|
||||||
tmdbinfos = TmdbChain().movie_recommend(tmdbid=tmdbid)
|
medias = TmdbChain().movie_recommend(tmdbid=tmdbid)
|
||||||
elif mediatype == MediaType.TV:
|
elif mediatype == MediaType.TV:
|
||||||
tmdbinfos = TmdbChain().tv_recommend(tmdbid=tmdbid)
|
medias = TmdbChain().tv_recommend(tmdbid=tmdbid)
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
if not tmdbinfos:
|
if medias:
|
||||||
return []
|
return [media.to_dict() for media in medias]
|
||||||
else:
|
return []
|
||||||
return [MediaInfo(tmdb_info=tmdbinfo).to_dict() for tmdbinfo in tmdbinfos]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.TmdbPerson])
|
@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.MediaPerson])
|
||||||
def tmdb_credits(tmdbid: int,
|
def tmdb_credits(tmdbid: int,
|
||||||
type_name: str,
|
type_name: str,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
@@ -73,28 +69,21 @@ def tmdb_credits(tmdbid: int,
|
|||||||
"""
|
"""
|
||||||
mediatype = MediaType(type_name)
|
mediatype = MediaType(type_name)
|
||||||
if mediatype == MediaType.MOVIE:
|
if mediatype == MediaType.MOVIE:
|
||||||
tmdbinfos = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
|
persons = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
|
||||||
elif mediatype == MediaType.TV:
|
elif mediatype == MediaType.TV:
|
||||||
tmdbinfos = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
|
persons = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
if not tmdbinfos:
|
return persons or []
|
||||||
return []
|
|
||||||
else:
|
|
||||||
return [schemas.TmdbPerson(**tmdbinfo) for tmdbinfo in tmdbinfos]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.TmdbPerson)
|
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||||
def tmdb_person(person_id: int,
|
def tmdb_person(person_id: int,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
根据人物ID查询人物详情
|
根据人物ID查询人物详情
|
||||||
"""
|
"""
|
||||||
tmdbinfo = TmdbChain().person_detail(person_id=person_id)
|
return TmdbChain().person_detail(person_id=person_id)
|
||||||
if not tmdbinfo:
|
|
||||||
return schemas.TmdbPerson()
|
|
||||||
else:
|
|
||||||
return schemas.TmdbPerson(**tmdbinfo)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||||
@@ -104,11 +93,10 @@ def tmdb_person_credits(person_id: int,
|
|||||||
"""
|
"""
|
||||||
根据人物ID查询人物参演作品
|
根据人物ID查询人物参演作品
|
||||||
"""
|
"""
|
||||||
tmdbinfo = TmdbChain().person_credits(person_id=person_id, page=page)
|
medias = TmdbChain().person_credits(person_id=person_id, page=page)
|
||||||
if not tmdbinfo:
|
if medias:
|
||||||
return []
|
return [media.to_dict() for media in medias]
|
||||||
else:
|
return []
|
||||||
return [MediaInfo(tmdb_info=tmdbinfo).to_dict() for tmdbinfo in tmdbinfo]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/movies", summary="TMDB电影", response_model=List[schemas.MediaInfo])
|
@router.get("/movies", summary="TMDB电影", response_model=List[schemas.MediaInfo])
|
||||||
@@ -127,7 +115,7 @@ def tmdb_movies(sort_by: str = "popularity.desc",
|
|||||||
page=page)
|
page=page)
|
||||||
if not movies:
|
if not movies:
|
||||||
return []
|
return []
|
||||||
return [MediaInfo(tmdb_info=movie).to_dict() for movie in movies]
|
return [movie.to_dict() for movie in movies]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
|
@router.get("/tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
|
||||||
@@ -146,7 +134,7 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
|
|||||||
page=page)
|
page=page)
|
||||||
if not tvs:
|
if not tvs:
|
||||||
return []
|
return []
|
||||||
return [MediaInfo(tmdb_info=tv).to_dict() for tv in tvs]
|
return [tv.to_dict() for tv in tvs]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
|
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
|
||||||
@@ -158,7 +146,7 @@ def tmdb_trending(page: int = 1,
|
|||||||
infos = TmdbChain().tmdb_trending(page=page)
|
infos = TmdbChain().tmdb_trending(page=page)
|
||||||
if not infos:
|
if not infos:
|
||||||
return []
|
return []
|
||||||
return [MediaInfo(tmdb_info=info).to_dict() for info in infos]
|
return [info.to_dict() for info in infos]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
|
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
|
||||||
@@ -167,8 +155,4 @@ def tmdb_season_episodes(tmdbid: int, season: int,
|
|||||||
"""
|
"""
|
||||||
根据TMDBID查询某季的所有信信息
|
根据TMDBID查询某季的所有信信息
|
||||||
"""
|
"""
|
||||||
episodes_info = TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)
|
return TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)
|
||||||
if not episodes_info:
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
return episodes_info
|
|
||||||
|
|||||||
@@ -1,101 +1,153 @@
|
|||||||
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.storage import StorageChain
|
||||||
from app.chain.transfer import TransferChain
|
from app.chain.transfer import TransferChain
|
||||||
from app.core.security import verify_token
|
from app.core.metainfo import MetaInfoPath
|
||||||
|
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)
|
||||||
|
def query_name(path: str, filetype: str,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询整理后的名称
|
||||||
|
:param path: 文件路径
|
||||||
|
:param filetype: 文件类型
|
||||||
|
:param _: Token校验
|
||||||
|
"""
|
||||||
|
meta = MetaInfoPath(Path(path))
|
||||||
|
mediainfo = MediaChain().recognize_media(meta)
|
||||||
|
if not mediainfo:
|
||||||
|
return schemas.Response(success=False, message="未识别到媒体信息")
|
||||||
|
new_path = TransferChain().recommend_name(meta=meta, mediainfo=mediainfo)
|
||||||
|
if not new_path:
|
||||||
|
return schemas.Response(success=False, message="未识别到新名称")
|
||||||
|
if filetype == "dir":
|
||||||
|
parents = Path(new_path).parents
|
||||||
|
if len(parents) > 2:
|
||||||
|
new_name = parents[1].name
|
||||||
|
else:
|
||||||
|
new_name = parents[0].name
|
||||||
|
else:
|
||||||
|
new_name = Path(new_path).name
|
||||||
|
return schemas.Response(success=True, data={
|
||||||
|
"name": new_name
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
||||||
def manual_transfer(path: str = None,
|
def manual_transfer(transer_item: ManualTransferItem,
|
||||||
logid: int = None,
|
|
||||||
target: str = None,
|
|
||||||
tmdbid: int = 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,
|
|
||||||
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 path: 转移路径或文件
|
:param transer_item: 手工整理项
|
||||||
:param logid: 转移历史记录ID
|
|
||||||
:param target: 目标路径
|
|
||||||
:param type_name: 媒体类型、电影/电视剧
|
|
||||||
:param tmdbid: tmdbid
|
|
||||||
:param season: 剧集季号
|
|
||||||
:param transfer_type: 转移类型,move/copy 等
|
|
||||||
:param episode_format: 剧集识别格式
|
|
||||||
:param episode_detail: 剧集识别详细信息
|
|
||||||
:param episode_part: 剧集识别分集信息
|
|
||||||
:param episode_offset: 剧集识别偏移量
|
|
||||||
:param min_filesize: 最小文件大小(MB)
|
|
||||||
: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)
|
||||||
if not target:
|
state = StorageChain().delete_media_file(dest_fileitem, mtype=MediaType(history.type))
|
||||||
target = transfer.get_root_path(path=history.dest,
|
if not state:
|
||||||
type_name=history.type,
|
return schemas.Response(success=False, message=f"{dest_fileitem.path} 删除失败")
|
||||||
category=history.category)
|
|
||||||
elif path:
|
# 从历史数据获取信息
|
||||||
in_path = Path(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(
|
||||||
in_path=in_path,
|
fileitem=src_fileitem,
|
||||||
target=target,
|
target_storage=transer_item.target_storage,
|
||||||
tmdbid=tmdbid,
|
target_path=target_path,
|
||||||
|
tmdbid=transer_item.tmdbid,
|
||||||
|
doubanid=transer_item.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=transer_item.scrape,
|
||||||
force=force
|
force=force
|
||||||
)
|
)
|
||||||
# 失败
|
# 失败
|
||||||
@@ -105,3 +157,12 @@ def manual_transfer(path: str = None,
|
|||||||
return schemas.Response(success=False, message=errormsg)
|
return schemas.Response(success=False, message=errormsg)
|
||||||
# 成功
|
# 成功
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response)
|
||||||
|
def now(_: str = Depends(verify_apitoken)) -> Any:
|
||||||
|
"""
|
||||||
|
立即执行下载器文件整理 API_TOKEN认证(?token=xxx)
|
||||||
|
"""
|
||||||
|
TransferChain().process()
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
from typing import Any, List
|
from typing import Any, List, Union
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -9,15 +9,17 @@ 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.utils.otp import OtpUtils
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="所有用户", response_model=List[schemas.User])
|
@router.get("/", summary="所有用户", response_model=List[schemas.User])
|
||||||
def read_users(
|
def 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:
|
||||||
"""
|
"""
|
||||||
查询用户列表
|
查询用户列表
|
||||||
@@ -28,10 +30,10 @@ def read_users(
|
|||||||
|
|
||||||
@router.post("/", summary="新增用户", response_model=schemas.Response)
|
@router.post("/", summary="新增用户", response_model=schemas.Response)
|
||||||
def create_user(
|
def create_user(
|
||||||
*,
|
*,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user_in: schemas.UserCreate,
|
user_in: schemas.UserCreate,
|
||||||
current_user: User = Depends(get_current_active_superuser),
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
新增用户
|
新增用户
|
||||||
@@ -50,10 +52,10 @@ def create_user(
|
|||||||
|
|
||||||
@router.put("/", summary="更新用户", response_model=schemas.Response)
|
@router.put("/", summary="更新用户", response_model=schemas.Response)
|
||||||
def update_user(
|
def update_user(
|
||||||
*,
|
*,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user_in: schemas.UserCreate,
|
user_in: schemas.UserUpdate,
|
||||||
_: User = Depends(get_current_active_superuser),
|
_: User = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
更新用户
|
更新用户
|
||||||
@@ -63,10 +65,19 @@ def update_user(
|
|||||||
# 正则表达式匹配密码包含字母、数字、特殊字符中的至少两项
|
# 正则表达式匹配密码包含字母、数字、特殊字符中的至少两项
|
||||||
pattern = r'^(?![a-zA-Z]+$)(?!\d+$)(?![^\da-zA-Z\s]+$).{6,50}$'
|
pattern = r'^(?![a-zA-Z]+$)(?!\d+$)(?![^\da-zA-Z\s]+$).{6,50}$'
|
||||||
if not re.match(pattern, user_info.get("password")):
|
if not re.match(pattern, user_info.get("password")):
|
||||||
return schemas.Response(success=False, message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位")
|
return schemas.Response(success=False,
|
||||||
|
message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位")
|
||||||
user_info["hashed_password"] = get_password_hash(user_info["password"])
|
user_info["hashed_password"] = get_password_hash(user_info["password"])
|
||||||
user_info.pop("password")
|
user_info.pop("password")
|
||||||
user = User.get_by_name(db, name=user_info["name"])
|
user = User.get_by_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)
|
||||||
@@ -75,7 +86,7 @@ def update_user(
|
|||||||
|
|
||||||
@router.get("/current", summary="当前登录用户信息", response_model=schemas.User)
|
@router.get("/current", summary="当前登录用户信息", response_model=schemas.User)
|
||||||
def read_current_user(
|
def read_current_user(
|
||||||
current_user: User = Depends(get_current_active_user)
|
current_user: User = Depends(get_current_active_user)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
当前登录用户信息
|
当前登录用户信息
|
||||||
@@ -84,8 +95,8 @@ def read_current_user(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response)
|
@router.post("/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response)
|
||||||
async def upload_avatar(user_id: int, db: Session = Depends(get_db),
|
def upload_avatar(user_id: int, db: Session = Depends(get_db), file: UploadFile = File(...),
|
||||||
file: UploadFile = File(...)):
|
_: User = Depends(get_current_active_user)):
|
||||||
"""
|
"""
|
||||||
上传用户头像
|
上传用户头像
|
||||||
"""
|
"""
|
||||||
@@ -101,15 +112,93 @@ async def upload_avatar(user_id: int, db: Session = Depends(get_db),
|
|||||||
return schemas.Response(success=True, message=file.filename)
|
return schemas.Response(success=True, message=file.filename)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_name}", summary="删除用户", response_model=schemas.Response)
|
@router.post('/otp/generate', summary='生成otp验证uri', response_model=schemas.Response)
|
||||||
def delete_user(
|
def otp_generate(
|
||||||
*,
|
current_user: User = Depends(get_current_active_user)
|
||||||
db: Session = Depends(get_db),
|
) -> Any:
|
||||||
user_name: str,
|
secret, uri = OtpUtils.generate_secret_key(current_user.name)
|
||||||
current_user: User = Depends(get_current_active_superuser),
|
return schemas.Response(success=secret != "", data={'secret': secret, 'uri': uri})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/otp/judge', summary='判断otp验证是否通过', response_model=schemas.Response)
|
||||||
|
def otp_judge(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
) -> Any:
|
||||||
|
uri = data.get("uri")
|
||||||
|
otp_password = data.get("otpPassword")
|
||||||
|
if not OtpUtils.is_legal(uri, otp_password):
|
||||||
|
return schemas.Response(success=False, message="验证码错误")
|
||||||
|
current_user.update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(uri))
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/otp/disable', summary='关闭当前用户的otp验证', response_model=schemas.Response)
|
||||||
|
def otp_disable(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
) -> Any:
|
||||||
|
current_user.update_otp_by_name(db, current_user.name, False, "")
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/otp/{userid}', summary='判断当前用户是否开启otp验证', response_model=schemas.Response)
|
||||||
|
def otp_enable(userid: str, db: Session = Depends(get_db)) -> Any:
|
||||||
|
user: User = User.get_by_name(db, userid)
|
||||||
|
if not user:
|
||||||
|
return schemas.Response(success=False)
|
||||||
|
return schemas.Response(success=user.is_otp)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config/{key}", summary="查询用户配置", response_model=schemas.Response)
|
||||||
|
def get_config(key: str,
|
||||||
|
current_user: User = Depends(get_current_active_user)):
|
||||||
|
"""
|
||||||
|
查询用户配置
|
||||||
|
"""
|
||||||
|
value = UserConfigOper().get(username=current_user.name, key=key)
|
||||||
|
return schemas.Response(success=True, data={
|
||||||
|
"value": value
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response)
|
||||||
|
def set_config(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||||
|
current_user: User = Depends(get_current_active_user)):
|
||||||
|
"""
|
||||||
|
更新用户配置
|
||||||
|
"""
|
||||||
|
UserConfigOper().set(username=current_user.name, key=key, value=value)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response)
|
||||||
|
def delete_user_by_id(
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> 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),
|
||||||
|
user_name: str,
|
||||||
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
|
) -> 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:
|
||||||
@@ -118,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,
|
||||||
@@ -135,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="用户权限不足"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, BackgroundTasks, Request, Depends
|
|||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.webhook import WebhookChain
|
from app.chain.webhook import WebhookChain
|
||||||
from app.core.security import verify_uri_token
|
from app.core.security import verify_apitoken
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -19,10 +19,10 @@ def start_webhook_chain(body: Any, form: Any, args: Any):
|
|||||||
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
|
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||||
async def webhook_message(background_tasks: BackgroundTasks,
|
async def webhook_message(background_tasks: BackgroundTasks,
|
||||||
request: Request,
|
request: Request,
|
||||||
_: str = Depends(verify_uri_token)
|
_: 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()
|
||||||
@@ -32,10 +32,10 @@ async def webhook_message(background_tasks: BackgroundTasks,
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
|
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||||
async def webhook_message(background_tasks: BackgroundTasks,
|
def webhook_message(background_tasks: BackgroundTasks,
|
||||||
request: Request, _: str = Depends(verify_uri_token)) -> 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)
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ 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.subscribe import SubscribeChain
|
from app.chain.subscribe import SubscribeChain
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.core.security import verify_uri_apikey
|
from app.core.security import verify_apikey
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
from app.schemas import RadarrMovie, SonarrSeries
|
from app.schemas import RadarrMovie, SonarrSeries
|
||||||
@@ -19,7 +18,7 @@ arr_router = APIRouter(tags=['servarr'])
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/system/status", summary="系统状态")
|
@arr_router.get("/system/status", summary="系统状态")
|
||||||
def arr_system_status(_: str = Depends(verify_uri_apikey)) -> Any:
|
def arr_system_status(_: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr系统状态
|
模拟Radarr、Sonarr系统状态
|
||||||
"""
|
"""
|
||||||
@@ -73,7 +72,7 @@ def arr_system_status(_: str = Depends(verify_uri_apikey)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/qualityProfile", summary="质量配置")
|
@arr_router.get("/qualityProfile", summary="质量配置")
|
||||||
def arr_qualityProfile(_: str = Depends(verify_uri_apikey)) -> Any:
|
def arr_qualityProfile(_: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr质量配置
|
模拟Radarr、Sonarr质量配置
|
||||||
"""
|
"""
|
||||||
@@ -114,14 +113,14 @@ def arr_qualityProfile(_: str = Depends(verify_uri_apikey)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/rootfolder", summary="根目录")
|
@arr_router.get("/rootfolder", summary="根目录")
|
||||||
def arr_rootfolder(_: str = Depends(verify_uri_apikey)) -> Any:
|
def arr_rootfolder(_: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr根目录
|
模拟Radarr、Sonarr根目录
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"path": "/" if not settings.LIBRARY_PATHS else str(settings.LIBRARY_PATHS[0]),
|
"path": "/",
|
||||||
"accessible": True,
|
"accessible": True,
|
||||||
"freeSpace": 0,
|
"freeSpace": 0,
|
||||||
"unmappedFolders": []
|
"unmappedFolders": []
|
||||||
@@ -130,7 +129,7 @@ def arr_rootfolder(_: str = Depends(verify_uri_apikey)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/tag", summary="标签")
|
@arr_router.get("/tag", summary="标签")
|
||||||
def arr_tag(_: str = Depends(verify_uri_apikey)) -> Any:
|
def arr_tag(_: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr标签
|
模拟Radarr、Sonarr标签
|
||||||
"""
|
"""
|
||||||
@@ -143,7 +142,7 @@ def arr_tag(_: str = Depends(verify_uri_apikey)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/languageprofile", summary="语言")
|
@arr_router.get("/languageprofile", summary="语言")
|
||||||
def arr_languageprofile(_: str = Depends(verify_uri_apikey)) -> Any:
|
def arr_languageprofile(_: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr语言
|
模拟Radarr、Sonarr语言
|
||||||
"""
|
"""
|
||||||
@@ -169,7 +168,7 @@ def arr_languageprofile(_: str = Depends(verify_uri_apikey)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie])
|
@arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie])
|
||||||
def arr_movies(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db)) -> Any:
|
def arr_movies(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询Rardar电影
|
查询Rardar电影
|
||||||
"""
|
"""
|
||||||
@@ -260,7 +259,7 @@ def arr_movies(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie])
|
@arr_router.get("/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie])
|
||||||
def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询Rardar电影 term: `tmdb:${id}`
|
查询Rardar电影 term: `tmdb:${id}`
|
||||||
存在和不存在均不能返回错误
|
存在和不存在均不能返回错误
|
||||||
@@ -306,7 +305,7 @@ def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie)
|
@arr_router.get("/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie)
|
||||||
def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询Rardar电影订阅
|
查询Rardar电影订阅
|
||||||
"""
|
"""
|
||||||
@@ -334,7 +333,7 @@ def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_u
|
|||||||
@arr_router.post("/movie", summary="新增电影订阅")
|
@arr_router.post("/movie", summary="新增电影订阅")
|
||||||
def arr_add_movie(movie: RadarrMovie,
|
def arr_add_movie(movie: RadarrMovie,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: str = Depends(verify_uri_apikey)
|
_: str = Depends(verify_apikey)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
新增Rardar电影订阅
|
新增Rardar电影订阅
|
||||||
@@ -363,7 +362,7 @@ def arr_add_movie(movie: RadarrMovie,
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response)
|
@arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response)
|
||||||
def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
删除Rardar电影订阅
|
删除Rardar电影订阅
|
||||||
"""
|
"""
|
||||||
@@ -379,7 +378,7 @@ def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(v
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/series", summary="所有剧集", response_model=List[schemas.SonarrSeries])
|
@arr_router.get("/series", summary="所有剧集", response_model=List[schemas.SonarrSeries])
|
||||||
def arr_series(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db)) -> Any:
|
def arr_series(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询Sonarr剧集
|
查询Sonarr剧集
|
||||||
"""
|
"""
|
||||||
@@ -515,7 +514,7 @@ def arr_series(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/series/lookup", summary="查询剧集")
|
@arr_router.get("/series/lookup", summary="查询剧集")
|
||||||
def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询Sonarr剧集 term: `tvdb:${id}` title
|
查询Sonarr剧集 term: `tvdb:${id}` title
|
||||||
"""
|
"""
|
||||||
@@ -604,7 +603,7 @@ def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.get("/series/{tid}", summary="剧集详情")
|
@arr_router.get("/series/{tid}", summary="剧集详情")
|
||||||
def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询Sonarr剧集
|
查询Sonarr剧集
|
||||||
"""
|
"""
|
||||||
@@ -640,7 +639,7 @@ def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_u
|
|||||||
@arr_router.post("/series", summary="新增剧集订阅")
|
@arr_router.post("/series", summary="新增剧集订阅")
|
||||||
def arr_add_series(tv: schemas.SonarrSeries,
|
def arr_add_series(tv: schemas.SonarrSeries,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: str = Depends(verify_uri_apikey)) -> Any:
|
_: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
新增Sonarr剧集订阅
|
新增Sonarr剧集订阅
|
||||||
"""
|
"""
|
||||||
@@ -682,7 +681,7 @@ def arr_add_series(tv: schemas.SonarrSeries,
|
|||||||
|
|
||||||
|
|
||||||
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
|
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
|
||||||
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
|
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
删除Sonarr剧集订阅
|
删除Sonarr剧集订阅
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
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):
|
||||||
@@ -92,8 +95,16 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
|
|
||||||
logger.debug(f"请求模块执行:{method} ...")
|
logger.debug(f"请求模块执行:{method} ...")
|
||||||
result = None
|
result = None
|
||||||
modules = self.modulemanager.get_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__
|
||||||
|
try:
|
||||||
|
module_name = module.get_name()
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f"获取模块名称出错:{str(err)}")
|
||||||
|
module_name = module_id
|
||||||
try:
|
try:
|
||||||
func = getattr(module, method)
|
func = getattr(module, method)
|
||||||
if is_result_empty(result):
|
if is_result_empty(result):
|
||||||
@@ -111,8 +122,24 @@ 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"运行模块 {method} 出错:{module.__class__.__name__} - {str(err)}\n{traceback.format_exc()}")
|
f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||||
|
self.messagehelper.put(title=f"{module_name}发生了错误",
|
||||||
|
message=str(err),
|
||||||
|
role="system")
|
||||||
|
self.eventmanager.send_event(
|
||||||
|
EventType.SystemError,
|
||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"module_id": module_id,
|
||||||
|
"module_name": module_name,
|
||||||
|
"module_method": method,
|
||||||
|
"error": str(err),
|
||||||
|
"traceback": traceback.format_exc()
|
||||||
|
}
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def recognize_media(self, meta: MetaBase = None,
|
def recognize_media(self, meta: MetaBase = None,
|
||||||
@@ -122,7 +149,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
bangumiid: int = None,
|
bangumiid: int = None,
|
||||||
cache: bool = True) -> Optional[MediaInfo]:
|
cache: bool = True) -> Optional[MediaInfo]:
|
||||||
"""
|
"""
|
||||||
识别媒体信息
|
识别媒体信息,不含Fanart图片
|
||||||
:param meta: 识别的元数据
|
:param meta: 识别的元数据
|
||||||
:param mtype: 识别的媒体类型,与tmdbid配套
|
:param mtype: 识别的媒体类型,与tmdbid配套
|
||||||
:param tmdbid: tmdbid
|
:param tmdbid: tmdbid
|
||||||
@@ -146,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: 标题
|
||||||
@@ -154,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]:
|
||||||
@@ -194,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]:
|
||||||
"""
|
"""
|
||||||
@@ -211,14 +242,15 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("tvdb_info", tvdbid=tvdbid)
|
return self.run_module("tvdb_info", tvdbid=tvdbid)
|
||||||
|
|
||||||
def tmdb_info(self, tmdbid: int, mtype: MediaType) -> Optional[dict]:
|
def tmdb_info(self, tmdbid: int, mtype: MediaType, season: int = None) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
获取TMDB信息
|
获取TMDB信息
|
||||||
:param tmdbid: int
|
:param tmdbid: int
|
||||||
:param mtype: 媒体类型
|
:param mtype: 媒体类型
|
||||||
|
:param season: 季
|
||||||
:return: TVDB信息
|
:return: TVDB信息
|
||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype)
|
return self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype, season=season)
|
||||||
|
|
||||||
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -228,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]:
|
||||||
"""
|
"""
|
||||||
@@ -260,6 +293,13 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("search_medias", meta=meta)
|
return self.run_module("search_medias", meta=meta)
|
||||||
|
|
||||||
|
def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
|
||||||
|
"""
|
||||||
|
搜索人物信息
|
||||||
|
:param name: 人物名称
|
||||||
|
"""
|
||||||
|
return self.run_module("search_persons", name=name)
|
||||||
|
|
||||||
def search_torrents(self, site: CommentedMap,
|
def search_torrents(self, site: CommentedMap,
|
||||||
keywords: List[str],
|
keywords: List[str],
|
||||||
mtype: MediaType = None,
|
mtype: MediaType = None,
|
||||||
@@ -283,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: 种子文件地址或者磁力链接
|
||||||
@@ -311,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,
|
||||||
@@ -330,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]]]:
|
||||||
"""
|
"""
|
||||||
获取下载器种子列表
|
获取下载器种子列表
|
||||||
@@ -341,34 +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,
|
||||||
|
target_storage: str = None, target_path: Path = None,
|
||||||
|
transfer_type: str = None, scrape: bool = None,
|
||||||
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
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 scrape: 是否刮削元数据
|
||||||
:param episodes_info: 当前季的全部集信息
|
: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,
|
||||||
|
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: Union[str, list], 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
|
||||||
@@ -378,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
|
||||||
@@ -387,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
|
||||||
@@ -397,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
|
||||||
@@ -406,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:
|
||||||
"""
|
"""
|
||||||
@@ -422,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: 消息体
|
||||||
@@ -452,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: 消息体
|
||||||
@@ -468,27 +531,25 @@ 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]:
|
||||||
force_nfo: bool = False, force_img: bool = False) -> None:
|
|
||||||
"""
|
"""
|
||||||
刮削元数据
|
获取图片名称和url
|
||||||
:param path: 媒体文件路径
|
:param mediainfo: 媒体信息
|
||||||
:param mediainfo: 识别的媒体信息
|
:param season: 季号
|
||||||
:param transfer_type: 转移模式
|
:param episode: 集号
|
||||||
:param force_nfo: 强制刮削nfo
|
|
||||||
:param force_img: 强制刮削图片
|
|
||||||
:return: 成功或失败
|
|
||||||
"""
|
"""
|
||||||
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo,
|
return self.run_module("metadata_img", mediainfo=mediainfo, season=season, episode=episode)
|
||||||
transfer_type=transfer_type, force_nfo=force_nfo, force_img=force_img)
|
|
||||||
|
def media_category(self) -> Optional[Dict[str, list]]:
|
||||||
|
"""
|
||||||
|
获取媒体分类
|
||||||
|
:return: 获取二级分类配置字典项,需包括电影、电视剧
|
||||||
|
"""
|
||||||
|
return self.run_module("media_category")
|
||||||
|
|
||||||
def register_commands(self, commands: Dict[str, dict]) -> None:
|
def register_commands(self, commands: Dict[str, dict]) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
|
from app.core.context import MediaInfo
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
|
|
||||||
@@ -9,13 +11,11 @@ class BangumiChain(ChainBase, metaclass=Singleton):
|
|||||||
Bangumi处理链,单例运行
|
Bangumi处理链,单例运行
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def calendar(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
def calendar(self) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
获取Bangumi每日放送
|
获取Bangumi每日放送
|
||||||
:param page: 页码
|
|
||||||
:param count: 每页数量
|
|
||||||
"""
|
"""
|
||||||
return self.run_module("bangumi_calendar", page=page, count=count)
|
return self.run_module("bangumi_calendar")
|
||||||
|
|
||||||
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -25,18 +25,30 @@ class BangumiChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("bangumi_info", bangumiid=bangumiid)
|
return self.run_module("bangumi_info", bangumiid=bangumiid)
|
||||||
|
|
||||||
def bangumi_credits(self, bangumiid: int, page: int = 1, count: int = 20) -> List[dict]:
|
def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
|
||||||
"""
|
"""
|
||||||
根据BangumiID查询电影演职员表
|
根据BangumiID查询电影演职员表
|
||||||
:param bangumiid: BangumiID
|
:param bangumiid: BangumiID
|
||||||
:param page: 页码
|
|
||||||
:param count: 数量
|
|
||||||
"""
|
"""
|
||||||
return self.run_module("bangumi_credits", bangumiid=bangumiid, page=page, count=count)
|
return self.run_module("bangumi_credits", bangumiid=bangumiid)
|
||||||
|
|
||||||
def bangumi_recommend(self, bangumiid: int) -> List[dict]:
|
def bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
根据BangumiID查询推荐电影
|
根据BangumiID查询推荐电影
|
||||||
:param bangumiid: BangumiID
|
:param bangumiid: BangumiID
|
||||||
"""
|
"""
|
||||||
return self.run_module("bangumi_recommend", bangumiid=bangumiid)
|
return self.run_module("bangumi_recommend", bangumiid=bangumiid)
|
||||||
|
|
||||||
|
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||||
|
"""
|
||||||
|
根据人物ID查询Bangumi人物详情
|
||||||
|
:param person_id: 人物ID
|
||||||
|
"""
|
||||||
|
return self.run_module("bangumi_person_detail", person_id=person_id)
|
||||||
|
|
||||||
|
def person_credits(self, person_id: int) -> Optional[List[MediaInfo]]:
|
||||||
|
"""
|
||||||
|
根据人物ID查询人物参演作品
|
||||||
|
:param person_id: 人物ID
|
||||||
|
"""
|
||||||
|
return self.run_module("bangumi_person_credits", person_id=person_id)
|
||||||
|
|||||||
410
app/chain/command.py
Normal file
410
app/chain/command.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
from typing import Any, Union, Dict, Optional
|
||||||
|
|
||||||
|
from app.chain import ChainBase
|
||||||
|
from app.chain.download import DownloadChain
|
||||||
|
from app.chain.site import SiteChain
|
||||||
|
from app.chain.subscribe import SubscribeChain
|
||||||
|
from app.chain.system import SystemChain
|
||||||
|
from app.chain.transfer import TransferChain
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.event import Event as ManagerEvent, eventmanager, Event
|
||||||
|
from app.core.plugin import PluginManager
|
||||||
|
from app.helper.message import MessageHelper
|
||||||
|
from app.helper.thread import ThreadHelper
|
||||||
|
from app.log import logger
|
||||||
|
from app.scheduler import Scheduler
|
||||||
|
from app.schemas import Notification
|
||||||
|
from app.schemas.event import CommandRegisterEventData
|
||||||
|
from app.schemas.types import EventType, MessageChannel, ChainEventType
|
||||||
|
from app.utils.object import ObjectUtils
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
|
from app.utils.structures import DictUtils
|
||||||
|
|
||||||
|
|
||||||
|
class CommandChain(ChainBase, metaclass=Singleton):
|
||||||
|
"""
|
||||||
|
全局命令管理,消费事件
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 插件管理器
|
||||||
|
super().__init__()
|
||||||
|
# 注册的命令集合
|
||||||
|
self._registered_commands = {}
|
||||||
|
# 所有命令集合
|
||||||
|
self._commands = {}
|
||||||
|
# 内建命令集合
|
||||||
|
self._preset_commands = {
|
||||||
|
"/cookiecloud": {
|
||||||
|
"id": "cookiecloud",
|
||||||
|
"type": "scheduler",
|
||||||
|
"description": "同步站点",
|
||||||
|
"category": "站点"
|
||||||
|
},
|
||||||
|
"/sites": {
|
||||||
|
"func": SiteChain().remote_list,
|
||||||
|
"description": "查询站点",
|
||||||
|
"category": "站点",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/site_cookie": {
|
||||||
|
"func": SiteChain().remote_cookie,
|
||||||
|
"description": "更新站点Cookie",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/site_enable": {
|
||||||
|
"func": SiteChain().remote_enable,
|
||||||
|
"description": "启用站点",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/site_disable": {
|
||||||
|
"func": SiteChain().remote_disable,
|
||||||
|
"description": "禁用站点",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/mediaserver_sync": {
|
||||||
|
"id": "mediaserver_sync",
|
||||||
|
"type": "scheduler",
|
||||||
|
"description": "同步媒体服务器",
|
||||||
|
"category": "管理"
|
||||||
|
},
|
||||||
|
"/subscribes": {
|
||||||
|
"func": SubscribeChain().remote_list,
|
||||||
|
"description": "查询订阅",
|
||||||
|
"category": "订阅",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/subscribe_refresh": {
|
||||||
|
"id": "subscribe_refresh",
|
||||||
|
"type": "scheduler",
|
||||||
|
"description": "刷新订阅",
|
||||||
|
"category": "订阅"
|
||||||
|
},
|
||||||
|
"/subscribe_search": {
|
||||||
|
"id": "subscribe_search",
|
||||||
|
"type": "scheduler",
|
||||||
|
"description": "搜索订阅",
|
||||||
|
"category": "订阅"
|
||||||
|
},
|
||||||
|
"/subscribe_delete": {
|
||||||
|
"func": SubscribeChain().remote_delete,
|
||||||
|
"description": "删除订阅",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/subscribe_tmdb": {
|
||||||
|
"id": "subscribe_tmdb",
|
||||||
|
"type": "scheduler",
|
||||||
|
"description": "订阅元数据更新"
|
||||||
|
},
|
||||||
|
"/downloading": {
|
||||||
|
"func": DownloadChain().remote_downloading,
|
||||||
|
"description": "正在下载",
|
||||||
|
"category": "管理",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/transfer": {
|
||||||
|
"id": "transfer",
|
||||||
|
"type": "scheduler",
|
||||||
|
"description": "下载文件整理",
|
||||||
|
"category": "管理"
|
||||||
|
},
|
||||||
|
"/redo": {
|
||||||
|
"func": TransferChain().remote_transfer,
|
||||||
|
"description": "手动整理",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/clear_cache": {
|
||||||
|
"func": SystemChain().remote_clear_cache,
|
||||||
|
"description": "清理缓存",
|
||||||
|
"category": "管理",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/restart": {
|
||||||
|
"func": SystemChain().restart,
|
||||||
|
"description": "重启系统",
|
||||||
|
"category": "管理",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/version": {
|
||||||
|
"func": SystemChain().version,
|
||||||
|
"description": "当前版本",
|
||||||
|
"category": "管理",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# 插件命令集合
|
||||||
|
self._plugin_commands = {}
|
||||||
|
# 其他命令集合
|
||||||
|
self._other_commands = {}
|
||||||
|
# 初始化锁
|
||||||
|
self._rlock = threading.RLock()
|
||||||
|
# 插件管理
|
||||||
|
self.pluginmanager = PluginManager()
|
||||||
|
# 定时服务管理
|
||||||
|
self.scheduler = Scheduler()
|
||||||
|
# 消息管理器
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 强制触发注册
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
触发事件,允许调整命令数据
|
||||||
|
"""
|
||||||
|
|
||||||
|
def add_commands(source, command_type):
|
||||||
|
"""
|
||||||
|
添加命令集合
|
||||||
|
"""
|
||||||
|
for cmd, command in source.items():
|
||||||
|
command_data = {
|
||||||
|
"type": command_type,
|
||||||
|
"description": command.get("description"),
|
||||||
|
"category": command.get("category")
|
||||||
|
}
|
||||||
|
# 如果有 pid,则添加到命令数据中
|
||||||
|
plugin_id = command.get("pid")
|
||||||
|
if plugin_id:
|
||||||
|
command_data["pid"] = plugin_id
|
||||||
|
commands[cmd] = command_data
|
||||||
|
|
||||||
|
# 初始化命令字典
|
||||||
|
commands: Dict[str, dict] = {}
|
||||||
|
add_commands(self._preset_commands, "preset")
|
||||||
|
add_commands(self._plugin_commands, "plugin")
|
||||||
|
add_commands(self._other_commands, "other")
|
||||||
|
|
||||||
|
# 触发事件允许可以拦截和调整命令
|
||||||
|
event_data = CommandRegisterEventData(commands=commands, origin="CommandChain", service=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 userid:
|
||||||
|
self.post_message(
|
||||||
|
Notification(
|
||||||
|
channel=channel,
|
||||||
|
source=source,
|
||||||
|
title=f"开始执行 {command.get('description')} ...",
|
||||||
|
userid=userid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 执行定时任务
|
||||||
|
self.scheduler.start(job_id=command.get("id"))
|
||||||
|
|
||||||
|
if userid:
|
||||||
|
self.post_message(
|
||||||
|
Notification(
|
||||||
|
channel=channel,
|
||||||
|
source=source,
|
||||||
|
title=f"{command.get('description')} 执行完成",
|
||||||
|
userid=userid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 命令
|
||||||
|
cmd_data = command['data'] if command.get('data') else {}
|
||||||
|
args_num = ObjectUtils.arguments(command['func'])
|
||||||
|
if args_num > 0:
|
||||||
|
if cmd_data:
|
||||||
|
# 有内置参数直接使用内置参数
|
||||||
|
data = cmd_data.get("data") or {}
|
||||||
|
data['channel'] = channel
|
||||||
|
data['source'] = source
|
||||||
|
data['user'] = userid
|
||||||
|
if data_str:
|
||||||
|
data['arg_str'] = data_str
|
||||||
|
cmd_data['data'] = data
|
||||||
|
command['func'](**cmd_data)
|
||||||
|
elif args_num == 3:
|
||||||
|
# 没有输入参数,只输入渠道来源、用户ID和消息来源
|
||||||
|
command['func'](channel, userid, source)
|
||||||
|
elif args_num > 3:
|
||||||
|
# 多个输入参数:用户输入、用户ID
|
||||||
|
command['func'](data_str, channel, userid, source)
|
||||||
|
else:
|
||||||
|
# 没有参数
|
||||||
|
command['func']()
|
||||||
|
|
||||||
|
def get_commands(self):
|
||||||
|
"""
|
||||||
|
获取命令列表
|
||||||
|
"""
|
||||||
|
return self._commands
|
||||||
|
|
||||||
|
def get(self, cmd: str) -> Any:
|
||||||
|
"""
|
||||||
|
获取命令
|
||||||
|
"""
|
||||||
|
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 = "",
|
||||||
|
channel: MessageChannel = None, source: str = None,
|
||||||
|
userid: Union[str, int] = None) -> None:
|
||||||
|
"""
|
||||||
|
执行命令
|
||||||
|
"""
|
||||||
|
command = self.get(cmd)
|
||||||
|
if command:
|
||||||
|
try:
|
||||||
|
if userid:
|
||||||
|
logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...")
|
||||||
|
else:
|
||||||
|
logger.info(f"开始执行:{command.get('description')} ...")
|
||||||
|
|
||||||
|
# 执行命令
|
||||||
|
self.__run_command(command, data_str=data_str,
|
||||||
|
channel=channel, source=source, userid=userid)
|
||||||
|
|
||||||
|
if userid:
|
||||||
|
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
|
||||||
|
else:
|
||||||
|
logger.info(f"{command.get('description')} 执行完成")
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}")
|
||||||
|
self.messagehelper.put(title=f"执行命令 {cmd} 出错",
|
||||||
|
message=str(err),
|
||||||
|
role="system")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_plugin_event(etype: EventType, data: dict) -> None:
|
||||||
|
"""
|
||||||
|
发送插件命令
|
||||||
|
"""
|
||||||
|
eventmanager.send_event(etype, data)
|
||||||
|
|
||||||
|
@eventmanager.register(EventType.CommandExcute)
|
||||||
|
def command_event(self, event: ManagerEvent) -> None:
|
||||||
|
"""
|
||||||
|
注册命令执行事件
|
||||||
|
event_data: {
|
||||||
|
"cmd": "/xxx args"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 命令参数
|
||||||
|
event_str = event.event_data.get('cmd')
|
||||||
|
# 消息渠道
|
||||||
|
event_channel = event.event_data.get('channel')
|
||||||
|
# 消息来源
|
||||||
|
event_source = event.event_data.get('source')
|
||||||
|
# 消息用户
|
||||||
|
event_user = event.event_data.get('user')
|
||||||
|
if event_str:
|
||||||
|
cmd = event_str.split()[0]
|
||||||
|
args = " ".join(event_str.split()[1:])
|
||||||
|
if self.get(cmd):
|
||||||
|
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)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
|
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.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
@@ -11,7 +12,22 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
|||||||
豆瓣处理链,单例运行
|
豆瓣处理链,单例运行
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def movie_top250(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||||
|
"""
|
||||||
|
根据人物ID查询豆瓣人物详情
|
||||||
|
:param person_id: 人物ID
|
||||||
|
"""
|
||||||
|
return self.run_module("douban_person_detail", person_id=person_id)
|
||||||
|
|
||||||
|
def person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:
|
||||||
|
"""
|
||||||
|
根据人物ID查询人物参演作品
|
||||||
|
:param person_id: 人物ID
|
||||||
|
:param page: 页码
|
||||||
|
"""
|
||||||
|
return self.run_module("douban_person_credits", person_id=person_id, page=page)
|
||||||
|
|
||||||
|
def movie_top250(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
获取豆瓣电影TOP250
|
获取豆瓣电影TOP250
|
||||||
:param page: 页码
|
:param page: 页码
|
||||||
@@ -19,26 +35,26 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("movie_top250", page=page, count=count)
|
return self.run_module("movie_top250", page=page, count=count)
|
||||||
|
|
||||||
def movie_showing(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
def movie_showing(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
获取正在上映的电影
|
获取正在上映的电影
|
||||||
"""
|
"""
|
||||||
return self.run_module("movie_showing", page=page, count=count)
|
return self.run_module("movie_showing", page=page, count=count)
|
||||||
|
|
||||||
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
获取本周中国剧集榜
|
获取本周中国剧集榜
|
||||||
"""
|
"""
|
||||||
return self.run_module("tv_weekly_chinese", page=page, count=count)
|
return self.run_module("tv_weekly_chinese", page=page, count=count)
|
||||||
|
|
||||||
def tv_weekly_global(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
def tv_weekly_global(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
获取本周全球剧集榜
|
获取本周全球剧集榜
|
||||||
"""
|
"""
|
||||||
return self.run_module("tv_weekly_global", page=page, count=count)
|
return self.run_module("tv_weekly_global", page=page, count=count)
|
||||||
|
|
||||||
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
|
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
|
||||||
page: int = 0, count: int = 30) -> Optional[List[dict]]:
|
page: int = 0, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
发现豆瓣电影、剧集
|
发现豆瓣电影、剧集
|
||||||
:param mtype: 媒体类型
|
:param mtype: 媒体类型
|
||||||
@@ -51,52 +67,46 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
|||||||
return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags,
|
return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags,
|
||||||
page=page, count=count)
|
page=page, count=count)
|
||||||
|
|
||||||
def tv_animation(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
def tv_animation(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
获取动画剧集
|
获取动画剧集
|
||||||
"""
|
"""
|
||||||
return self.run_module("tv_animation", page=page, count=count)
|
return self.run_module("tv_animation", page=page, count=count)
|
||||||
|
|
||||||
def movie_hot(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
def movie_hot(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
获取热门电影
|
获取热门电影
|
||||||
"""
|
"""
|
||||||
if settings.RECOGNIZE_SOURCE != "douban":
|
|
||||||
return None
|
|
||||||
return self.run_module("movie_hot", page=page, count=count)
|
return self.run_module("movie_hot", page=page, count=count)
|
||||||
|
|
||||||
def tv_hot(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
def tv_hot(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
获取热门剧集
|
获取热门剧集
|
||||||
"""
|
"""
|
||||||
if settings.RECOGNIZE_SOURCE != "douban":
|
|
||||||
return None
|
|
||||||
return self.run_module("tv_hot", page=page, count=count)
|
return self.run_module("tv_hot", page=page, count=count)
|
||||||
|
|
||||||
def movie_credits(self, doubanid: str, page: int = 1) -> List[dict]:
|
def movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询电影演职人员
|
根据TMDBID查询电影演职人员
|
||||||
:param doubanid: 豆瓣ID
|
:param doubanid: 豆瓣ID
|
||||||
:param page: 页码
|
|
||||||
"""
|
"""
|
||||||
return self.run_module("douban_movie_credits", doubanid=doubanid, page=page)
|
return self.run_module("douban_movie_credits", doubanid=doubanid)
|
||||||
|
|
||||||
def tv_credits(self, doubanid: str, page: int = 1) -> List[dict]:
|
def tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询电视剧演职人员
|
根据TMDBID查询电视剧演职人员
|
||||||
:param doubanid: 豆瓣ID
|
:param doubanid: 豆瓣ID
|
||||||
:param page: 页码
|
|
||||||
"""
|
"""
|
||||||
return self.run_module("douban_tv_credits", doubanid=doubanid, page=page)
|
return self.run_module("douban_tv_credits", doubanid=doubanid)
|
||||||
|
|
||||||
def movie_recommend(self, doubanid: str) -> List[dict]:
|
def movie_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||||
"""
|
"""
|
||||||
根据豆瓣ID查询推荐电影
|
根据豆瓣ID查询推荐电影
|
||||||
:param doubanid: 豆瓣ID
|
:param doubanid: 豆瓣ID
|
||||||
"""
|
"""
|
||||||
return self.run_module("douban_movie_recommend", doubanid=doubanid)
|
return self.run_module("douban_movie_recommend", doubanid=doubanid)
|
||||||
|
|
||||||
def tv_recommend(self, doubanid: str) -> List[dict]:
|
def tv_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||||
"""
|
"""
|
||||||
根据豆瓣ID查询推荐电视剧
|
根据豆瓣ID查询推荐电视剧
|
||||||
:param doubanid: 豆瓣ID
|
:param doubanid: 豆瓣ID
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Tuple, Set, Dict, Union
|
from typing import List, Optional, Tuple, Set, Dict, Union
|
||||||
|
|
||||||
|
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
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||||
from app.db.mediaserver_oper import MediaServerOper
|
from app.db.mediaserver_oper import MediaServerOper
|
||||||
|
from app.helper.directory import DirectoryHelper
|
||||||
|
from app.helper.message import MessageHelper
|
||||||
from app.helper.torrent import TorrentHelper
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification
|
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification
|
||||||
@@ -32,18 +35,22 @@ class DownloadChain(ChainBase):
|
|||||||
self.torrent = TorrentHelper()
|
self.torrent = TorrentHelper()
|
||||||
self.downloadhis = DownloadHistoryOper()
|
self.downloadhis = DownloadHistoryOper()
|
||||||
self.mediaserver = MediaServerOper()
|
self.mediaserver = MediaServerOper()
|
||||||
|
self.directoryhelper = DirectoryHelper()
|
||||||
|
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):
|
||||||
"""
|
"""
|
||||||
发送添加下载的消息
|
发送添加下载的消息,根据消息场景开关决定发给谁
|
||||||
: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: 下载的集数
|
||||||
"""
|
"""
|
||||||
|
# 拼装消息内容
|
||||||
msg_text = ""
|
msg_text = ""
|
||||||
if username:
|
if username:
|
||||||
msg_text = f"用户:{username}"
|
msg_text = f"用户:{username}"
|
||||||
@@ -69,23 +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"{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'),
|
||||||
|
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]:
|
||||||
"""
|
"""
|
||||||
@@ -150,12 +162,15 @@ class DownloadChain(ChainBase):
|
|||||||
return None, "", []
|
return None, "", []
|
||||||
if torrent.enclosure.startswith("magnet:"):
|
if torrent.enclosure.startswith("magnet:"):
|
||||||
return torrent.enclosure, "", []
|
return torrent.enclosure, "", []
|
||||||
|
# Cookie
|
||||||
|
site_cookie = torrent.site_cookie
|
||||||
if torrent.enclosure.startswith("["):
|
if torrent.enclosure.startswith("["):
|
||||||
# 需要解码获取下载地址
|
# 需要解码获取下载地址
|
||||||
torrent_url = __get_redict_url(url=torrent.enclosure,
|
torrent_url = __get_redict_url(url=torrent.enclosure,
|
||||||
ua=torrent.site_ua,
|
ua=torrent.site_ua,
|
||||||
cookie=torrent.site_cookie)
|
cookie=site_cookie)
|
||||||
|
# 涉及解析地址的不使用Cookie下载种子,否则MT会出错
|
||||||
|
site_cookie = None
|
||||||
else:
|
else:
|
||||||
torrent_url = torrent.enclosure
|
torrent_url = torrent.enclosure
|
||||||
if not torrent_url:
|
if not torrent_url:
|
||||||
@@ -164,7 +179,7 @@ class DownloadChain(ChainBase):
|
|||||||
# 下载种子文件
|
# 下载种子文件
|
||||||
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
|
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
|
||||||
url=torrent_url,
|
url=torrent_url,
|
||||||
cookie=torrent.site_cookie,
|
cookie=site_cookie,
|
||||||
ua=torrent.site_ua,
|
ua=torrent.site_ua,
|
||||||
proxy=torrent.site_proxy)
|
proxy=torrent.site_proxy)
|
||||||
|
|
||||||
@@ -176,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}",
|
||||||
@@ -187,28 +203,44 @@ 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
|
||||||
_meta = context.meta_info
|
_meta = context.meta_info
|
||||||
|
|
||||||
|
# 补充完整的media数据
|
||||||
|
if not _media.genre_ids:
|
||||||
|
new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id,
|
||||||
|
doubanid=_media.douban_id, bangumiid=_media.bangumi_id)
|
||||||
|
if new_media:
|
||||||
|
_media = new_media
|
||||||
|
|
||||||
|
# 实际下载的集数
|
||||||
|
download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None
|
||||||
_folder_name = ""
|
_folder_name = ""
|
||||||
if not torrent_file:
|
if not torrent_file:
|
||||||
# 下载种子文件,得到的可能是文件也可能是磁力链
|
# 下载种子文件,得到的可能是文件也可能是磁力链
|
||||||
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
|
||||||
@@ -218,50 +250,48 @@ class DownloadChain(ChainBase):
|
|||||||
_folder_name, _file_list = self.torrent.get_torrent_info(torrent_file)
|
_folder_name, _file_list = self.torrent.get_torrent_info(torrent_file)
|
||||||
|
|
||||||
# 下载目录
|
# 下载目录
|
||||||
if not save_path:
|
if save_path:
|
||||||
if settings.DOWNLOAD_CATEGORY and _media and _media.category:
|
# 有自定义下载目录时,尝试匹配目录配置
|
||||||
# 开启下载二级目录
|
dir_info = self.directoryhelper.get_dir(_media, src_path=Path(save_path), local=True)
|
||||||
if _media.type == MediaType.MOVIE:
|
|
||||||
# 电影
|
|
||||||
download_dir = settings.SAVE_MOVIE_PATH / _media.category
|
|
||||||
else:
|
|
||||||
if _media.genre_ids \
|
|
||||||
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
|
||||||
# 动漫
|
|
||||||
download_dir = settings.SAVE_ANIME_PATH / _media.category
|
|
||||||
else:
|
|
||||||
# 电视剧
|
|
||||||
download_dir = settings.SAVE_TV_PATH / _media.category
|
|
||||||
elif _media:
|
|
||||||
# 未开启下载二级目录
|
|
||||||
if _media.type == MediaType.MOVIE:
|
|
||||||
# 电影
|
|
||||||
download_dir = settings.SAVE_MOVIE_PATH
|
|
||||||
else:
|
|
||||||
if _media.genre_ids \
|
|
||||||
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
|
||||||
# 动漫
|
|
||||||
download_dir = settings.SAVE_ANIME_PATH
|
|
||||||
else:
|
|
||||||
# 电视剧
|
|
||||||
download_dir = settings.SAVE_TV_PATH
|
|
||||||
else:
|
|
||||||
# 未识别
|
|
||||||
download_dir = settings.SAVE_PATH
|
|
||||||
else:
|
else:
|
||||||
|
# 根据媒体信息查询下载目录配置
|
||||||
|
dir_info = self.directoryhelper.get_dir(_media, local=True)
|
||||||
|
|
||||||
|
# 拼装子目录
|
||||||
|
if dir_info:
|
||||||
|
# 一级目录
|
||||||
|
if not dir_info.media_type and dir_info.download_type_folder:
|
||||||
|
# 一级自动分类
|
||||||
|
download_dir = Path(dir_info.download_path) / _media.type.value
|
||||||
|
else:
|
||||||
|
# 一级不分类
|
||||||
|
download_dir = Path(dir_info.download_path)
|
||||||
|
|
||||||
|
# 二级目录
|
||||||
|
if not dir_info.media_category and dir_info.download_category_folder and _media and _media.category:
|
||||||
|
# 二级自动分类
|
||||||
|
download_dir = download_dir / _media.category
|
||||||
|
elif save_path:
|
||||||
# 自定义下载目录
|
# 自定义下载目录
|
||||||
download_dir = Path(save_path)
|
download_dir = Path(save_path)
|
||||||
|
else:
|
||||||
|
# 未找到下载目录,且没有自定义下载目录
|
||||||
|
logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}")
|
||||||
|
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
|
||||||
|
title="下载失败", role="system")
|
||||||
|
return None
|
||||||
|
|
||||||
# 添加下载
|
# 添加下载
|
||||||
result: Optional[tuple] = self.download(content=content,
|
result: Optional[tuple] = self.download(content=content,
|
||||||
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:
|
||||||
# 下载文件路径
|
# 下载文件路径
|
||||||
@@ -281,7 +311,7 @@ class DownloadChain(ChainBase):
|
|||||||
tvdbid=_media.tvdb_id,
|
tvdbid=_media.tvdb_id,
|
||||||
doubanid=_media.douban_id,
|
doubanid=_media.douban_id,
|
||||||
seasons=_meta.season,
|
seasons=_meta.season,
|
||||||
episodes=_meta.episode,
|
episodes=download_episodes or _meta.episode,
|
||||||
image=_media.get_backdrop_image(),
|
image=_media.get_backdrop_image(),
|
||||||
download_hash=_hash,
|
download_hash=_hash,
|
||||||
torrent_name=_torrent.title,
|
torrent_name=_torrent.title,
|
||||||
@@ -290,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
|
||||||
)
|
)
|
||||||
|
|
||||||
# 登记下载文件
|
# 登记下载文件
|
||||||
@@ -308,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,
|
||||||
@@ -317,15 +348,17 @@ 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, username=username)
|
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
|
||||||
|
username=username, download_episodes=download_episodes)
|
||||||
# 下载成功后处理
|
# 下载成功后处理
|
||||||
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
||||||
# 广播事件
|
# 广播事件
|
||||||
self.eventmanager.send_event(EventType.DownloadAdded, {
|
self.eventmanager.send_event(EventType.DownloadAdded, {
|
||||||
"hash": _hash,
|
"hash": _hash,
|
||||||
"context": context,
|
"context": context,
|
||||||
"username": username
|
"username": username,
|
||||||
|
"downloader": _downloader
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# 下载失败
|
# 下载失败
|
||||||
@@ -334,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),
|
||||||
@@ -349,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]]]:
|
||||||
"""
|
"""
|
||||||
根据缺失数据,自动种子列表中组合择优下载
|
根据缺失数据,自动种子列表中组合择优下载
|
||||||
@@ -358,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}
|
||||||
"""
|
"""
|
||||||
# 已下载的项目
|
# 已下载的项目
|
||||||
@@ -425,13 +463,19 @@ 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} ...")
|
||||||
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} 添加下载成功")
|
||||||
downloaded_list.append(context)
|
downloaded_list.append(context)
|
||||||
|
|
||||||
# 电视剧整季匹配
|
# 电视剧整季匹配
|
||||||
|
logger.info(f"开始匹配电视剧整季:{no_exists}")
|
||||||
if no_exists:
|
if no_exists:
|
||||||
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
|
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
|
||||||
need_seasons: Dict[int, list] = {}
|
need_seasons: Dict[int, list] = {}
|
||||||
@@ -444,10 +488,13 @@ class DownloadChain(ChainBase):
|
|||||||
if not need_seasons.get(need_mid):
|
if not need_seasons.get(need_mid):
|
||||||
need_seasons[need_mid] = []
|
need_seasons[need_mid] = []
|
||||||
need_seasons[need_mid].append(tv.season or 1)
|
need_seasons[need_mid].append(tv.season or 1)
|
||||||
|
logger.info(f"缺失整季:{need_seasons}")
|
||||||
# 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子
|
# 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子
|
||||||
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
|
||||||
# 识别元数据
|
# 识别元数据
|
||||||
@@ -459,23 +506,31 @@ class DownloadChain(ChainBase):
|
|||||||
continue
|
continue
|
||||||
# 种子的季清单
|
# 种子的季清单
|
||||||
torrent_season = meta.season_list
|
torrent_season = meta.season_list
|
||||||
|
# 没有季的默认为第1季
|
||||||
|
if not torrent_season:
|
||||||
|
torrent_season = [1]
|
||||||
# 种子有集的不要
|
# 种子有集的不要
|
||||||
if meta.episode_list:
|
if meta.episode_list:
|
||||||
continue
|
continue
|
||||||
# 匹配TMDBID
|
# 匹配TMDBID
|
||||||
if need_mid == media.tmdb_id or need_mid == media.douban_id:
|
if need_mid == media.tmdb_id or need_mid == media.douban_id:
|
||||||
|
# 不重复添加
|
||||||
|
if context in downloaded_list:
|
||||||
|
continue
|
||||||
# 种子季是需要季或者子集
|
# 种子季是需要季或者子集
|
||||||
if set(torrent_season).issubset(set(need_season)):
|
if set(torrent_season).issubset(set(need_season)):
|
||||||
if len(torrent_season) == 1:
|
if len(torrent_season) == 1:
|
||||||
# 只有一季的可能是命名错误,需要打开种子鉴别,只有实际集数大于等于总集数才下载
|
# 只有一季的可能是命名错误,需要打开种子鉴别,只有实际集数大于等于总集数才下载
|
||||||
|
logger.info(f"开始下载种子 {torrent.title} ...")
|
||||||
content, _, torrent_files = self.download_torrent(torrent)
|
content, _, torrent_files = self.download_torrent(torrent)
|
||||||
if not content:
|
if not content:
|
||||||
|
logger.warn(f"{torrent.title} 种子下载失败!")
|
||||||
continue
|
continue
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法确定种子文件集数")
|
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法确定种子文件集数")
|
||||||
continue
|
continue
|
||||||
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||||
logger.info(f"{meta.org_string} 解析文件集数为 {torrent_episodes}")
|
logger.info(f"{meta.org_string} 解析种子文件集数为 {torrent_episodes}")
|
||||||
if not torrent_episodes:
|
if not torrent_episodes:
|
||||||
continue
|
continue
|
||||||
# 更新集数范围
|
# 更新集数范围
|
||||||
@@ -486,35 +541,43 @@ class DownloadChain(ChainBase):
|
|||||||
need_total = __get_season_episodes(need_mid, torrent_season[0])
|
need_total = __get_season_episodes(need_mid, torrent_season[0])
|
||||||
if len(torrent_episodes) < need_total:
|
if len(torrent_episodes) < need_total:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{meta.org_string} 解析文件集数发现不是完整合集")
|
f"{meta.org_string} 解析文件集数发现不是完整合集,先放弃这个种子")
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
# 下载
|
# 下载
|
||||||
|
logger.info(f"开始下载 {torrent.title} ...")
|
||||||
download_id = self.download_single(
|
download_id = self.download_single(
|
||||||
context=context,
|
context=context,
|
||||||
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:
|
||||||
# 下载
|
# 下载
|
||||||
download_id = self.download_single(context,
|
logger.info(f"开始下载 {torrent.title} ...")
|
||||||
save_path=save_path, channel=channel,
|
download_id = self.download_single(context, save_path=save_path,
|
||||||
userid=userid, username=username)
|
channel=channel, source=source,
|
||||||
|
userid=userid, username=username,
|
||||||
|
media_category=media_category)
|
||||||
|
|
||||||
if download_id:
|
if download_id:
|
||||||
# 下载成功
|
# 下载成功
|
||||||
|
logger.info(f"{torrent.title} 添加下载成功")
|
||||||
downloaded_list.append(context)
|
downloaded_list.append(context)
|
||||||
# 更新仍需季集
|
# 更新仍需季集
|
||||||
need_season = __update_seasons(_mid=need_mid,
|
need_season = __update_seasons(_mid=need_mid,
|
||||||
_need=need_season,
|
_need=need_season,
|
||||||
_current=torrent_season)
|
_current=torrent_season)
|
||||||
|
logger.info(f"{need_mid} 剩余需要季:{need_season}")
|
||||||
if not need_season:
|
if not need_season:
|
||||||
# 全部下载完成
|
# 全部下载完成
|
||||||
break
|
break
|
||||||
# 电视剧季内的集匹配
|
# 电视剧季内的集匹配
|
||||||
|
logger.info(f"开始电视剧完整集匹配:{no_exists}")
|
||||||
if no_exists:
|
if no_exists:
|
||||||
# TMDBID列表
|
# TMDBID列表
|
||||||
need_tv_list = list(no_exists)
|
need_tv_list = list(no_exists)
|
||||||
@@ -539,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
|
||||||
# 识别元数据
|
# 识别元数据
|
||||||
@@ -564,19 +629,24 @@ class DownloadChain(ChainBase):
|
|||||||
# 为需要集的子集则下载
|
# 为需要集的子集则下载
|
||||||
if torrent_episodes.issubset(set(need_episodes)):
|
if torrent_episodes.issubset(set(need_episodes)):
|
||||||
# 下载
|
# 下载
|
||||||
download_id = self.download_single(context,
|
logger.info(f"开始下载 {meta.title} ...")
|
||||||
save_path=save_path, channel=channel,
|
download_id = self.download_single(context, save_path=save_path,
|
||||||
userid=userid, username=username)
|
channel=channel, source=source,
|
||||||
|
userid=userid, username=username,
|
||||||
|
media_category=media_category)
|
||||||
if download_id:
|
if download_id:
|
||||||
# 下载成功
|
# 下载成功
|
||||||
|
logger.info(f"{meta.title} 添加下载成功")
|
||||||
downloaded_list.append(context)
|
downloaded_list.append(context)
|
||||||
# 更新仍需集数
|
# 更新仍需集数
|
||||||
need_episodes = __update_episodes(_mid=need_mid,
|
need_episodes = __update_episodes(_mid=need_mid,
|
||||||
_need=need_episodes,
|
_need=need_episodes,
|
||||||
_sea=need_season,
|
_sea=need_season,
|
||||||
_current=torrent_episodes)
|
_current=torrent_episodes)
|
||||||
|
logger.info(f"季 {need_season} 剩余需要集:{need_episodes}")
|
||||||
|
|
||||||
# 仍然缺失的剧集,从整季中选择需要的集数文件下载,仅支持QB和TR
|
# 仍然缺失的剧集,从整季中选择需要的集数文件下载,仅支持QB和TR
|
||||||
|
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
|
||||||
if no_exists:
|
if no_exists:
|
||||||
# TMDBID列表
|
# TMDBID列表
|
||||||
no_exists_list = list(no_exists)
|
no_exists_list = list(no_exists)
|
||||||
@@ -600,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
|
||||||
# 识别元数据
|
# 识别元数据
|
||||||
@@ -622,15 +694,17 @@ class DownloadChain(ChainBase):
|
|||||||
and len(meta.season_list) == 1 \
|
and len(meta.season_list) == 1 \
|
||||||
and meta.season_list[0] == need_season:
|
and meta.season_list[0] == need_season:
|
||||||
# 检查种子看是否有需要的集
|
# 检查种子看是否有需要的集
|
||||||
|
logger.info(f"开始下载种子 {torrent.title} ...")
|
||||||
content, _, torrent_files = self.download_torrent(torrent)
|
content, _, torrent_files = self.download_torrent(torrent)
|
||||||
if not content:
|
if not content:
|
||||||
|
logger.info(f"{torrent.title} 种子下载失败!")
|
||||||
continue
|
continue
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法解析种子文件集数")
|
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法解析种子文件集数")
|
||||||
continue
|
continue
|
||||||
# 种子全部集
|
# 种子全部集
|
||||||
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||||
logger.info(f"{torrent.site_name} - {meta.org_string} 解析文件集数:{torrent_episodes}")
|
logger.info(f"{torrent.site_name} - {meta.org_string} 解析种子文件集数:{torrent_episodes}")
|
||||||
# 选中的集
|
# 选中的集
|
||||||
selected_episodes = set(torrent_episodes).intersection(set(need_episodes))
|
selected_episodes = set(torrent_episodes).intersection(set(need_episodes))
|
||||||
if not selected_episodes:
|
if not selected_episodes:
|
||||||
@@ -638,18 +712,22 @@ class DownloadChain(ChainBase):
|
|||||||
continue
|
continue
|
||||||
logger.info(f"{torrent.site_name} - {torrent.title} 选中集数:{selected_episodes}")
|
logger.info(f"{torrent.site_name} - {torrent.title} 选中集数:{selected_episodes}")
|
||||||
# 添加下载
|
# 添加下载
|
||||||
|
logger.info(f"开始下载 {torrent.title} ...")
|
||||||
download_id = self.download_single(
|
download_id = self.download_single(
|
||||||
context=context,
|
context=context,
|
||||||
torrent_file=content if isinstance(content, Path) else None,
|
torrent_file=content if isinstance(content, Path) else None,
|
||||||
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
|
||||||
# 下载成功
|
# 下载成功
|
||||||
|
logger.info(f"{torrent.title} 添加下载成功")
|
||||||
downloaded_list.append(context)
|
downloaded_list.append(context)
|
||||||
# 更新种子集数范围
|
# 更新种子集数范围
|
||||||
begin_ep = min(torrent_episodes)
|
begin_ep = min(torrent_episodes)
|
||||||
@@ -660,8 +738,10 @@ class DownloadChain(ChainBase):
|
|||||||
_need=need_episodes,
|
_need=need_episodes,
|
||||||
_sea=need_season,
|
_sea=need_season,
|
||||||
_current=selected_episodes)
|
_current=selected_episodes)
|
||||||
|
logger.info(f"季 {need_season} 剩余需要集:{need_episodes}")
|
||||||
|
|
||||||
# 返回下载的资源,剩下没下完的
|
# 返回下载的资源,剩下没下完的
|
||||||
|
logger.info(f"成功下载种子数:{len(downloaded_list)},剩余未下载的剧集:{no_exists}")
|
||||||
return downloaded_list, no_exists
|
return downloaded_list, no_exists
|
||||||
|
|
||||||
def get_no_exists_info(self, meta: MetaBase,
|
def get_no_exists_info(self, meta: MetaBase,
|
||||||
@@ -794,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):
|
||||||
"""
|
"""
|
||||||
查询正在下载的任务,并发送消息
|
查询正在下载的任务,并发送消息
|
||||||
"""
|
"""
|
||||||
@@ -802,9 +882,12 @@ 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,
|
||||||
|
link=settings.MP_DOMAIN('#/downloading')
|
||||||
|
))
|
||||||
return
|
return
|
||||||
# 发送消息
|
# 发送消息
|
||||||
title = f"共 {len(torrents)} 个任务正在下载:"
|
title = f"共 {len(torrents)} 个任务正在下载:"
|
||||||
@@ -816,14 +899,20 @@ class DownloadChain(ChainBase):
|
|||||||
f"{round(torrent.progress, 1)}%")
|
f"{round(torrent.progress, 1)}%")
|
||||||
index += 1
|
index += 1
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel, mtype=NotificationType.Download,
|
channel=channel,
|
||||||
title=title, text="\n".join(messages), userid=userid))
|
source=source,
|
||||||
|
mtype=NotificationType.Download,
|
||||||
|
title=title,
|
||||||
|
text="\n".join(messages),
|
||||||
|
userid=userid,
|
||||||
|
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 = []
|
||||||
@@ -872,4 +961,14 @@ class DownloadChain(ChainBase):
|
|||||||
if not hash_str:
|
if not hash_str:
|
||||||
return
|
return
|
||||||
logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}")
|
logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}")
|
||||||
self.remove_torrents(hashs=[hash_str], delete_file=False)
|
# 先查询种子
|
||||||
|
torrents: List[schemas.TransferTorrent] = self.list_torrents(hashs=[hash_str])
|
||||||
|
if torrents:
|
||||||
|
self.remove_torrents(hashs=[hash_str], delete_file=False)
|
||||||
|
# 发出下载任务删除事件,如需处理辅种,可监听该事件
|
||||||
|
self.eventmanager.send_event(EventType.DownloadDeleted, {
|
||||||
|
"hash": hash_str,
|
||||||
|
"torrents": [torrent.dict() for torrent in torrents]
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
logger.info(f"没有在下载器中查询到 {hash_str} 对应的下载任务")
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
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
|
from typing import Optional, List, Tuple, Union
|
||||||
|
|
||||||
|
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.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.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.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
@@ -21,10 +23,21 @@ 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,
|
||||||
|
season: int = None, episode: int = None) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取NFO文件内容文本
|
||||||
|
:param meta: 元数据
|
||||||
|
:param mediainfo: 媒体信息
|
||||||
|
:param season: 季号
|
||||||
|
:param episode: 集号
|
||||||
|
"""
|
||||||
|
return self.run_module("metadata_nfo", meta=meta, mediainfo=mediainfo, season=season, episode=episode)
|
||||||
|
|
||||||
def recognize_by_meta(self, metainfo: MetaBase) -> Optional[MediaInfo]:
|
def recognize_by_meta(self, metainfo: MetaBase) -> Optional[MediaInfo]:
|
||||||
"""
|
"""
|
||||||
@@ -34,8 +47,8 @@ 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:
|
||||||
@@ -54,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]:
|
||||||
"""
|
"""
|
||||||
@@ -143,8 +120,8 @@ 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:
|
||||||
@@ -156,9 +133,9 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
# 返回上下文
|
# 返回上下文
|
||||||
return Context(meta_info=file_meta, media_info=mediainfo)
|
return Context(meta_info=file_meta, media_info=mediainfo)
|
||||||
|
|
||||||
def search(self, title: str) -> Tuple[MetaBase, List[MediaInfo]]:
|
def search(self, title: str) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
搜索媒体信息
|
搜索媒体/人物信息
|
||||||
:param title: 搜索内容
|
:param title: 搜索内容
|
||||||
:return: 识别元数据,媒体信息列表
|
:return: 识别元数据,媒体信息列表
|
||||||
"""
|
"""
|
||||||
@@ -195,14 +172,11 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
doubaninfo = self.douban_info(doubanid=doubanid, mtype=mtype)
|
doubaninfo = self.douban_info(doubanid=doubanid, mtype=mtype)
|
||||||
if doubaninfo:
|
if doubaninfo:
|
||||||
# 优先使用原标题匹配
|
# 优先使用原标题匹配
|
||||||
season_meta = None
|
|
||||||
if doubaninfo.get("original_title"):
|
if doubaninfo.get("original_title"):
|
||||||
meta = MetaInfo(title=doubaninfo.get("original_title"))
|
|
||||||
season_meta = MetaInfo(title=doubaninfo.get("title"))
|
|
||||||
# 合并季
|
|
||||||
meta.begin_season = season_meta.begin_season
|
|
||||||
else:
|
|
||||||
meta = MetaInfo(title=doubaninfo.get("title"))
|
meta = MetaInfo(title=doubaninfo.get("title"))
|
||||||
|
meta_org = MetaInfo(title=doubaninfo.get("original_title"))
|
||||||
|
else:
|
||||||
|
meta_org = meta = MetaInfo(title=doubaninfo.get("title"))
|
||||||
# 年份
|
# 年份
|
||||||
if doubaninfo.get("year"):
|
if doubaninfo.get("year"):
|
||||||
meta.year = doubaninfo.get("year")
|
meta.year = doubaninfo.get("year")
|
||||||
@@ -211,22 +185,21 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
meta.type = doubaninfo.get('media_type')
|
meta.type = doubaninfo.get('media_type')
|
||||||
else:
|
else:
|
||||||
meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV
|
meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV
|
||||||
# 使用原标题识别TMDB媒体信息
|
# 匹配TMDB信息
|
||||||
tmdbinfo = self.match_tmdbinfo(
|
meta_names = list(dict.fromkeys([k for k in [meta_org.name,
|
||||||
name=meta.name,
|
meta.cn_name,
|
||||||
year=meta.year,
|
meta.en_name] if k]))
|
||||||
mtype=mtype or meta.type,
|
for name in meta_names:
|
||||||
season=meta.begin_season
|
tmdbinfo = self.match_tmdbinfo(
|
||||||
)
|
name=name,
|
||||||
if not tmdbinfo:
|
year=meta.year,
|
||||||
if season_meta and season_meta.name != meta.name:
|
mtype=mtype or meta.type,
|
||||||
# 使用主标题识别媒体信息
|
season=meta.begin_season
|
||||||
tmdbinfo = self.match_tmdbinfo(
|
)
|
||||||
name=season_meta.name,
|
if tmdbinfo:
|
||||||
year=meta.year,
|
# 合季季后返回
|
||||||
mtype=mtype or meta.type,
|
tmdbinfo['season'] = meta.begin_season
|
||||||
season=meta.begin_season
|
break
|
||||||
)
|
|
||||||
return tmdbinfo
|
return tmdbinfo
|
||||||
|
|
||||||
def get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
|
def get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
|
||||||
@@ -236,23 +209,29 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
bangumiinfo = self.bangumi_info(bangumiid=bangumiid)
|
bangumiinfo = self.bangumi_info(bangumiid=bangumiid)
|
||||||
if bangumiinfo:
|
if bangumiinfo:
|
||||||
# 优先使用原标题匹配
|
# 优先使用原标题匹配
|
||||||
if bangumiinfo.get("name"):
|
if bangumiinfo.get("name_cn"):
|
||||||
meta = MetaInfo(title=bangumiinfo.get("name"))
|
meta = MetaInfo(title=bangumiinfo.get("name"))
|
||||||
|
meta_cn = MetaInfo(title=bangumiinfo.get("name_cn"))
|
||||||
else:
|
else:
|
||||||
meta = MetaInfo(title=bangumiinfo.get("name_cn"))
|
meta_cn = meta = MetaInfo(title=bangumiinfo.get("name"))
|
||||||
# 年份
|
# 年份
|
||||||
release_date = bangumiinfo.get("date") or bangumiinfo.get("air_date")
|
release_date = bangumiinfo.get("date") or bangumiinfo.get("air_date")
|
||||||
if release_date:
|
if release_date:
|
||||||
year = release_date[:4]
|
year = release_date[:4]
|
||||||
else:
|
else:
|
||||||
year = None
|
year = None
|
||||||
# 使用名称识别TMDB媒体信息
|
# 识别TMDB媒体信息
|
||||||
return self.match_tmdbinfo(
|
meta_names = list(dict.fromkeys([k for k in [meta_cn.name,
|
||||||
name=meta.name,
|
meta.name] if k]))
|
||||||
year=year,
|
for name in meta_names:
|
||||||
mtype=MediaType.TV,
|
tmdbinfo = self.match_tmdbinfo(
|
||||||
season=meta.begin_season
|
name=name,
|
||||||
)
|
year=year,
|
||||||
|
mtype=MediaType.TV,
|
||||||
|
season=meta.begin_season
|
||||||
|
)
|
||||||
|
if tmdbinfo:
|
||||||
|
return tmdbinfo
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_doubaninfo_by_tmdbid(self, tmdbid: int,
|
def get_doubaninfo_by_tmdbid(self, tmdbid: int,
|
||||||
@@ -313,3 +292,232 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
season=meta.begin_season
|
season=meta.begin_season
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@eventmanager.register(EventType.MetadataScrape)
|
||||||
|
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(_fileitem: schemas.FileItem):
|
||||||
|
"""
|
||||||
|
列出下级文件
|
||||||
|
"""
|
||||||
|
return self.storagechain.list_files(fileitem=_fileitem)
|
||||||
|
|
||||||
|
def __save_file(_fileitem: schemas.FileItem, _path: Path, _content: Union[bytes, str]):
|
||||||
|
"""
|
||||||
|
保存或上传文件
|
||||||
|
:param _fileitem: 关联的媒体文件项
|
||||||
|
:param _path: 元数据文件路径
|
||||||
|
:param _content: 文件内容
|
||||||
|
"""
|
||||||
|
if not _fileitem or not _content or not _path:
|
||||||
|
return
|
||||||
|
tmp_file = settings.TEMP_PATH / _path.name
|
||||||
|
tmp_file.write_bytes(_content)
|
||||||
|
logger.info(f"保存文件:【{_fileitem.storage}】{_path}")
|
||||||
|
_fileitem.path = str(_path.parent)
|
||||||
|
self.storagechain.upload_file(fileitem=_fileitem, path=tmp_file)
|
||||||
|
if tmp_file.exists():
|
||||||
|
tmp_file.unlink()
|
||||||
|
|
||||||
|
def __download_image(_url: str) -> Optional[bytes]:
|
||||||
|
"""
|
||||||
|
下载图片并保存
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"正在下载图片:{_url} ...")
|
||||||
|
r = RequestUtils(proxies=settings.PROXY).get_res(url=_url)
|
||||||
|
if r:
|
||||||
|
return r.content
|
||||||
|
else:
|
||||||
|
logger.info(f"{_url} 图片下载失败,请检查网络连通性!")
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"{_url} 图片下载失败:{str(err)}!")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 当前文件路径
|
||||||
|
filepath = Path(fileitem.path)
|
||||||
|
if fileitem.type == "file" \
|
||||||
|
and (not filepath.suffix or filepath.suffix.lower() not in settings.RMT_MEDIAEXT):
|
||||||
|
return
|
||||||
|
if not meta:
|
||||||
|
meta = MetaInfoPath(filepath)
|
||||||
|
if not mediainfo:
|
||||||
|
mediainfo = self.recognize_by_meta(meta)
|
||||||
|
if not mediainfo:
|
||||||
|
logger.warn(f"{filepath} 无法识别文件媒体信息!")
|
||||||
|
return
|
||||||
|
logger.info(f"开始刮削:{filepath} ...")
|
||||||
|
if mediainfo.type == MediaType.MOVIE:
|
||||||
|
# 电影
|
||||||
|
if fileitem.type == "file":
|
||||||
|
# 电影文件
|
||||||
|
logger.info(f"正在生成电影nfo:{mediainfo.title_year} - {filepath.name}")
|
||||||
|
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||||
|
if not movie_nfo:
|
||||||
|
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||||
|
return
|
||||||
|
# 保存或上传nfo文件到上级目录
|
||||||
|
nfo_path = filepath.with_suffix(".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:
|
||||||
|
# 电影目录
|
||||||
|
files = __list_files(_fileitem=fileitem)
|
||||||
|
for file in files:
|
||||||
|
self.scrape_metadata(fileitem=file,
|
||||||
|
meta=meta, mediainfo=mediainfo,
|
||||||
|
init_folder=False, parent=fileitem)
|
||||||
|
# 生成目录内图片文件
|
||||||
|
if init_folder:
|
||||||
|
# 图片
|
||||||
|
for attr_name, attr_value in vars(mediainfo).items():
|
||||||
|
if attr_value \
|
||||||
|
and attr_name.endswith("_path") \
|
||||||
|
and attr_value \
|
||||||
|
and isinstance(attr_value, str) \
|
||||||
|
and attr_value.startswith("http"):
|
||||||
|
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||||
|
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 = __download_image(_url=attr_value)
|
||||||
|
# 写入图片到当前目录
|
||||||
|
if content:
|
||||||
|
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||||
|
else:
|
||||||
|
# 电视剧
|
||||||
|
if fileitem.type == "file":
|
||||||
|
# 当前为集文件,重新识别季集
|
||||||
|
file_meta = MetaInfoPath(filepath)
|
||||||
|
if not file_meta.begin_episode:
|
||||||
|
logger.warn(f"{filepath.name} 无法识别文件集数!")
|
||||||
|
return
|
||||||
|
file_mediainfo = self.recognize_media(meta=file_meta)
|
||||||
|
if not file_mediainfo:
|
||||||
|
logger.warn(f"{filepath.name} 无法识别文件媒体信息!")
|
||||||
|
return
|
||||||
|
# 获取集的nfo文件
|
||||||
|
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
|
||||||
|
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||||
|
if not episode_nfo:
|
||||||
|
logger.warn(f"{filepath.name} nfo生成失败!")
|
||||||
|
return
|
||||||
|
# 保存或上传nfo文件到上级目录
|
||||||
|
nfo_path = filepath.with_suffix(".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:
|
||||||
|
# 当前为目录,处理目录内的文件
|
||||||
|
files = __list_files(_fileitem=fileitem)
|
||||||
|
for file in files:
|
||||||
|
self.scrape_metadata(fileitem=file,
|
||||||
|
meta=meta, mediainfo=mediainfo,
|
||||||
|
parent=fileitem if file.type == "file" else None,
|
||||||
|
init_folder=True if file.type == "dir" else False)
|
||||||
|
# 生成目录的nfo和图片
|
||||||
|
if init_folder:
|
||||||
|
# 识别文件夹名称
|
||||||
|
season_meta = MetaInfo(filepath.name)
|
||||||
|
if season_meta.begin_season:
|
||||||
|
# 当前目录有季号,生成季nfo
|
||||||
|
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo, season=meta.begin_season)
|
||||||
|
if not season_nfo:
|
||||||
|
logger.warn(f"无法生成电视剧季nfo文件:{meta.name}")
|
||||||
|
return
|
||||||
|
# 写入nfo到根目录
|
||||||
|
nfo_path = filepath / "season.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=fileitem, _path=nfo_path, _content=season_nfo)
|
||||||
|
# TMDB季poster图片
|
||||||
|
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
|
||||||
|
if image_dict:
|
||||||
|
for image_name, image_url in image_dict.items():
|
||||||
|
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 = __download_image(image_url)
|
||||||
|
# 保存图片文件到当前目录
|
||||||
|
if content:
|
||||||
|
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||||
|
# 判断当前目录是不是剧集根目录
|
||||||
|
if season_meta.name:
|
||||||
|
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
||||||
|
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||||
|
if not tv_nfo:
|
||||||
|
logger.warn(f"无法生成电视剧nfo文件:{meta.name}")
|
||||||
|
return
|
||||||
|
# 写入tvshow nfo到根目录
|
||||||
|
nfo_path = filepath / "tvshow.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=fileitem, _path=nfo_path, _content=tv_nfo)
|
||||||
|
# 生成目录图片
|
||||||
|
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||||
|
if image_dict:
|
||||||
|
for image_name, image_url in image_dict.items():
|
||||||
|
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 = __download_image(image_url)
|
||||||
|
# 保存图片文件到当前目录
|
||||||
|
if content:
|
||||||
|
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -80,35 +131,49 @@ class MediaServerChain(ChainBase):
|
|||||||
self.dboper.empty()
|
self.dboper.empty()
|
||||||
# 遍历媒体服务器
|
# 遍历媒体服务器
|
||||||
for mediaserver in mediaservers:
|
for mediaserver in mediaservers:
|
||||||
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")
|
if not mediaserver:
|
||||||
for library in self.librarys(mediaserver):
|
continue
|
||||||
# 同步黑名单 跳过
|
logger.info(f"正在准备同步媒体服务器 {mediaserver.name} 的数据")
|
||||||
if library.name in sync_blacklist:
|
if not mediaserver.enabled:
|
||||||
|
logger.info(f"媒体服务器 {mediaserver.name} 未启用,跳过")
|
||||||
|
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,8 @@ 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')
|
||||||
), torrents=items)
|
), torrents=items)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import pickle
|
import pickle
|
||||||
import re
|
|
||||||
import traceback
|
import traceback
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -7,8 +6,10 @@ 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.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.helper.progress import ProgressHelper
|
from app.helper.progress import ProgressHelper
|
||||||
@@ -16,8 +17,7 @@ from app.helper.sites import SitesHelper
|
|||||||
from app.helper.torrent import TorrentHelper
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import NotExistMediaInfo
|
from app.schemas import NotExistMediaInfo
|
||||||
from app.schemas.types import MediaType, ProgressKey, SystemConfigKey
|
from app.schemas.types import MediaType, ProgressKey, SystemConfigKey, EventType
|
||||||
from app.utils.string import StringUtils
|
|
||||||
|
|
||||||
|
|
||||||
class SearchChain(ChainBase):
|
class SearchChain(ChainBase):
|
||||||
@@ -25,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()
|
||||||
@@ -33,25 +35,33 @@ class SearchChain(ChainBase):
|
|||||||
self.torrenthelper = TorrentHelper()
|
self.torrenthelper = TorrentHelper()
|
||||||
|
|
||||||
def search_by_id(self, tmdbid: int = None, doubanid: str = None,
|
def search_by_id(self, tmdbid: int = None, doubanid: str = None,
|
||||||
mtype: MediaType = None, area: str = "title") -> List[Context]:
|
mtype: MediaType = None, area: str = "title", season: int = None) -> List[Context]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID/豆瓣ID搜索资源,精确匹配,但不不过滤本地存在的资源
|
根据TMDBID/豆瓣ID搜索资源,精确匹配,但不不过滤本地存在的资源
|
||||||
:param tmdbid: TMDB ID
|
:param tmdbid: TMDB ID
|
||||||
:param doubanid: 豆瓣 ID
|
:param doubanid: 豆瓣 ID
|
||||||
:param mtype: 媒体,电影 or 电视剧
|
:param mtype: 媒体,电影 or 电视剧
|
||||||
:param area: 搜索范围,title or imdbid
|
:param area: 搜索范围,title or imdbid
|
||||||
|
:param season: 季数
|
||||||
"""
|
"""
|
||||||
mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||||
return []
|
return []
|
||||||
results = self.process(mediainfo=mediainfo, area=area)
|
no_exists = None
|
||||||
# 保存眲结果
|
if season:
|
||||||
|
no_exists = {
|
||||||
|
tmdbid or doubanid: {
|
||||||
|
season: NotExistMediaInfo(episodes=[])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = self.process(mediainfo=mediainfo, area=area, no_exists=no_exists)
|
||||||
|
# 保存到本地文件
|
||||||
bytes_results = pickle.dumps(results)
|
bytes_results = pickle.dumps(results)
|
||||||
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
|
self.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[TorrentInfo]:
|
def search_by_title(self, title: str, page: int = 0, site: int = None) -> List[Context]:
|
||||||
"""
|
"""
|
||||||
根据标题搜索资源,不识别不过滤,直接返回站点内容
|
根据标题搜索资源,不识别不过滤,直接返回站点内容
|
||||||
:param title: 标题,为空时返回所有站点首页内容
|
:param title: 标题,为空时返回所有站点首页内容
|
||||||
@@ -63,17 +73,28 @@ class SearchChain(ChainBase):
|
|||||||
else:
|
else:
|
||||||
logger.info(f'开始浏览资源,站点:{site} ...')
|
logger.info(f'开始浏览资源,站点:{site} ...')
|
||||||
# 搜索
|
# 搜索
|
||||||
return self.__search_all_sites(keywords=[title], sites=[site] if site else None, page=page) or []
|
torrents = self.__search_all_sites(keywords=[title], sites=[site] if site else None, page=page) or []
|
||||||
|
if not torrents:
|
||||||
|
logger.warn(f'{title} 未搜索到资源')
|
||||||
|
return []
|
||||||
|
# 组装上下文
|
||||||
|
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||||
|
torrent_info=torrent) for torrent in torrents]
|
||||||
|
# 保存到本地文件
|
||||||
|
bytes_results = pickle.dumps(contexts)
|
||||||
|
self.save_cache(bytes_results, self.__result_temp_file)
|
||||||
|
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 []
|
||||||
@@ -82,25 +103,38 @@ 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]:
|
||||||
|
"""
|
||||||
|
执行优先级过滤
|
||||||
|
"""
|
||||||
|
return self.filter_torrents(rule_groups=rule_groups,
|
||||||
|
torrent_list=torrent_list,
|
||||||
|
season_episodes=season_episodes,
|
||||||
|
mediainfo=mediainfo) or []
|
||||||
|
|
||||||
# 豆瓣标题处理
|
# 豆瓣标题处理
|
||||||
if not mediainfo.tmdb_id:
|
if not mediainfo.tmdb_id:
|
||||||
meta = MetaInfo(title=mediainfo.title)
|
meta = MetaInfo(title=mediainfo.title)
|
||||||
mediainfo.title = meta.name
|
mediainfo.title = meta.name
|
||||||
mediainfo.season = meta.begin_season
|
mediainfo.season = meta.begin_season
|
||||||
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||||
|
|
||||||
# 补充媒体信息
|
# 补充媒体信息
|
||||||
if not mediainfo.names:
|
if not mediainfo.names:
|
||||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||||
@@ -109,17 +143,19 @@ class SearchChain(ChainBase):
|
|||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
logger.error(f'媒体信息识别失败!')
|
logger.error(f'媒体信息识别失败!')
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 缺失的季集
|
# 缺失的季集
|
||||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||||
if no_exists and no_exists.get(mediakey):
|
if no_exists and no_exists.get(mediakey):
|
||||||
# 过滤剧集
|
# 过滤剧集
|
||||||
season_episodes = {sea: info.episodes
|
season_episodes = {sea: info.episodes
|
||||||
for sea, info in no_exists[mediainfo.tmdb_id].items()}
|
for sea, info in no_exists[mediakey].items()}
|
||||||
elif mediainfo.season:
|
elif mediainfo.season:
|
||||||
# 豆瓣只搜索当前季
|
# 豆瓣只搜索当前季
|
||||||
season_episodes = {mediainfo.season: []}
|
season_episodes = {mediainfo.season: []}
|
||||||
else:
|
else:
|
||||||
season_episodes = None
|
season_episodes = None
|
||||||
|
|
||||||
# 搜索关键词
|
# 搜索关键词
|
||||||
if keyword:
|
if keyword:
|
||||||
keywords = [keyword]
|
keywords = [keyword]
|
||||||
@@ -128,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]))
|
||||||
|
|
||||||
# 执行搜索
|
# 执行搜索
|
||||||
@@ -140,134 +178,103 @@ class SearchChain(ChainBase):
|
|||||||
if not torrents:
|
if not torrents:
|
||||||
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 开始新进度
|
# 开始新进度
|
||||||
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:
|
||||||
|
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 filter_params and not self.torrenthelper.filter_torrent(torrent_info=torrent,
|
||||||
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
|
filter_params=filter_params):
|
||||||
logger.warn(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value},'
|
|
||||||
f'需要是 {mediainfo.type.value},不匹配')
|
|
||||||
continue
|
continue
|
||||||
# 比对种子在站点中的类型
|
|
||||||
if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
|
# 比对种子
|
||||||
logger.warn(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category},'
|
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||||
f'需要是 {mediainfo.type.value},不匹配')
|
torrent_meta=torrent_meta,
|
||||||
|
torrent=torrent):
|
||||||
|
# 匹配成功
|
||||||
|
_match_torrents.append((torrent, torrent_meta))
|
||||||
continue
|
continue
|
||||||
# 比对年份
|
# 匹配完成
|
||||||
if mediainfo.year:
|
|
||||||
if mediainfo.type == MediaType.TV:
|
|
||||||
# 剧集年份,每季的年份可能不同
|
|
||||||
if torrent_meta.year and torrent_meta.year not in [year for year in
|
|
||||||
mediainfo.season_years.values()]:
|
|
||||||
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配')
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# 电影年份,上下浮动1年
|
|
||||||
if torrent_meta.year not in [str(int(mediainfo.year) - 1),
|
|
||||||
mediainfo.year,
|
|
||||||
str(int(mediainfo.year) + 1)]:
|
|
||||||
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配')
|
|
||||||
continue
|
|
||||||
# 比对标题和原语种标题
|
|
||||||
meta_name = StringUtils.clear_upper(torrent_meta.name)
|
|
||||||
if meta_name in [
|
|
||||||
StringUtils.clear_upper(mediainfo.title),
|
|
||||||
StringUtils.clear_upper(mediainfo.original_title)
|
|
||||||
]:
|
|
||||||
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
|
|
||||||
_match_torrents.append(torrent)
|
|
||||||
continue
|
|
||||||
# 在副标题中判断是否存在标题与原语种标题
|
|
||||||
if torrent.description:
|
|
||||||
subtitle = re.split(r'[\s/|]+', torrent.description)
|
|
||||||
if (StringUtils.is_chinese(mediainfo.title)
|
|
||||||
and str(mediainfo.title) in subtitle) \
|
|
||||||
or (StringUtils.is_chinese(mediainfo.original_title)
|
|
||||||
and str(mediainfo.original_title) in subtitle):
|
|
||||||
logger.info(f'{mediainfo.title} 通过副标题匹配到资源:{torrent.site_name} - {torrent.title},'
|
|
||||||
f'副标题:{torrent.description}')
|
|
||||||
_match_torrents.append(torrent)
|
|
||||||
continue
|
|
||||||
# 比对别名和译名
|
|
||||||
for name in mediainfo.names:
|
|
||||||
if StringUtils.clear_upper(name) == meta_name:
|
|
||||||
logger.info(f'{mediainfo.title} 通过别名或译名匹配到资源:{torrent.site_name} - {torrent.title}')
|
|
||||||
_match_torrents.append(torrent)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
logger.warn(f'{torrent.site_name} - {torrent.title} 标题不匹配')
|
|
||||||
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
||||||
self.progress.update(value=97,
|
self.progress.update(value=97,
|
||||||
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 priority_rule is None:
|
|
||||||
# 取搜索优先级规则
|
|
||||||
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
|
|
||||||
if priority_rule:
|
|
||||||
logger.info(f'开始优先级规则过滤,当前规则:{priority_rule} ...')
|
|
||||||
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
|
|
||||||
torrent_list=_match_torrents,
|
|
||||||
season_episodes=season_episodes,
|
|
||||||
mediainfo=mediainfo)
|
|
||||||
if result is not None:
|
|
||||||
_match_torrents = result
|
|
||||||
if not _match_torrents:
|
|
||||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
|
||||||
return []
|
|
||||||
# 使用过滤规则再次过滤
|
|
||||||
if filter_rule:
|
|
||||||
logger.info(f'开始过滤规则过滤,当前规则:{filter_rule} ...')
|
|
||||||
_match_torrents = self.filter_torrents_by_rule(torrents=_match_torrents,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
filter_rule=filter_rule)
|
|
||||||
if not _match_torrents:
|
|
||||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
|
||||||
return []
|
|
||||||
# 去掉mediainfo中多余的数据
|
# 去掉mediainfo中多余的数据
|
||||||
mediainfo.clear()
|
mediainfo.clear()
|
||||||
# 组装上下文
|
|
||||||
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
|
||||||
media_info=mediainfo,
|
|
||||||
torrent_info=torrent) for torrent in _match_torrents]
|
|
||||||
|
|
||||||
logger.info(f"过滤完成,剩余 {_total} 个资源")
|
# 组装上下文
|
||||||
self.progress.update(value=99, text=f'过滤完成,剩余 {_total} 个资源', key=ProgressKey.Search)
|
contexts = [Context(torrent_info=t[0],
|
||||||
|
media_info=mediainfo,
|
||||||
|
meta_info=t[1]) for t in _match_torrents]
|
||||||
|
|
||||||
# 排序
|
# 排序
|
||||||
self.progress.update(value=100,
|
self.progress.update(value=99,
|
||||||
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
|
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
|
||||||
key=ProgressKey.Search)
|
key=ProgressKey.Search)
|
||||||
contexts = self.torrenthelper.sort_torrents(contexts)
|
contexts = self.torrenthelper.sort_torrents(contexts)
|
||||||
|
|
||||||
# 结束进度
|
# 结束进度
|
||||||
|
logger.info(f'搜索完成,共 {len(contexts)} 个资源')
|
||||||
|
self.progress.update(value=100,
|
||||||
|
text=f'搜索完成,共 {len(contexts)} 个资源',
|
||||||
|
key=ProgressKey.Search)
|
||||||
self.progress.end(ProgressKey.Search)
|
self.progress.end(ProgressKey.Search)
|
||||||
|
|
||||||
# 返回
|
# 返回
|
||||||
return contexts
|
return contexts
|
||||||
|
|
||||||
@@ -337,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:
|
||||||
@@ -357,30 +366,23 @@ class SearchChain(ChainBase):
|
|||||||
# 返回
|
# 返回
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def filter_torrents_by_rule(self,
|
@eventmanager.register(EventType.SiteDeleted)
|
||||||
torrents: List[TorrentInfo],
|
def remove_site(self, event: Event):
|
||||||
mediainfo: MediaInfo,
|
|
||||||
filter_rule: Dict[str, str] = None,
|
|
||||||
) -> List[TorrentInfo]:
|
|
||||||
"""
|
"""
|
||||||
使用过滤规则过滤种子
|
从搜索站点中移除与已删除站点相关的设置
|
||||||
:param torrents: 种子列表
|
|
||||||
:param filter_rule: 过滤规则
|
|
||||||
:param mediainfo: 媒体信息
|
|
||||||
"""
|
"""
|
||||||
|
if not event:
|
||||||
if not filter_rule:
|
return
|
||||||
# 没有则取搜索默认过滤规则
|
event_data = event.event_data or {}
|
||||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules)
|
site_id = event_data.get("site_id")
|
||||||
if not filter_rule:
|
if not site_id:
|
||||||
return torrents
|
return
|
||||||
|
if site_id == "*":
|
||||||
# 使用默认过滤规则再次过滤
|
# 清空搜索站点
|
||||||
return list(filter(
|
SystemConfigOper().set(SystemConfigKey.IndexerSites, [])
|
||||||
lambda t: self.torrenthelper.filter_torrent(
|
return
|
||||||
torrent_info=t,
|
# 从选中的rss站点中移除
|
||||||
filter_rule=filter_rule,
|
selected_sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
|
||||||
mediainfo=mediainfo
|
if site_id in selected_sites:
|
||||||
),
|
selected_sites.remove(site_id)
|
||||||
torrents
|
SystemConfigOper().set(SystemConfigKey.IndexerSites, selected_sites)
|
||||||
))
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
from typing import Tuple, Optional
|
from datetime import datetime
|
||||||
from typing import Union
|
from typing import Optional, Tuple, 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.helper.browser import PlaywrightHelper
|
from app.helper.browser import PlaywrightHelper
|
||||||
from app.helper.cloudflare import under_challenge
|
from app.helper.cloudflare import under_challenge
|
||||||
@@ -21,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
|
||||||
@@ -36,7 +36,6 @@ 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()
|
||||||
@@ -49,8 +48,64 @@ class SiteChain(ChainBase):
|
|||||||
"zhuque.in": self.__zhuque_test,
|
"zhuque.in": self.__zhuque_test,
|
||||||
"m-team.io": self.__mteam_test,
|
"m-team.io": self.__mteam_test,
|
||||||
"m-team.cc": self.__mteam_test,
|
"m-team.cc": self.__mteam_test,
|
||||||
|
"ptlsp.com": self.__indexphp_test,
|
||||||
|
"1ptba.com": self.__indexphp_test,
|
||||||
|
"star-space.net": self.__indexphp_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:
|
||||||
"""
|
"""
|
||||||
判断是否特殊站点
|
判断是否特殊站点
|
||||||
@@ -64,11 +119,12 @@ class SiteChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
# 获取token
|
# 获取token
|
||||||
token = None
|
token = None
|
||||||
|
user_agent = site.ua or settings.USER_AGENT
|
||||||
res = RequestUtils(
|
res = RequestUtils(
|
||||||
ua=site.ua,
|
ua=user_agent,
|
||||||
cookies=site.cookie,
|
cookies=site.cookie,
|
||||||
proxies=settings.PROXY if site.proxy else None,
|
proxies=settings.PROXY if site.proxy else None,
|
||||||
timeout=15
|
timeout=site.timeout or 15
|
||||||
).get_res(url=site.url)
|
).get_res(url=site.url)
|
||||||
if res and res.status_code == 200:
|
if res and res.status_code == 200:
|
||||||
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
|
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
|
||||||
@@ -81,11 +137,11 @@ class SiteChain(ChainBase):
|
|||||||
headers={
|
headers={
|
||||||
'X-CSRF-TOKEN': token,
|
'X-CSRF-TOKEN': token,
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
"User-Agent": f"{site.ua}"
|
"User-Agent": f"{user_agent}"
|
||||||
},
|
},
|
||||||
cookies=site.cookie,
|
cookies=site.cookie,
|
||||||
proxies=settings.PROXY if site.proxy else None,
|
proxies=settings.PROXY if site.proxy else None,
|
||||||
timeout=15
|
timeout=site.timeout or 15
|
||||||
).get_res(url=f"{site.url}api/user/getInfo")
|
).get_res(url=f"{site.url}api/user/getInfo")
|
||||||
if user_res and user_res.status_code == 200:
|
if user_res and user_res.status_code == 200:
|
||||||
user_info = user_res.json()
|
user_info = user_res.json()
|
||||||
@@ -98,18 +154,65 @@ class SiteChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
判断站点是否已经登陆:m-team
|
判断站点是否已经登陆:m-team
|
||||||
"""
|
"""
|
||||||
url = f"{site.url}api/member/profile"
|
user_agent = site.ua or settings.USER_AGENT
|
||||||
|
domain = StringUtils.get_url_domain(site.url)
|
||||||
|
url = f"https://api.{domain}/api/member/profile"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": user_agent,
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Authorization": site.token
|
||||||
|
}
|
||||||
res = RequestUtils(
|
res = RequestUtils(
|
||||||
ua=site.ua,
|
headers=headers,
|
||||||
cookies=site.cookie,
|
|
||||||
proxies=settings.PROXY if site.proxy else None,
|
proxies=settings.PROXY if site.proxy else None,
|
||||||
timeout=15
|
timeout=site.timeout or 15
|
||||||
).post_res(url=url)
|
).post_res(url=url)
|
||||||
if res and res.status_code == 200:
|
if res and res.status_code == 200:
|
||||||
user_info = res.json()
|
user_info = res.json()
|
||||||
if user_info and user_info.get("data"):
|
if user_info and user_info.get("data"):
|
||||||
|
# 更新最后访问时间
|
||||||
|
res = RequestUtils(headers=headers,
|
||||||
|
timeout=site.timeout or 15,
|
||||||
|
proxies=settings.PROXY if site.proxy else None,
|
||||||
|
referer=f"{site.url}index"
|
||||||
|
).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse")
|
||||||
|
if res:
|
||||||
|
return True, "连接成功"
|
||||||
|
else:
|
||||||
|
return True, f"连接成功,但更新状态失败"
|
||||||
|
return False, "鉴权已过期或无效"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __yema_test(site: Site) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
判断站点是否已经登陆:yemapt
|
||||||
|
"""
|
||||||
|
user_agent = site.ua or settings.USER_AGENT
|
||||||
|
url = f"{site.url}api/consumer/fetchSelfDetail"
|
||||||
|
headers = {
|
||||||
|
"User-Agent": user_agent,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
}
|
||||||
|
res = RequestUtils(
|
||||||
|
headers=headers,
|
||||||
|
cookies=site.cookie,
|
||||||
|
proxies=settings.PROXY if site.proxy else None,
|
||||||
|
timeout=site.timeout or 15
|
||||||
|
).get_res(url=url)
|
||||||
|
if res and res.status_code == 200:
|
||||||
|
user_info = res.json()
|
||||||
|
if user_info and user_info.get("success"):
|
||||||
return True, "连接成功"
|
return True, "连接成功"
|
||||||
return False, "Cookie已失效"
|
return False, "Cookie已过期"
|
||||||
|
|
||||||
|
def __indexphp_test(self, site: Site) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
判断站点是否已经登陆:ptlsp/1ptba
|
||||||
|
"""
|
||||||
|
site.url = f"{site.url}index.php"
|
||||||
|
return self.__test(site)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:
|
def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:
|
||||||
@@ -121,19 +224,19 @@ class SiteChain(ChainBase):
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
favicon_url = urljoin(url, "favicon.ico")
|
favicon_url = urljoin(url, "favicon.ico")
|
||||||
res = RequestUtils(cookies=cookie, timeout=60, ua=ua).get_res(url=url)
|
res = RequestUtils(cookies=cookie, timeout=30, ua=ua).get_res(url=url)
|
||||||
if res:
|
if res:
|
||||||
html_text = res.text
|
html_text = res.text
|
||||||
else:
|
else:
|
||||||
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])
|
||||||
|
|
||||||
res = RequestUtils(cookies=cookie, timeout=20, ua=ua).get_res(url=favicon_url)
|
res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)
|
||||||
if res:
|
if res:
|
||||||
return favicon_url, base64.b64encode(res.content).decode()
|
return favicon_url, base64.b64encode(res.content).decode()
|
||||||
else:
|
else:
|
||||||
@@ -161,7 +264,7 @@ class SiteChain(ChainBase):
|
|||||||
if not cookies:
|
if not cookies:
|
||||||
logger.error(f"CookieCloud同步失败:{msg}")
|
logger.error(f"CookieCloud同步失败:{msg}")
|
||||||
if manual:
|
if manual:
|
||||||
self.message.put(f"CookieCloud同步失败: {msg}")
|
self.message.put(msg, title="CookieCloud同步失败", role="system")
|
||||||
return False, msg
|
return False, msg
|
||||||
# 保存Cookie或新增站点
|
# 保存Cookie或新增站点
|
||||||
_update_count = 0
|
_update_count = 0
|
||||||
@@ -172,7 +275,7 @@ class SiteChain(ChainBase):
|
|||||||
indexer = self.siteshelper.get_indexer(domain)
|
indexer = self.siteshelper.get_indexer(domain)
|
||||||
# 数据库的站点信息
|
# 数据库的站点信息
|
||||||
site_info = self.siteoper.get_by_domain(domain)
|
site_info = self.siteoper.get_by_domain(domain)
|
||||||
if site_info:
|
if site_info and site_info.is_active == 1:
|
||||||
# 站点已存在,检查站点连通性
|
# 站点已存在,检查站点连通性
|
||||||
status, msg = self.test(domain)
|
status, msg = self.test(domain)
|
||||||
# 更新站点Cookie
|
# 更新站点Cookie
|
||||||
@@ -184,7 +287,7 @@ class SiteChain(ChainBase):
|
|||||||
rss_url, errmsg = self.rsshelper.get_rss_link(
|
rss_url, errmsg = self.rsshelper.get_rss_link(
|
||||||
url=site_info.url,
|
url=site_info.url,
|
||||||
cookie=cookie,
|
cookie=cookie,
|
||||||
ua=settings.USER_AGENT,
|
ua=site_info.ua or settings.USER_AGENT,
|
||||||
proxy=True if site_info.proxy else False
|
proxy=True if site_info.proxy else False
|
||||||
)
|
)
|
||||||
if rss_url:
|
if rss_url:
|
||||||
@@ -198,6 +301,11 @@ class SiteChain(ChainBase):
|
|||||||
self.siteoper.update_cookie(domain=domain, cookies=cookie)
|
self.siteoper.update_cookie(domain=domain, cookies=cookie)
|
||||||
_update_count += 1
|
_update_count += 1
|
||||||
elif indexer:
|
elif indexer:
|
||||||
|
if settings.COOKIECLOUD_BLACKLIST and any(
|
||||||
|
StringUtils.get_url_domain(domain) == StringUtils.get_url_domain(black_domain) for black_domain
|
||||||
|
in str(settings.COOKIECLOUD_BLACKLIST).split(",")):
|
||||||
|
logger.warn(f"站点 {domain} 已在黑名单中,不添加站点")
|
||||||
|
continue
|
||||||
# 新增站点
|
# 新增站点
|
||||||
domain_url = __indexer_domain(inx=indexer, sub_domain=domain)
|
domain_url = __indexer_domain(inx=indexer, sub_domain=domain)
|
||||||
res = RequestUtils(cookies=cookie,
|
res = RequestUtils(cookies=cookie,
|
||||||
@@ -249,7 +357,7 @@ class SiteChain(ChainBase):
|
|||||||
if _fail_count > 0:
|
if _fail_count > 0:
|
||||||
ret_msg += f",{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
|
ret_msg += f",{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
|
||||||
if manual:
|
if manual:
|
||||||
self.message.put(f"CookieCloud同步成功, {ret_msg}")
|
self.message.put(ret_msg, title="CookieCloud同步成功", role="system")
|
||||||
logger.info(f"CookieCloud同步成功:{ret_msg}")
|
logger.info(f"CookieCloud同步成功:{ret_msg}")
|
||||||
return True, ret_msg
|
return True, ret_msg
|
||||||
|
|
||||||
@@ -280,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')} 图标失败")
|
||||||
@@ -316,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]:
|
||||||
"""
|
"""
|
||||||
测试站点是否可用
|
测试站点是否可用
|
||||||
@@ -330,55 +458,72 @@ class SiteChain(ChainBase):
|
|||||||
|
|
||||||
# 模拟登录
|
# 模拟登录
|
||||||
try:
|
try:
|
||||||
|
# 开始记时
|
||||||
|
start_time = datetime.now()
|
||||||
# 特殊站点测试
|
# 特殊站点测试
|
||||||
if self.special_site_test.get(domain):
|
if self.special_site_test.get(domain):
|
||||||
return self.special_site_test[domain](site_info)
|
state, message = self.special_site_test[domain](site_info)
|
||||||
|
|
||||||
# 通用站点测试
|
|
||||||
site_url = site_info.url
|
|
||||||
site_cookie = site_info.cookie
|
|
||||||
ua = site_info.ua
|
|
||||||
render = site_info.render
|
|
||||||
public = site_info.public
|
|
||||||
proxies = settings.PROXY if site_info.proxy else None
|
|
||||||
proxy_server = settings.PROXY_SERVER if site_info.proxy else None
|
|
||||||
|
|
||||||
# 访问链接
|
|
||||||
if render:
|
|
||||||
page_source = PlaywrightHelper().get_page_source(url=site_url,
|
|
||||||
cookies=site_cookie,
|
|
||||||
ua=ua,
|
|
||||||
proxies=proxy_server)
|
|
||||||
if not public and not SiteUtils.is_logged_in(page_source):
|
|
||||||
if under_challenge(page_source):
|
|
||||||
return False, f"无法通过Cloudflare!"
|
|
||||||
return False, f"仿真登录失败,Cookie已失效!"
|
|
||||||
else:
|
else:
|
||||||
res = RequestUtils(cookies=site_cookie,
|
# 通用站点测试
|
||||||
ua=ua,
|
state, message = self.__test(site_info)
|
||||||
proxies=proxies
|
# 统计
|
||||||
).get_res(url=site_url)
|
seconds = (datetime.now() - start_time).seconds
|
||||||
# 判断登录状态
|
if state:
|
||||||
if res and res.status_code in [200, 500, 403]:
|
self.siteoper.success(domain=domain, seconds=seconds)
|
||||||
if not public and not SiteUtils.is_logged_in(res.text):
|
else:
|
||||||
if under_challenge(res.text):
|
self.siteoper.fail(domain)
|
||||||
msg = "站点被Cloudflare防护,请打开站点浏览器仿真"
|
return state, message
|
||||||
elif res.status_code == 200:
|
|
||||||
msg = "Cookie已失效"
|
|
||||||
else:
|
|
||||||
msg = f"状态码:{res.status_code}"
|
|
||||||
return False, f"{msg}!"
|
|
||||||
elif public and res.status_code != 200:
|
|
||||||
return False, f"状态码:{res.status_code}!"
|
|
||||||
elif res is not None:
|
|
||||||
return False, f"状态码:{res.status_code}!"
|
|
||||||
else:
|
|
||||||
return False, f"无法打开网站!"
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"{str(e)}!"
|
return False, f"{str(e)}!"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __test(site_info: Site) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
通用站点测试
|
||||||
|
"""
|
||||||
|
site_url = site_info.url
|
||||||
|
site_cookie = site_info.cookie
|
||||||
|
ua = site_info.ua or settings.USER_AGENT
|
||||||
|
render = site_info.render
|
||||||
|
public = site_info.public
|
||||||
|
proxies = settings.PROXY if site_info.proxy else None
|
||||||
|
proxy_server = settings.PROXY_SERVER if site_info.proxy else None
|
||||||
|
|
||||||
|
# 访问链接
|
||||||
|
if render:
|
||||||
|
page_source = PlaywrightHelper().get_page_source(url=site_url,
|
||||||
|
cookies=site_cookie,
|
||||||
|
ua=ua,
|
||||||
|
proxies=proxy_server)
|
||||||
|
if not public and not SiteUtils.is_logged_in(page_source):
|
||||||
|
if under_challenge(page_source):
|
||||||
|
return False, f"无法通过Cloudflare!"
|
||||||
|
return False, f"仿真登录失败,Cookie已失效!"
|
||||||
|
else:
|
||||||
|
res = RequestUtils(cookies=site_cookie,
|
||||||
|
ua=ua,
|
||||||
|
proxies=proxies
|
||||||
|
).get_res(url=site_url)
|
||||||
|
# 判断登录状态
|
||||||
|
if res and res.status_code in [200, 500, 403]:
|
||||||
|
if not public and not SiteUtils.is_logged_in(res.text):
|
||||||
|
if under_challenge(res.text):
|
||||||
|
msg = "站点被Cloudflare防护,请打开站点浏览器仿真"
|
||||||
|
elif res.status_code == 200:
|
||||||
|
msg = "Cookie已失效"
|
||||||
|
else:
|
||||||
|
msg = f"状态码:{res.status_code}"
|
||||||
|
return False, f"{msg}!"
|
||||||
|
elif public and res.status_code != 200:
|
||||||
|
return False, f"状态码:{res.status_code}!"
|
||||||
|
elif res is not None:
|
||||||
|
return False, f"状态码:{res.status_code}!"
|
||||||
|
else:
|
||||||
|
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):
|
||||||
"""
|
"""
|
||||||
查询所有站点,发送消息
|
查询所有站点,发送消息
|
||||||
"""
|
"""
|
||||||
@@ -387,7 +532,8 @@ class SiteChain(ChainBase):
|
|||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
title="没有维护任何站点信息!",
|
title="没有维护任何站点信息!",
|
||||||
userid=userid))
|
userid=userid,
|
||||||
|
link=settings.MP_DOMAIN('#/site')))
|
||||||
title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \
|
title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \
|
||||||
f"\n- 禁用站点:/site_disable [id]" \
|
f"\n- 禁用站点:/site_disable [id]" \
|
||||||
f"\n- 启用站点:/site_enable [id]" \
|
f"\n- 启用站点:/site_enable [id]" \
|
||||||
@@ -405,9 +551,13 @@ class SiteChain(ChainBase):
|
|||||||
# 发送列表
|
# 发送列表
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
title=title, text="\n".join(messages), userid=userid))
|
source=source,
|
||||||
|
title=title, text="\n".join(messages), userid=userid,
|
||||||
|
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):
|
||||||
"""
|
"""
|
||||||
禁用站点
|
禁用站点
|
||||||
"""
|
"""
|
||||||
@@ -429,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):
|
||||||
"""
|
"""
|
||||||
启用站点
|
启用站点
|
||||||
"""
|
"""
|
||||||
@@ -454,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]:
|
||||||
@@ -485,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
|
||||||
"""
|
"""
|
||||||
@@ -494,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()
|
||||||
@@ -505,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
|
||||||
@@ -520,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]
|
||||||
@@ -538,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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
@@ -18,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({
|
||||||
@@ -40,19 +46,31 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
|||||||
}, self._restart_file)
|
}, self._restart_file)
|
||||||
SystemUtils.restart()
|
SystemUtils.restart()
|
||||||
|
|
||||||
def version(self, channel: MessageChannel, userid: Union[int, str]):
|
def __get_version_message(self) -> str:
|
||||||
|
"""
|
||||||
|
获取版本信息文本
|
||||||
|
"""
|
||||||
|
server_release_version = self.__get_server_release_version()
|
||||||
|
front_release_version = self.__get_front_release_version()
|
||||||
|
server_local_version = self.get_server_local_version()
|
||||||
|
front_local_version = self.get_frontend_version()
|
||||||
|
if server_release_version == server_local_version:
|
||||||
|
title = f"当前后端版本:{server_local_version},已是最新版本\n"
|
||||||
|
else:
|
||||||
|
title = f"当前后端版本:{server_local_version},远程版本:{server_release_version}\n"
|
||||||
|
if front_release_version == front_local_version:
|
||||||
|
title += f"当前前端版本:{front_local_version},已是最新版本"
|
||||||
|
else:
|
||||||
|
title += f"当前前端版本:{front_local_version},远程版本:{front_release_version}"
|
||||||
|
return title
|
||||||
|
|
||||||
|
def version(self, channel: MessageChannel, userid: Union[int, str], source: str = None):
|
||||||
"""
|
"""
|
||||||
查看当前版本、远程版本
|
查看当前版本、远程版本
|
||||||
"""
|
"""
|
||||||
release_version = self.__get_release_version()
|
self.post_message(Notification(channel=channel, source=source,
|
||||||
local_version = self.get_local_version()
|
title=self.__get_version_message(),
|
||||||
if release_version == local_version:
|
userid=userid))
|
||||||
title = f"当前版本:{local_version},已是最新版本"
|
|
||||||
else:
|
|
||||||
title = f"当前版本:{local_version},远程版本:{release_version}"
|
|
||||||
|
|
||||||
self.post_message(Notification(channel=channel,
|
|
||||||
title=title, userid=userid))
|
|
||||||
|
|
||||||
def restart_finish(self):
|
def restart_finish(self):
|
||||||
"""
|
"""
|
||||||
@@ -71,33 +89,50 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
|||||||
userid = restart_channel.get('userid')
|
userid = restart_channel.get('userid')
|
||||||
|
|
||||||
# 版本号
|
# 版本号
|
||||||
release_version = self.__get_release_version()
|
title = self.__get_version_message()
|
||||||
local_version = self.get_local_version()
|
|
||||||
if release_version == local_version:
|
|
||||||
title = f"当前版本:{local_version}"
|
|
||||||
else:
|
|
||||||
title = f"当前版本:{local_version},远程版本:{release_version}"
|
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(Notification(channel=channel,
|
||||||
title=f"系统已重启完成!{title}",
|
title=f"系统已重启完成!\n{title}",
|
||||||
userid=userid))
|
userid=userid))
|
||||||
self.remove_cache(self._restart_file)
|
self.remove_cache(self._restart_file)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_release_version():
|
def __get_server_release_version():
|
||||||
"""
|
"""
|
||||||
获取最新版本
|
获取后端最新版本
|
||||||
"""
|
"""
|
||||||
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
try:
|
||||||
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
|
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||||
if version_res:
|
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
|
||||||
ver_json = version_res.json()
|
if version_res:
|
||||||
version = f"{ver_json['tag_name']}"
|
ver_json = version_res.json()
|
||||||
return version
|
version = f"{ver_json['tag_name']}"
|
||||||
else:
|
return version
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"获取后端最新版本失败:{str(err)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_local_version():
|
def __get_front_release_version():
|
||||||
|
"""
|
||||||
|
获取前端最新版本
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||||
|
"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest")
|
||||||
|
if version_res:
|
||||||
|
ver_json = version_res.json()
|
||||||
|
version = f"{ver_json['tag_name']}"
|
||||||
|
return version
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"获取前端最新版本失败:{str(err)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_server_local_version():
|
||||||
"""
|
"""
|
||||||
查看当前版本
|
查看当前版本
|
||||||
"""
|
"""
|
||||||
@@ -117,3 +152,23 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
|||||||
return None
|
return None
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"加载版本文件 {version_file} 出错:{str(err)}")
|
logger.error(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_frontend_version():
|
||||||
|
"""
|
||||||
|
获取前端版本
|
||||||
|
"""
|
||||||
|
if SystemUtils.is_frozen() and SystemUtils.is_windows():
|
||||||
|
version_file = settings.CONFIG_PATH.parent / "nginx" / "html" / "version.txt"
|
||||||
|
else:
|
||||||
|
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
|
||||||
|
if version_file.exists():
|
||||||
|
try:
|
||||||
|
with open(version_file, 'r') as f:
|
||||||
|
version = str(f.read()).strip()
|
||||||
|
return version
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||||
|
else:
|
||||||
|
logger.warn("未找到前端版本文件,请正确设置 FRONTEND_PATH")
|
||||||
|
return None
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str,
|
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str,
|
||||||
with_original_language: str, page: int = 1) -> Optional[List[dict]]:
|
with_original_language: str, page: int = 1) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
:param mtype: 媒体类型
|
:param mtype: 媒体类型
|
||||||
:param sort_by: 排序方式
|
:param sort_by: 排序方式
|
||||||
@@ -25,21 +25,17 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
:param page: 页码
|
:param page: 页码
|
||||||
:return: 媒体信息列表
|
:return: 媒体信息列表
|
||||||
"""
|
"""
|
||||||
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
|
||||||
return None
|
|
||||||
return self.run_module("tmdb_discover", mtype=mtype,
|
return self.run_module("tmdb_discover", mtype=mtype,
|
||||||
sort_by=sort_by, with_genres=with_genres,
|
sort_by=sort_by, with_genres=with_genres,
|
||||||
with_original_language=with_original_language,
|
with_original_language=with_original_language,
|
||||||
page=page)
|
page=page)
|
||||||
|
|
||||||
def tmdb_trending(self, page: int = 1) -> Optional[List[dict]]:
|
def tmdb_trending(self, page: int = 1) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
TMDB流行趋势
|
TMDB流行趋势
|
||||||
:param page: 第几页
|
:param page: 第几页
|
||||||
:return: TMDB信息列表
|
:return: TMDB信息列表
|
||||||
"""
|
"""
|
||||||
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
|
||||||
return None
|
|
||||||
return self.run_module("tmdb_trending", page=page)
|
return self.run_module("tmdb_trending", page=page)
|
||||||
|
|
||||||
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
||||||
@@ -57,35 +53,35 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_episodes", tmdbid=tmdbid, season=season)
|
return self.run_module("tmdb_episodes", tmdbid=tmdbid, season=season)
|
||||||
|
|
||||||
def movie_similar(self, tmdbid: int) -> List[dict]:
|
def movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询类似电影
|
根据TMDBID查询类似电影
|
||||||
:param tmdbid: TMDBID
|
:param tmdbid: TMDBID
|
||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_movie_similar", tmdbid=tmdbid)
|
return self.run_module("tmdb_movie_similar", tmdbid=tmdbid)
|
||||||
|
|
||||||
def tv_similar(self, tmdbid: int) -> List[dict]:
|
def tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询类似电视剧
|
根据TMDBID查询类似电视剧
|
||||||
:param tmdbid: TMDBID
|
:param tmdbid: TMDBID
|
||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_tv_similar", tmdbid=tmdbid)
|
return self.run_module("tmdb_tv_similar", tmdbid=tmdbid)
|
||||||
|
|
||||||
def movie_recommend(self, tmdbid: int) -> List[dict]:
|
def movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询推荐电影
|
根据TMDBID查询推荐电影
|
||||||
:param tmdbid: TMDBID
|
:param tmdbid: TMDBID
|
||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_movie_recommend", tmdbid=tmdbid)
|
return self.run_module("tmdb_movie_recommend", tmdbid=tmdbid)
|
||||||
|
|
||||||
def tv_recommend(self, tmdbid: int) -> List[dict]:
|
def tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询推荐电视剧
|
根据TMDBID查询推荐电视剧
|
||||||
:param tmdbid: TMDBID
|
:param tmdbid: TMDBID
|
||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid)
|
return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid)
|
||||||
|
|
||||||
def movie_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
def movie_credits(self, tmdbid: int, page: int = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询电影演职人员
|
根据TMDBID查询电影演职人员
|
||||||
:param tmdbid: TMDBID
|
:param tmdbid: TMDBID
|
||||||
@@ -93,7 +89,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page)
|
return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page)
|
||||||
|
|
||||||
def tv_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
def tv_credits(self, tmdbid: int, page: int = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询电视剧演职人员
|
根据TMDBID查询电视剧演职人员
|
||||||
:param tmdbid: TMDBID
|
:param tmdbid: TMDBID
|
||||||
@@ -101,14 +97,14 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page)
|
return self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page)
|
||||||
|
|
||||||
def person_detail(self, person_id: int) -> dict:
|
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询演职员详情
|
根据TMDBID查询演职员详情
|
||||||
:param person_id: 人物ID
|
:param person_id: 人物ID
|
||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_person_detail", person_id=person_id)
|
return self.run_module("tmdb_person_detail", person_id=person_id)
|
||||||
|
|
||||||
def person_credits(self, person_id: int, page: int = 1) -> List[dict]:
|
def person_credits(self, person_id: int, page: int = 1) -> Optional[List[MediaInfo]]:
|
||||||
"""
|
"""
|
||||||
根据人物ID查询人物参演作品
|
根据人物ID查询人物参演作品
|
||||||
:param person_id: 人物ID
|
:param person_id: 人物ID
|
||||||
@@ -117,7 +113,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
|
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
||||||
def get_random_wallpager(self):
|
def get_random_wallpager(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
获取随机壁纸,缓存1个小时
|
获取随机壁纸,缓存1个小时
|
||||||
"""
|
"""
|
||||||
@@ -126,6 +122,16 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
# 随机一个电影
|
# 随机一个电影
|
||||||
while True:
|
while True:
|
||||||
info = random.choice(infos)
|
info = random.choice(infos)
|
||||||
if info and info.get("backdrop_path"):
|
if info and info.backdrop_path:
|
||||||
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.get('backdrop_path')}"
|
return info.backdrop_path
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
||||||
|
def get_trending_wallpapers(self, num: int = 10) -> List[str]:
|
||||||
|
"""
|
||||||
|
获取所有流行壁纸
|
||||||
|
"""
|
||||||
|
infos = self.tmdb_trending()
|
||||||
|
if infos:
|
||||||
|
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
|
||||||
|
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
|
||||||
@@ -99,7 +99,8 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
if not site.get("rss"):
|
if not site.get("rss"):
|
||||||
logger.error(f'站点 {domain} 未配置RSS地址!')
|
logger.error(f'站点 {domain} 未配置RSS地址!')
|
||||||
return []
|
return []
|
||||||
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False, timeout=int(site.get("timeout") or 30))
|
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False,
|
||||||
|
timeout=int(site.get("timeout") or 30))
|
||||||
if rss_items is None:
|
if rss_items is None:
|
||||||
# rss过期,尝试保留原配置生成新的rss
|
# rss过期,尝试保留原配置生成新的rss
|
||||||
self.__renew_rss_url(domain=domain, site=site)
|
self.__renew_rss_url(domain=domain, site=site)
|
||||||
@@ -153,12 +154,17 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
|
|
||||||
# 所有站点索引
|
# 所有站点索引
|
||||||
indexers = self.siteshelper.get_indexers()
|
indexers = self.siteshelper.get_indexers()
|
||||||
|
# 需要刷新的站点domain
|
||||||
|
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
|
||||||
domain = StringUtils.get_url_domain(indexer.get("domain"))
|
domain = StringUtils.get_url_domain(indexer.get("domain"))
|
||||||
|
domains.append(domain)
|
||||||
if stype == "spider":
|
if stype == "spider":
|
||||||
# 刷新首页种子
|
# 刷新首页种子
|
||||||
torrents: List[TorrentInfo] = self.browse(domain=domain)
|
torrents: List[TorrentInfo] = self.browse(domain=domain)
|
||||||
@@ -181,9 +187,13 @@ 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)
|
||||||
|
if torrent.title != meta.org_string:
|
||||||
|
logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}')
|
||||||
# 使用站点种子分类,校正类型识别
|
# 使用站点种子分类,校正类型识别
|
||||||
if meta.type != MediaType.TV \
|
if meta.type != MediaType.TV \
|
||||||
and torrent.category == MediaType.TV.value:
|
and torrent.category == MediaType.TV.value:
|
||||||
@@ -191,7 +201,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
# 识别媒体信息
|
# 识别媒体信息
|
||||||
mediainfo: MediaInfo = self.mediachain.recognize_by_meta(meta)
|
mediainfo: MediaInfo = self.mediachain.recognize_by_meta(meta)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
logger.warn(f'未识别到媒体信息,标题:{torrent.title}')
|
logger.warn(f'{torrent.title} 未识别到媒体信息')
|
||||||
# 存储空的媒体信息
|
# 存储空的媒体信息
|
||||||
mediainfo = MediaInfo()
|
mediainfo = MediaInfo()
|
||||||
# 清理多余数据
|
# 清理多余数据
|
||||||
@@ -217,7 +227,9 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
self.save_cache(torrents_cache, self._rss_file)
|
self.save_cache(torrents_cache, self._rss_file)
|
||||||
|
|
||||||
# 返回
|
# 去除不在站点范围内的缓存种子
|
||||||
|
if sites and torrents_cache:
|
||||||
|
torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains}
|
||||||
return torrents_cache
|
return torrents_cache
|
||||||
|
|
||||||
def __renew_rss_url(self, domain: str, site: dict):
|
def __renew_rss_url(self, domain: str, site: dict):
|
||||||
@@ -246,10 +258,14 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
# 发送消息
|
# 发送消息
|
||||||
self.post_message(
|
self.post_message(
|
||||||
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
|
||||||
|
link=settings.MP_DOMAIN('#/site'))
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.post_message(
|
self.post_message(
|
||||||
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
|
||||||
|
link=settings.MP_DOMAIN('#/site')))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"站点 {domain} RSS链接自动获取失败:{str(e)} - {traceback.format_exc()}")
|
logger.error(f"站点 {domain} RSS链接自动获取失败:{str(e)} - {traceback.format_exc()}")
|
||||||
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
|
||||||
|
link=settings.MP_DOMAIN('#/site')))
|
||||||
|
|||||||
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
|
||||||
|
|||||||
348
app/command.py
348
app/command.py
@@ -1,348 +0,0 @@
|
|||||||
import importlib
|
|
||||||
import threading
|
|
||||||
import traceback
|
|
||||||
from threading import Thread
|
|
||||||
from typing import Any, Union, Dict
|
|
||||||
|
|
||||||
from app.chain import ChainBase
|
|
||||||
from app.chain.download import DownloadChain
|
|
||||||
from app.chain.site import SiteChain
|
|
||||||
from app.chain.subscribe import SubscribeChain
|
|
||||||
from app.chain.system import SystemChain
|
|
||||||
from app.chain.transfer import TransferChain
|
|
||||||
from app.core.event import Event as ManagerEvent
|
|
||||||
from app.core.event import eventmanager, EventManager
|
|
||||||
from app.core.plugin import PluginManager
|
|
||||||
from app.helper.thread import ThreadHelper
|
|
||||||
from app.log import logger
|
|
||||||
from app.scheduler import Scheduler
|
|
||||||
from app.schemas import Notification
|
|
||||||
from app.schemas.types import EventType, MessageChannel
|
|
||||||
from app.utils.object import ObjectUtils
|
|
||||||
from app.utils.singleton import Singleton
|
|
||||||
|
|
||||||
|
|
||||||
class CommandChian(ChainBase):
|
|
||||||
"""
|
|
||||||
插件处理链
|
|
||||||
"""
|
|
||||||
|
|
||||||
def process(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Command(metaclass=Singleton):
|
|
||||||
"""
|
|
||||||
全局命令管理,消费事件
|
|
||||||
"""
|
|
||||||
# 内建命令
|
|
||||||
_commands = {}
|
|
||||||
|
|
||||||
# 退出事件
|
|
||||||
_event = threading.Event()
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
# 事件管理器
|
|
||||||
self.eventmanager = EventManager()
|
|
||||||
# 插件管理器
|
|
||||||
self.pluginmanager = PluginManager()
|
|
||||||
# 处理链
|
|
||||||
self.chain = CommandChian()
|
|
||||||
# 定时服务管理
|
|
||||||
self.scheduler = Scheduler()
|
|
||||||
# 线程管理器
|
|
||||||
self.threader = ThreadHelper()
|
|
||||||
# 内置命令
|
|
||||||
self._commands = {
|
|
||||||
"/cookiecloud": {
|
|
||||||
"id": "cookiecloud",
|
|
||||||
"type": "scheduler",
|
|
||||||
"description": "同步站点",
|
|
||||||
"category": "站点"
|
|
||||||
},
|
|
||||||
"/sites": {
|
|
||||||
"func": SiteChain().remote_list,
|
|
||||||
"description": "查询站点",
|
|
||||||
"category": "站点",
|
|
||||||
"data": {}
|
|
||||||
},
|
|
||||||
"/site_cookie": {
|
|
||||||
"func": SiteChain().remote_cookie,
|
|
||||||
"description": "更新站点Cookie",
|
|
||||||
"data": {}
|
|
||||||
},
|
|
||||||
"/site_enable": {
|
|
||||||
"func": SiteChain().remote_enable,
|
|
||||||
"description": "启用站点",
|
|
||||||
"data": {}
|
|
||||||
},
|
|
||||||
"/site_disable": {
|
|
||||||
"func": SiteChain().remote_disable,
|
|
||||||
"description": "禁用站点",
|
|
||||||
"data": {}
|
|
||||||
},
|
|
||||||
"/mediaserver_sync": {
|
|
||||||
"id": "mediaserver_sync",
|
|
||||||
"type": "scheduler",
|
|
||||||
"description": "同步媒体服务器",
|
|
||||||
"category": "管理"
|
|
||||||
},
|
|
||||||
"/subscribes": {
|
|
||||||
"func": SubscribeChain().remote_list,
|
|
||||||
"description": "查询订阅",
|
|
||||||
"category": "订阅",
|
|
||||||
"data": {}
|
|
||||||
},
|
|
||||||
"/subscribe_refresh": {
|
|
||||||
"id": "subscribe_refresh",
|
|
||||||
"type": "scheduler",
|
|
||||||
"description": "刷新订阅",
|
|
||||||
"category": "订阅"
|
|
||||||
},
|
|
||||||
"/subscribe_search": {
|
|
||||||
"id": "subscribe_search",
|
|
||||||
"type": "scheduler",
|
|
||||||
"description": "搜索订阅",
|
|
||||||
"category": "订阅"
|
|
||||||
},
|
|
||||||
"/subscribe_delete": {
|
|
||||||
"func": SubscribeChain().remote_delete,
|
|
||||||
"description": "删除订阅",
|
|
||||||
"data": {}
|
|
||||||
},
|
|
||||||
"/subscribe_tmdb": {
|
|
||||||
"id": "subscribe_tmdb",
|
|
||||||
"type": "scheduler",
|
|
||||||
"description": "订阅元数据更新"
|
|
||||||
},
|
|
||||||
"/downloading": {
|
|
||||||
"func": DownloadChain().remote_downloading,
|
|
||||||
"description": "正在下载",
|
|
||||||
"category": "管理",
|
|
||||||
"data": {}
|
|
||||||
},
|
|
||||||
"/transfer": {
|
|
||||||
"id": "transfer",
|
|
||||||
"type": "scheduler",
|
|
||||||
"description": "下载文件整理",
|
|
||||||
"category": "管理"
|
|
||||||
},
|
|
||||||
"/redo": {
|
|
||||||
"func": TransferChain().remote_transfer,
|
|
||||||
"description": "手动整理",
|
|
||||||
"data": {}
|
|
||||||
},
|
|
||||||
"/clear_cache": {
|
|
||||||
"func": SystemChain().remote_clear_cache,
|
|
||||||
"description": "清理缓存",
|
|
||||||
"category": "管理",
|
|
||||||
"data": {}
|
|
||||||
},
|
|
||||||
"/restart": {
|
|
||||||
"func": SystemChain().restart,
|
|
||||||
"description": "重启系统",
|
|
||||||
"category": "管理",
|
|
||||||
"data": {}
|
|
||||||
},
|
|
||||||
"/version": {
|
|
||||||
"func": SystemChain().version,
|
|
||||||
"description": "当前版本",
|
|
||||||
"category": "管理",
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# 汇总插件命令
|
|
||||||
plugin_commands = self.pluginmanager.get_plugin_commands()
|
|
||||||
for command in plugin_commands:
|
|
||||||
self.register(
|
|
||||||
cmd=command.get('cmd'),
|
|
||||||
func=Command.send_plugin_event,
|
|
||||||
desc=command.get('desc'),
|
|
||||||
category=command.get('category'),
|
|
||||||
data={
|
|
||||||
'etype': command.get('event'),
|
|
||||||
'data': command.get('data')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
# 广播注册命令菜单
|
|
||||||
self.chain.register_commands(commands=self.get_commands())
|
|
||||||
# 消息处理线程
|
|
||||||
self._thread = Thread(target=self.__run)
|
|
||||||
# 启动事件处理线程
|
|
||||||
self._thread.start()
|
|
||||||
# 重启msg
|
|
||||||
SystemChain().restart_finish()
|
|
||||||
|
|
||||||
def __run(self):
|
|
||||||
"""
|
|
||||||
事件处理线程
|
|
||||||
"""
|
|
||||||
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:
|
|
||||||
try:
|
|
||||||
names = handler.__qualname__.split(".")
|
|
||||||
[class_name, method_name] = names
|
|
||||||
if class_name in self.pluginmanager.get_plugin_ids():
|
|
||||||
# 插件事件
|
|
||||||
self.threader.submit(
|
|
||||||
self.pluginmanager.run_plugin_method,
|
|
||||||
class_name, method_name, event
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# 检查全局变量中是否存在
|
|
||||||
if class_name not in globals():
|
|
||||||
# 导入模块,除了插件和Command本身,只有chain能响应事件
|
|
||||||
module = importlib.import_module(
|
|
||||||
f"app.chain.{class_name[:-5].lower()}"
|
|
||||||
)
|
|
||||||
class_obj = getattr(module, class_name)()
|
|
||||||
else:
|
|
||||||
# 通过类名创建类实例
|
|
||||||
class_obj = globals()[class_name]()
|
|
||||||
# 检查类是否存在并调用方法
|
|
||||||
if hasattr(class_obj, method_name):
|
|
||||||
self.threader.submit(
|
|
||||||
getattr(class_obj, method_name),
|
|
||||||
event
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
|
||||||
|
|
||||||
def __run_command(self, command: Dict[str, any],
|
|
||||||
data_str: str = "",
|
|
||||||
channel: MessageChannel = None, userid: Union[str, int] = None):
|
|
||||||
"""
|
|
||||||
运行定时服务
|
|
||||||
"""
|
|
||||||
if command.get("type") == "scheduler":
|
|
||||||
# 定时服务
|
|
||||||
if userid:
|
|
||||||
self.chain.post_message(
|
|
||||||
Notification(
|
|
||||||
channel=channel,
|
|
||||||
title=f"开始执行 {command.get('description')} ...",
|
|
||||||
userid=userid
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 执行定时任务
|
|
||||||
self.scheduler.start(job_id=command.get("id"))
|
|
||||||
|
|
||||||
if userid:
|
|
||||||
self.chain.post_message(
|
|
||||||
Notification(
|
|
||||||
channel=channel,
|
|
||||||
title=f"{command.get('description')} 执行完成",
|
|
||||||
userid=userid
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 命令
|
|
||||||
cmd_data = command['data'] if command.get('data') else {}
|
|
||||||
args_num = ObjectUtils.arguments(command['func'])
|
|
||||||
if args_num > 0:
|
|
||||||
if cmd_data:
|
|
||||||
# 有内置参数直接使用内置参数
|
|
||||||
data = cmd_data.get("data") or {}
|
|
||||||
data['channel'] = channel
|
|
||||||
data['user'] = userid
|
|
||||||
cmd_data['data'] = data
|
|
||||||
command['func'](**cmd_data)
|
|
||||||
elif args_num == 2:
|
|
||||||
# 没有输入参数,只输入渠道和用户ID
|
|
||||||
command['func'](channel, userid)
|
|
||||||
elif args_num > 2:
|
|
||||||
# 多个输入参数:用户输入、用户ID
|
|
||||||
command['func'](data_str, channel, userid)
|
|
||||||
else:
|
|
||||||
# 没有参数
|
|
||||||
command['func']()
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""
|
|
||||||
停止事件处理线程
|
|
||||||
"""
|
|
||||||
self._event.set()
|
|
||||||
try:
|
|
||||||
self._thread.join()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
|
|
||||||
|
|
||||||
def get_commands(self):
|
|
||||||
"""
|
|
||||||
获取命令列表
|
|
||||||
"""
|
|
||||||
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:
|
|
||||||
"""
|
|
||||||
获取命令
|
|
||||||
"""
|
|
||||||
return self._commands.get(cmd, {})
|
|
||||||
|
|
||||||
def execute(self, cmd: str, data_str: str = "",
|
|
||||||
channel: MessageChannel = None, userid: Union[str, int] = None) -> None:
|
|
||||||
"""
|
|
||||||
执行命令
|
|
||||||
"""
|
|
||||||
command = self.get(cmd)
|
|
||||||
if command:
|
|
||||||
try:
|
|
||||||
if userid:
|
|
||||||
logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...")
|
|
||||||
else:
|
|
||||||
logger.info(f"开始执行:{command.get('description')} ...")
|
|
||||||
|
|
||||||
# 执行命令
|
|
||||||
self.__run_command(command, data_str=data_str,
|
|
||||||
channel=channel, userid=userid)
|
|
||||||
|
|
||||||
if userid:
|
|
||||||
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
|
|
||||||
else:
|
|
||||||
logger.info(f"{command.get('description')} 执行完成")
|
|
||||||
except Exception as err:
|
|
||||||
logger.error(f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def send_plugin_event(etype: EventType, data: dict) -> None:
|
|
||||||
"""
|
|
||||||
发送插件命令
|
|
||||||
"""
|
|
||||||
EventManager().send_event(etype, data)
|
|
||||||
|
|
||||||
@eventmanager.register(EventType.CommandExcute)
|
|
||||||
def command_event(self, event: ManagerEvent) -> None:
|
|
||||||
"""
|
|
||||||
注册命令执行事件
|
|
||||||
event_data: {
|
|
||||||
"cmd": "/xxx args"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
# 命令参数
|
|
||||||
event_str = event.event_data.get('cmd')
|
|
||||||
# 消息渠道
|
|
||||||
event_channel = event.event_data.get('channel')
|
|
||||||
# 消息用户
|
|
||||||
event_user = event.event_data.get('user')
|
|
||||||
if event_str:
|
|
||||||
cmd = event_str.split()[0]
|
|
||||||
args = " ".join(event_str.split()[1:])
|
|
||||||
if self.get(cmd):
|
|
||||||
self.execute(cmd, args, event_channel, event_user)
|
|
||||||
@@ -1,24 +1,46 @@
|
|||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
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
|
||||||
|
APP_DOMAIN: str = ""
|
||||||
# API路径
|
# API路径
|
||||||
API_V1_STR: str = "/api/v1"
|
API_V1_STR: str = "/api/v1"
|
||||||
|
# 前端资源路径
|
||||||
|
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监听地址
|
||||||
@@ -31,22 +53,40 @@ class Settings(BaseSettings):
|
|||||||
DEBUG: bool = False
|
DEBUG: bool = False
|
||||||
# 是否开发模式
|
# 是否开发模式
|
||||||
DEV: bool = False
|
DEV: bool = False
|
||||||
|
# 是否在控制台输出 SQL 语句,默认关闭
|
||||||
|
DB_ECHO: bool = False
|
||||||
|
# 数据库连接池类型,QueuePool, NullPool
|
||||||
|
DB_POOL_TYPE: str = "QueuePool"
|
||||||
|
# 是否在获取连接时进行预先 ping 操作,默认关闭
|
||||||
|
DB_POOL_PRE_PING: bool = False
|
||||||
|
# 数据库连接池的大小,默认 100
|
||||||
|
DB_POOL_SIZE: int = 100
|
||||||
|
# 数据库连接的回收时间(秒),默认 1800 秒
|
||||||
|
DB_POOL_RECYCLE: int = 1800
|
||||||
|
# 数据库连接池获取连接的超时时间(秒),默认 60 秒
|
||||||
|
DB_POOL_TIMEOUT: int = 60
|
||||||
|
# 数据库连接池最大溢出连接数,默认 500
|
||||||
|
DB_MAX_OVERFLOW: int = 500
|
||||||
|
# SQLite 的 busy_timeout 参数,默认为 60 秒
|
||||||
|
DB_TIMEOUT: int = 60
|
||||||
# 配置文件目录
|
# 配置文件目录
|
||||||
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,多个用,分隔
|
||||||
|
SEARCH_SOURCE: str = "themoviedb,douban,bangumi"
|
||||||
# 媒体识别来源 themoviedb/douban
|
# 媒体识别来源 themoviedb/douban
|
||||||
RECOGNIZE_SOURCE: str = "themoviedb"
|
RECOGNIZE_SOURCE: str = "themoviedb"
|
||||||
# 刮削来源 themoviedb/douban
|
# 刮削来源 themoviedb/douban
|
||||||
SCRAP_SOURCE: str = "themoviedb"
|
SCRAP_SOURCE: str = "themoviedb"
|
||||||
# 刮削入库的媒体文件
|
|
||||||
SCRAP_METADATA: bool = True
|
|
||||||
# 新增已入库媒体是否跟随TMDB信息变化
|
# 新增已入库媒体是否跟随TMDB信息变化
|
||||||
SCRAP_FOLLOW_TMDB: bool = True
|
SCRAP_FOLLOW_TMDB: bool = True
|
||||||
# TMDB图片地址
|
# TMDB图片地址
|
||||||
@@ -61,132 +101,62 @@ 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',
|
||||||
'.mpg', '.wmv', '.3gp', '.asf',
|
'.mpg', '.wmv', '.3gp', '.asf',
|
||||||
'.m4v', '.flv', '.m2ts', '.strm',
|
'.m4v', '.flv', '.m2ts', '.strm',
|
||||||
'.tp']
|
'.tp', '.f4v']
|
||||||
# 支持的字幕文件后缀格式
|
# 支持的字幕文件后缀格式
|
||||||
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa', '.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,多个通知渠道用,分隔
|
# 站点数据刷新间隔(小时)
|
||||||
MESSAGER: str = "telegram"
|
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_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
|
|
||||||
# 下载站点字幕
|
# 下载站点字幕
|
||||||
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"
|
|
||||||
# CookieCloud是否启动本地服务
|
# CookieCloud是否启动本地服务
|
||||||
COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False
|
COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False
|
||||||
# CookieCloud服务器地址
|
# CookieCloud服务器地址
|
||||||
@@ -197,22 +167,10 @@ class Settings(BaseSettings):
|
|||||||
COOKIECLOUD_PASSWORD: Optional[str] = None
|
COOKIECLOUD_PASSWORD: Optional[str] = None
|
||||||
# CookieCloud同步间隔(分钟)
|
# CookieCloud同步间隔(分钟)
|
||||||
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
||||||
# OCR服务器地址
|
# CookieCloud同步黑名单,多个域名,分割
|
||||||
OCR_HOST: str = "https://movie-pilot.org"
|
COOKIECLOUD_BLACKLIST: Optional[str] = None
|
||||||
# CookieCloud对应的浏览器UA
|
# CookieCloud对应的浏览器UA
|
||||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
||||||
# 媒体库目录,多个目录使用,分隔
|
|
||||||
LIBRARY_PATH: Optional[str] = None
|
|
||||||
# 电影媒体库目录名
|
|
||||||
LIBRARY_MOVIE_NAME: str = "电影"
|
|
||||||
# 电视剧媒体库目录名
|
|
||||||
LIBRARY_TV_NAME: str = "电视剧"
|
|
||||||
# 动漫媒体库目录名,不设置时使用电视剧目录
|
|
||||||
LIBRARY_ANIME_NAME: Optional[str] = None
|
|
||||||
# 二级分类
|
|
||||||
LIBRARY_CATEGORY: bool = True
|
|
||||||
# 电视剧动漫的分类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 %}" \
|
||||||
@@ -222,35 +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"
|
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/
|
||||||
AUTO_UPDATE_RESOURCE: bool = True
|
GITHUB_PROXY: Optional[str] = ''
|
||||||
# 元数据识别缓存过期时间(小时)
|
# pip镜像站点,格式:https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
META_CACHE_EXPIRE: int = 0
|
PIP_PROXY: Optional[str] = ''
|
||||||
# 是否启用DOH解析域名
|
# 指定的仓库Github token,多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****
|
||||||
DOH_ENABLE: bool = True
|
REPO_GITHUB_TOKEN: Optional[str] = None
|
||||||
# 搜索多个名称
|
# 大内存模式
|
||||||
SEARCH_MULTIPLE_NAME: bool = True
|
BIG_MEMORY_MODE: bool = False
|
||||||
|
# 全局图片缓存,将媒体图片缓存到本地
|
||||||
|
GLOBAL_IMAGE_CACHE: bool = False
|
||||||
|
# 允许的图片缓存域名
|
||||||
|
SECURITY_IMAGE_DOMAINS: List[str] = ["image.tmdb.org", "static-mdb.v.geilijiasu.com", "doubanio.com", "lain.bgm.tv",
|
||||||
|
"raw.githubusercontent.com", "github.com"]
|
||||||
|
# 允许的图片文件后缀格式
|
||||||
|
SECURITY_IMAGE_SUFFIXES: List[str] = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"]
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings, ConfigModel):
|
||||||
|
"""
|
||||||
|
系统配置类
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
case_sensitive = True
|
||||||
|
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()
|
||||||
|
|
||||||
@validator("SUBSCRIBE_RSS_INTERVAL",
|
|
||||||
"COOKIECLOUD_INTERVAL",
|
|
||||||
"MEDIASERVER_SYNC_INTERVAL",
|
|
||||||
"META_CACHE_EXPIRE",
|
|
||||||
pre=True, always=True)
|
|
||||||
def convert_int(cls, value):
|
|
||||||
if not value:
|
|
||||||
return 0
|
|
||||||
try:
|
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):
|
||||||
@@ -270,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]
|
||||||
@@ -281,7 +426,7 @@ class Settings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def LOG_PATH(self):
|
def LOG_PATH(self):
|
||||||
return self.CONFIG_PATH / "logs"
|
return self.CONFIG_PATH / "logs"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def COOKIE_PATH(self):
|
def COOKIE_PATH(self):
|
||||||
return self.CONFIG_PATH / "cookies"
|
return self.CONFIG_PATH / "cookies"
|
||||||
@@ -322,48 +467,6 @@ class Settings(BaseSettings):
|
|||||||
"server": self.PROXY_HOST
|
"server": self.PROXY_HOST
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
|
||||||
def LIBRARY_PATHS(self) -> List[Path]:
|
|
||||||
if self.LIBRARY_PATH:
|
|
||||||
return [Path(path) for path in self.LIBRARY_PATH.split(",")]
|
|
||||||
return [self.CONFIG_PATH / "library"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def SAVE_PATH(self) -> Path:
|
|
||||||
"""
|
|
||||||
获取下载保存目录
|
|
||||||
"""
|
|
||||||
if self.DOWNLOAD_PATH:
|
|
||||||
return Path(self.DOWNLOAD_PATH)
|
|
||||||
return self.CONFIG_PATH / "downloads"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def SAVE_MOVIE_PATH(self) -> Path:
|
|
||||||
"""
|
|
||||||
获取电影下载保存目录
|
|
||||||
"""
|
|
||||||
if self.DOWNLOAD_MOVIE_PATH:
|
|
||||||
return Path(self.DOWNLOAD_MOVIE_PATH)
|
|
||||||
return self.SAVE_PATH
|
|
||||||
|
|
||||||
@property
|
|
||||||
def SAVE_TV_PATH(self) -> Path:
|
|
||||||
"""
|
|
||||||
获取电视剧下载保存目录
|
|
||||||
"""
|
|
||||||
if self.DOWNLOAD_TV_PATH:
|
|
||||||
return Path(self.DOWNLOAD_TV_PATH)
|
|
||||||
return self.SAVE_PATH
|
|
||||||
|
|
||||||
@property
|
|
||||||
def SAVE_ANIME_PATH(self) -> Path:
|
|
||||||
"""
|
|
||||||
获取动漫下载保存目录
|
|
||||||
"""
|
|
||||||
if self.DOWNLOAD_ANIME_PATH:
|
|
||||||
return Path(self.DOWNLOAD_ANIME_PATH)
|
|
||||||
return self.SAVE_TV_PATH
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def GITHUB_HEADERS(self):
|
def GITHUB_HEADERS(self):
|
||||||
"""
|
"""
|
||||||
@@ -375,47 +478,88 @@ class Settings(BaseSettings):
|
|||||||
}
|
}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
def REPO_GITHUB_HEADERS(self, repo: str = None):
|
||||||
|
"""
|
||||||
|
Github指定的仓库请求头
|
||||||
|
:param repo: 指定的仓库名称,格式为 "user/repo"。如果为空,或者没有找到指定仓库请求头,则返回默认的请求头信息
|
||||||
|
:return: Github请求头
|
||||||
|
"""
|
||||||
|
# 如果没有传入指定的仓库名称,或没有配置指定的仓库Token,则返回默认的请求头信息
|
||||||
|
if not repo or not self.REPO_GITHUB_TOKEN:
|
||||||
|
return self.GITHUB_HEADERS
|
||||||
|
headers = {}
|
||||||
|
# 格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****
|
||||||
|
token_pairs = self.REPO_GITHUB_TOKEN.split(",")
|
||||||
|
for token_pair in token_pairs:
|
||||||
|
try:
|
||||||
|
parts = token_pair.split(":")
|
||||||
|
if len(parts) != 2:
|
||||||
|
print(f"无效的令牌格式: {token_pair}")
|
||||||
|
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 DEFAULT_DOWNLOADER(self):
|
def VAPID(self):
|
||||||
"""
|
return {
|
||||||
默认下载器
|
"subject": f"mailto:{self.SUPERUSER}@movie-pilot.org",
|
||||||
"""
|
"publicKey": "BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM",
|
||||||
if not self.DOWNLOADER:
|
"privateKey": "JTixnYY0vEw97t9uukfO3UWKfHKJdT5kCQDiv3gu894"
|
||||||
|
}
|
||||||
|
|
||||||
|
def MP_DOMAIN(self, url: str = None):
|
||||||
|
if not self.APP_DOMAIN:
|
||||||
return None
|
return None
|
||||||
return self.DOWNLOADER.split(",")[0]
|
return UrlUtils.combine_url(host=self.APP_DOMAIN, path=url)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalVar(object):
|
||||||
|
"""
|
||||||
|
全局标识
|
||||||
|
"""
|
||||||
|
# 系统停止事件
|
||||||
|
STOP_EVENT: threading.Event = threading.Event()
|
||||||
|
# webpush订阅
|
||||||
|
SUBSCRIPTIONS: List[dict] = []
|
||||||
|
|
||||||
|
def stop_system(self):
|
||||||
|
"""
|
||||||
|
停止系统
|
||||||
|
"""
|
||||||
|
self.STOP_EVENT.set()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def DOWNLOADERS(self):
|
def is_system_stopped(self):
|
||||||
"""
|
"""
|
||||||
下载器列表
|
是否停止
|
||||||
"""
|
"""
|
||||||
if not self.DOWNLOADER:
|
return self.STOP_EVENT.is_set()
|
||||||
return []
|
|
||||||
return self.DOWNLOADER.split(",")
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def get_subscriptions(self):
|
||||||
super().__init__(**kwargs)
|
"""
|
||||||
with self.CONFIG_PATH as p:
|
获取webpush订阅
|
||||||
if not p.exists():
|
"""
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
return self.SUBSCRIPTIONS
|
||||||
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:
|
def push_subscription(self, subscription: dict):
|
||||||
case_sensitive = True
|
"""
|
||||||
|
添加webpush订阅
|
||||||
|
"""
|
||||||
|
self.SUBSCRIPTIONS.append(subscription)
|
||||||
|
|
||||||
|
|
||||||
settings = Settings(
|
# 实例化配置
|
||||||
_env_file=Settings().CONFIG_PATH / "app.env",
|
settings = Settings()
|
||||||
_env_file_encoding="utf-8"
|
|
||||||
)
|
# 全局标识
|
||||||
|
global_vars = GlobalVar()
|
||||||
|
|||||||
@@ -133,12 +133,18 @@ class TorrentInfo:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MediaInfo:
|
class MediaInfo:
|
||||||
|
# 来源:themoviedb、douban、bangumi
|
||||||
|
source: str = None
|
||||||
# 类型 电影、电视剧
|
# 类型 电影、电视剧
|
||||||
type: MediaType = None
|
type: MediaType = None
|
||||||
# 媒体标题
|
# 媒体标题
|
||||||
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
|
||||||
# 年份
|
# 年份
|
||||||
@@ -345,16 +351,18 @@ 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
|
||||||
|
|
||||||
if not info:
|
if not info:
|
||||||
return
|
return
|
||||||
|
# 来源
|
||||||
|
self.source = "themoviedb"
|
||||||
# 本体
|
# 本体
|
||||||
self.tmdb_info = info
|
self.tmdb_info = info
|
||||||
# 类型
|
# 类型
|
||||||
@@ -382,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:
|
||||||
@@ -440,6 +452,8 @@ class MediaInfo:
|
|||||||
"""
|
"""
|
||||||
if not info:
|
if not info:
|
||||||
return
|
return
|
||||||
|
# 来源
|
||||||
|
self.source = "douban"
|
||||||
# 本体
|
# 本体
|
||||||
self.douban_info = info
|
self.douban_info = info
|
||||||
# 豆瓣ID
|
# 豆瓣ID
|
||||||
@@ -448,10 +462,16 @@ class MediaInfo:
|
|||||||
if not self.type:
|
if not self.type:
|
||||||
if isinstance(info.get('media_type'), MediaType):
|
if isinstance(info.get('media_type'), MediaType):
|
||||||
self.type = info.get('media_type')
|
self.type = info.get('media_type')
|
||||||
elif info.get("type"):
|
elif info.get("subtype"):
|
||||||
self.type = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
|
self.type = MediaType.MOVIE if info.get("subtype") == "movie" else MediaType.TV
|
||||||
|
elif info.get("target_type"):
|
||||||
|
self.type = MediaType.MOVIE if info.get("target_type") == "movie" else MediaType.TV
|
||||||
elif info.get("type_name"):
|
elif info.get("type_name"):
|
||||||
self.type = MediaType(info.get("type_name"))
|
self.type = MediaType(info.get("type_name"))
|
||||||
|
elif info.get("uri"):
|
||||||
|
self.type = MediaType.MOVIE if "/movie/" in info.get("uri") else MediaType.TV
|
||||||
|
elif info.get("type") and info.get("type") in ["movie", "tv"]:
|
||||||
|
self.type = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
|
||||||
# 标题
|
# 标题
|
||||||
if not self.title:
|
if not self.title:
|
||||||
self.title = info.get("title")
|
self.title = info.get("title")
|
||||||
@@ -464,6 +484,8 @@ class MediaInfo:
|
|||||||
# 年份
|
# 年份
|
||||||
if not self.year:
|
if not self.year:
|
||||||
self.year = info.get("year")[:4] if info.get("year") else None
|
self.year = info.get("year")[:4] if info.get("year") else None
|
||||||
|
if not self.year and info.get("extra"):
|
||||||
|
self.year = info.get("extra").get("year")
|
||||||
# 识别标题中的季
|
# 识别标题中的季
|
||||||
meta = MetaInfo(info.get("title"))
|
meta = MetaInfo(info.get("title"))
|
||||||
# 季
|
# 季
|
||||||
@@ -493,14 +515,24 @@ class MediaInfo:
|
|||||||
self.release_date = match.group()
|
self.release_date = match.group()
|
||||||
# 海报
|
# 海报
|
||||||
if not self.poster_path:
|
if not self.poster_path:
|
||||||
self.poster_path = info.get("pic", {}).get("large")
|
if info.get("pic"):
|
||||||
|
self.poster_path = info.get("pic", {}).get("large")
|
||||||
if not self.poster_path and info.get("cover_url"):
|
if not self.poster_path and info.get("cover_url"):
|
||||||
self.poster_path = info.get("cover_url")
|
# imageView2/0/q/80/w/9999/h/120/format/webp -> imageView2/1/w/500/h/750/format/webp
|
||||||
|
self.poster_path = re.sub(r'imageView2/\d/q/\d+/w/\d+/h/\d+/format/webp', 'imageView2/1/w/500/h/750/format/webp', info.get("cover_url"))
|
||||||
if not self.poster_path and info.get("cover"):
|
if not self.poster_path and info.get("cover"):
|
||||||
self.poster_path = info.get("cover").get("url")
|
if info.get("cover").get("url"):
|
||||||
|
self.poster_path = info.get("cover").get("url")
|
||||||
|
else:
|
||||||
|
self.poster_path = info.get("cover").get("large", {}).get("url")
|
||||||
# 简介
|
# 简介
|
||||||
if not self.overview:
|
if not self.overview:
|
||||||
self.overview = info.get("intro") or info.get("card_subtitle") or ""
|
self.overview = info.get("intro") or info.get("card_subtitle") or ""
|
||||||
|
if not self.overview:
|
||||||
|
if info.get("extra", {}).get("info"):
|
||||||
|
extra_info = info.get("extra").get("info")
|
||||||
|
if extra_info:
|
||||||
|
self.overview = ",".join([":".join(item) for item in extra_info])
|
||||||
# 从简介中提取年份
|
# 从简介中提取年份
|
||||||
if self.overview and not self.year:
|
if self.overview and not self.year:
|
||||||
match = re.search(r'\d{4}', self.overview)
|
match = re.search(r'\d{4}', self.overview)
|
||||||
@@ -552,6 +584,8 @@ class MediaInfo:
|
|||||||
"""
|
"""
|
||||||
if not info:
|
if not info:
|
||||||
return
|
return
|
||||||
|
# 来源
|
||||||
|
self.source = "bangumi"
|
||||||
# 本体
|
# 本体
|
||||||
self.bangumi_info = info
|
self.bangumi_info = info
|
||||||
# 豆瓣ID
|
# 豆瓣ID
|
||||||
@@ -586,7 +620,10 @@ class MediaInfo:
|
|||||||
self.year = self.release_date[:4] if self.release_date else None
|
self.year = self.release_date[:4] if self.release_date else None
|
||||||
# 海报
|
# 海报
|
||||||
if not self.poster_path:
|
if not self.poster_path:
|
||||||
self.poster_path = info.get("images", {}).get("large")
|
if info.get("images"):
|
||||||
|
self.poster_path = info.get("images", {}).get("large")
|
||||||
|
if not self.poster_path and info.get("image"):
|
||||||
|
self.poster_path = info.get("image")
|
||||||
# 简介
|
# 简介
|
||||||
if not self.overview:
|
if not self.overview:
|
||||||
self.overview = info.get("summary")
|
self.overview = info.get("summary")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class MetaAnime(MetaBase):
|
|||||||
识别动漫
|
识别动漫
|
||||||
"""
|
"""
|
||||||
_anime_no_words = ['CHS&CHT', 'MP4', 'GB MP4', 'WEB-DL']
|
_anime_no_words = ['CHS&CHT', 'MP4', 'GB MP4', 'WEB-DL']
|
||||||
_name_nostring_re = r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}"
|
_name_nostring_re = r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}|\s+GB"
|
||||||
|
|
||||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||||
super().__init__(title, subtitle, isfile)
|
super().__init__(title, subtitle, isfile)
|
||||||
@@ -32,8 +32,6 @@ class MetaAnime(MetaBase):
|
|||||||
if anitopy_info:
|
if anitopy_info:
|
||||||
# 名称
|
# 名称
|
||||||
name = anitopy_info.get("anime_title")
|
name = anitopy_info.get("anime_title")
|
||||||
if name and name.find("/") != -1:
|
|
||||||
name = name.split("/")[-1].strip()
|
|
||||||
if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):
|
if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):
|
||||||
anitopy_info = anitopy.parse("[ANIME]" + title)
|
anitopy_info = anitopy.parse("[ANIME]" + title)
|
||||||
if anitopy_info:
|
if anitopy_info:
|
||||||
@@ -44,28 +42,45 @@ class MetaAnime(MetaBase):
|
|||||||
name = name_match.group(1).strip()
|
name = name_match.group(1).strip()
|
||||||
# 拆份中英文名称
|
# 拆份中英文名称
|
||||||
if name:
|
if name:
|
||||||
lastword_type = ""
|
_split_flag = True
|
||||||
for word in name.split():
|
# 按/拆分中英文
|
||||||
if not word:
|
if name.find("/") != -1:
|
||||||
continue
|
names = name.split("/")
|
||||||
if word.endswith(']'):
|
if StringUtils.is_chinese(names[0]):
|
||||||
word = word[:-1]
|
self.cn_name = names[0]
|
||||||
if word.isdigit():
|
if len(names) > 1:
|
||||||
if lastword_type == "cn":
|
self.en_name = names[1]
|
||||||
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
_split_flag = False
|
||||||
elif lastword_type == "en":
|
elif StringUtils.is_chinese(names[-1]):
|
||||||
self.en_name = "%s %s" % (self.en_name or "", word)
|
self.cn_name = names[-1]
|
||||||
elif StringUtils.is_chinese(word):
|
if len(names) > 1:
|
||||||
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
self.en_name = names[0]
|
||||||
lastword_type = "cn"
|
_split_flag = False
|
||||||
else:
|
else:
|
||||||
self.en_name = "%s %s" % (self.en_name or "", word)
|
name = names[-1]
|
||||||
lastword_type = "en"
|
# 拆分中英文
|
||||||
|
if _split_flag:
|
||||||
|
lastword_type = ""
|
||||||
|
for word in name.split():
|
||||||
|
if not word:
|
||||||
|
continue
|
||||||
|
if word.endswith(']'):
|
||||||
|
word = word[:-1]
|
||||||
|
if word.isdigit():
|
||||||
|
if lastword_type == "cn":
|
||||||
|
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
||||||
|
elif lastword_type == "en":
|
||||||
|
self.en_name = "%s %s" % (self.en_name or "", word)
|
||||||
|
elif StringUtils.is_chinese(word):
|
||||||
|
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
||||||
|
lastword_type = "cn"
|
||||||
|
else:
|
||||||
|
self.en_name = "%s %s" % (self.en_name or "", word)
|
||||||
|
lastword_type = "en"
|
||||||
if self.cn_name:
|
if self.cn_name:
|
||||||
_, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)
|
_, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)
|
||||||
if self.cn_name:
|
if self.cn_name:
|
||||||
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)
|
||||||
|
|||||||
@@ -67,16 +67,18 @@ class MetaBase(object):
|
|||||||
|
|
||||||
# 副标题解析
|
# 副标题解析
|
||||||
_subtitle_flag = False
|
_subtitle_flag = False
|
||||||
|
_title_episodel_re = r"Episode\s+(\d{1,4})"
|
||||||
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
|
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
|
||||||
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季|([0-9一二三四五六七八九十]+)\s*季\s*全"
|
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季|([0-9一二三四五六七八九十]+)\s*季\s*全"
|
||||||
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP\-]+)\s*[集话話期幕](?!\s*[全共])"
|
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP]+)\s*[集话話期幕](?!\s*[全共])"
|
||||||
|
_subtitle_episode_between_re = r"[第]*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]?\s*-\s*第*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
||||||
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
||||||
|
|
||||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||||
if not title:
|
if not title:
|
||||||
return
|
return
|
||||||
self.org_string = title
|
self.org_string = title.strip() if title else None
|
||||||
self.subtitle = subtitle
|
self.subtitle = subtitle.strip() if subtitle else None
|
||||||
self.isfile = isfile
|
self.isfile = isfile
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -110,7 +112,39 @@ class MetaBase(object):
|
|||||||
if not title_text:
|
if not title_text:
|
||||||
return
|
return
|
||||||
title_text = f" {title_text} "
|
title_text = f" {title_text} "
|
||||||
if re.search(r'[全第季集话話期幕]', title_text, re.IGNORECASE):
|
if re.search(r"%s" % self._title_episodel_re, title_text, re.IGNORECASE):
|
||||||
|
episode_str = re.search(r'%s' % self._title_episodel_re, title_text, re.IGNORECASE)
|
||||||
|
if episode_str:
|
||||||
|
try:
|
||||||
|
episode = int(episode_str.group(1))
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
if episode >= 10000:
|
||||||
|
return
|
||||||
|
if self.begin_episode is None:
|
||||||
|
self.begin_episode = episode
|
||||||
|
self.total_episode = 1
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self._subtitle_flag = True
|
||||||
|
elif re.search(r'[全第季集话話期幕]', title_text, re.IGNORECASE):
|
||||||
|
# 全x季 x季全
|
||||||
|
season_all_str = re.search(r"%s" % self._subtitle_season_all_re, title_text, re.IGNORECASE)
|
||||||
|
if season_all_str:
|
||||||
|
season_all = season_all_str.group(1)
|
||||||
|
if not season_all:
|
||||||
|
season_all = season_all_str.group(2)
|
||||||
|
if season_all and self.begin_season is None and self.begin_episode is None:
|
||||||
|
try:
|
||||||
|
self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart'))
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
self.begin_season = 1
|
||||||
|
self.end_season = self.total_season
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self._subtitle_flag = True
|
||||||
|
return
|
||||||
# 第x季
|
# 第x季
|
||||||
season_str = re.search(r'%s' % self._subtitle_season_re, title_text, re.IGNORECASE)
|
season_str = re.search(r'%s' % self._subtitle_season_re, title_text, re.IGNORECASE)
|
||||||
if season_str:
|
if season_str:
|
||||||
@@ -146,6 +180,37 @@ class MetaBase(object):
|
|||||||
self.total_season = (self.end_season - self.begin_season) + 1
|
self.total_season = (self.end_season - self.begin_season) + 1
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
self._subtitle_flag = True
|
self._subtitle_flag = True
|
||||||
|
# 第x-x集 第x集-x集
|
||||||
|
episode_between_str = re.search(r'%s' % self._subtitle_episode_between_re, title_text, re.IGNORECASE)
|
||||||
|
if episode_between_str:
|
||||||
|
episodes = episode_between_str.groups()
|
||||||
|
if episodes:
|
||||||
|
begin_episode = episodes[0]
|
||||||
|
end_episode = episodes[1]
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
begin_episode = int(cn2an.cn2an(begin_episode.strip(), mode='smart'))
|
||||||
|
end_episode = int(cn2an.cn2an(end_episode.strip(), mode='smart'))
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
if begin_episode and begin_episode >= 10000:
|
||||||
|
return
|
||||||
|
if end_episode and end_episode >= 10000:
|
||||||
|
return
|
||||||
|
if self.begin_episode is None and isinstance(begin_episode, int):
|
||||||
|
self.begin_episode = begin_episode
|
||||||
|
self.total_episode = 1
|
||||||
|
if self.begin_episode is not None \
|
||||||
|
and self.end_episode is None \
|
||||||
|
and isinstance(end_episode, int) \
|
||||||
|
and end_episode != self.begin_episode:
|
||||||
|
self.end_episode = end_episode
|
||||||
|
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self._subtitle_flag = True
|
||||||
|
return
|
||||||
# 第x集
|
# 第x集
|
||||||
episode_str = re.search(r'%s' % self._subtitle_episode_re, title_text, re.IGNORECASE)
|
episode_str = re.search(r'%s' % self._subtitle_episode_re, title_text, re.IGNORECASE)
|
||||||
if episode_str:
|
if episode_str:
|
||||||
@@ -181,6 +246,7 @@ class MetaBase(object):
|
|||||||
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
self._subtitle_flag = True
|
self._subtitle_flag = True
|
||||||
|
return
|
||||||
# x集全
|
# x集全
|
||||||
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
|
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
|
||||||
if episode_all_str:
|
if episode_all_str:
|
||||||
@@ -197,22 +263,7 @@ class MetaBase(object):
|
|||||||
self.end_episode = None
|
self.end_episode = None
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
self._subtitle_flag = True
|
self._subtitle_flag = True
|
||||||
# 全x季 x季全
|
return
|
||||||
season_all_str = re.search(r"%s" % self._subtitle_season_all_re, title_text, re.IGNORECASE)
|
|
||||||
if season_all_str:
|
|
||||||
season_all = season_all_str.group(1)
|
|
||||||
if not season_all:
|
|
||||||
season_all = season_all_str.group(2)
|
|
||||||
if season_all and self.begin_season is None and self.begin_episode is None:
|
|
||||||
try:
|
|
||||||
self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart'))
|
|
||||||
except Exception as err:
|
|
||||||
logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
|
|
||||||
return
|
|
||||||
self.begin_season = 1
|
|
||||||
self.end_season = self.total_season
|
|
||||||
self.type = MediaType.TV
|
|
||||||
self._subtitle_flag = True
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def season(self) -> str:
|
def season(self) -> str:
|
||||||
@@ -240,7 +291,7 @@ class MetaBase(object):
|
|||||||
return self.season
|
return self.season
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def season_seq(self) -> str:
|
def season_seq(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -283,7 +334,7 @@ class MetaBase(object):
|
|||||||
str(self.end_episode).rjust(2, "0"))
|
str(self.end_episode).rjust(2, "0"))
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def episode_list(self) -> List[int]:
|
def episode_list(self) -> List[int]:
|
||||||
"""
|
"""
|
||||||
@@ -481,7 +532,7 @@ class MetaBase(object):
|
|||||||
self.end_episode = end
|
self.end_episode = end
|
||||||
if self.begin_episode and self.end_episode:
|
if self.begin_episode and self.end_episode:
|
||||||
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
||||||
|
|
||||||
def merge(self, meta: Self):
|
def merge(self, meta: Self):
|
||||||
"""
|
"""
|
||||||
全并Meta信息
|
全并Meta信息
|
||||||
@@ -499,13 +550,13 @@ class MetaBase(object):
|
|||||||
self.year = meta.year
|
self.year = meta.year
|
||||||
# 季
|
# 季
|
||||||
if (self.type == MediaType.TV
|
if (self.type == MediaType.TV
|
||||||
and not self.season):
|
and self.begin_season is None):
|
||||||
self.begin_season = meta.begin_season
|
self.begin_season = meta.begin_season
|
||||||
self.end_season = meta.end_season
|
self.end_season = meta.end_season
|
||||||
self.total_season = meta.total_season
|
self.total_season = meta.total_season
|
||||||
# 开始集
|
# 开始集
|
||||||
if (self.type == MediaType.TV
|
if (self.type == MediaType.TV
|
||||||
and not self.episode):
|
and self.begin_episode is None):
|
||||||
self.begin_episode = meta.begin_episode
|
self.begin_episode = meta.begin_episode
|
||||||
self.end_episode = meta.end_episode
|
self.end_episode = meta.end_episode
|
||||||
self.total_episode = meta.total_episode
|
self.total_episode = meta.total_episode
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from typing import Optional
|
||||||
|
|
||||||
|
from Pinyin2Hanzi import is_pinyin
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.meta.customization import CustomizationMatcher
|
from app.core.meta.customization import CustomizationMatcher
|
||||||
from app.core.meta.metabase import MetaBase
|
from app.core.meta.metabase import MetaBase
|
||||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||||
|
from app.schemas.types import MediaType
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
from app.utils.tokens import Tokens
|
from app.utils.tokens import Tokens
|
||||||
from app.schemas.types import MediaType
|
|
||||||
|
|
||||||
|
|
||||||
class MetaVideo(MetaBase):
|
class MetaVideo(MetaBase):
|
||||||
@@ -24,14 +26,14 @@ class MetaVideo(MetaBase):
|
|||||||
_source = ""
|
_source = ""
|
||||||
_effect = []
|
_effect = []
|
||||||
# 正则式区
|
# 正则式区
|
||||||
_season_re = r"S(\d{2})|^S(\d{1,2})$|S(\d{1,2})E"
|
_season_re = r"S(\d{3})|^S(\d{1,3})$|S(\d{1,3})E"
|
||||||
_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".*版|.*字幕"
|
||||||
_name_se_words = ['共', '第', '季', '集', '话', '話', '期']
|
_name_se_words = ['共', '第', '季', '集', '话', '話', '期']
|
||||||
_name_movie_words = ['剧场版', '劇場版', '电影版', '電影版']
|
_name_movie_words = ['剧场版', '劇場版', '电影版', '電影版']
|
||||||
@@ -39,19 +41,25 @@ class MetaVideo(MetaBase):
|
|||||||
r"|HBO$|\s+HBO|\d{1,2}th|\d{1,2}bit|NETFLIX|AMAZON|IMAX|^3D|\s+3D|^BBC\s+|\s+BBC|BBC$|DISNEY\+?|XXX|\s+DC$" \
|
r"|HBO$|\s+HBO|\d{1,2}th|\d{1,2}bit|NETFLIX|AMAZON|IMAX|^3D|\s+3D|^BBC\s+|\s+BBC|BBC$|DISNEY\+?|XXX|\s+DC$" \
|
||||||
r"|[第\s共]+[0-9一二三四五六七八九十\-\s]+季" \
|
r"|[第\s共]+[0-9一二三四五六七八九十\-\s]+季" \
|
||||||
r"|[第\s共]+[0-9一二三四五六七八九十百零\-\s]+[集话話]" \
|
r"|[第\s共]+[0-9一二三四五六七八九十百零\-\s]+[集话話]" \
|
||||||
r"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|蓝光|翡翠台|梦幻天堂·龙网|★?\d*月?新番" \
|
r"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|无水印|下载|蓝光|翡翠台|梦幻天堂·龙网|★?\d*月?新番" \
|
||||||
r"|最终季|合集|[多中国英葡法俄日韩德意西印泰台港粤双文语简繁体特效内封官译外挂]+字幕|版本|出品|台版|港版|\w+字幕组" \
|
r"|最终季|合集|[多中国英葡法俄日韩德意西印泰台港粤双文语简繁体特效内封官译外挂]+字幕|版本|出品|台版|港版|\w+字幕组|\w+字幕社" \
|
||||||
r"|未删减版|UNCUT$|UNRATE$|WITH EXTRAS$|RERIP$|SUBBED$|PROPER$|REPACK$|SEASON$|EPISODE$|Complete$|Extended$|Extended Version$" \
|
r"|未删减版|UNCUT$|UNRATE$|WITH EXTRAS$|RERIP$|SUBBED$|PROPER$|REPACK$|SEASON$|EPISODE$|Complete$|Extended$|Extended Version$" \
|
||||||
r"|S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}" \
|
r"|S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}" \
|
||||||
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]" \
|
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]" \
|
||||||
r"|[248]K|\d{3,4}[PIX]+" \
|
r"|[248]K|\d{3,4}[PIX]+" \
|
||||||
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]"
|
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]|\s+GB"
|
||||||
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
|
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
|
||||||
_resources_pix_re2 = r"(^[248]+K)"
|
_resources_pix_re2 = r"(^[248]+K)"
|
||||||
_video_encode_re = r"^[HX]26[45]$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^HDR\d*$"
|
_video_encode_re = r"^[HX]26[45]$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^HDR\d*$"
|
||||||
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$"
|
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$"
|
||||||
|
|
||||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||||
|
"""
|
||||||
|
初始化
|
||||||
|
:param title: 标题,文件为去掉了后缀
|
||||||
|
:param subtitle: 副标题
|
||||||
|
:param isfile: 是否是文件名
|
||||||
|
"""
|
||||||
super().__init__(title, subtitle, isfile)
|
super().__init__(title, subtitle, isfile)
|
||||||
if not title:
|
if not title:
|
||||||
return
|
return
|
||||||
@@ -59,13 +67,21 @@ class MetaVideo(MetaBase):
|
|||||||
self._source = ""
|
self._source = ""
|
||||||
self._effect = []
|
self._effect = []
|
||||||
# 判断是否纯数字命名
|
# 判断是否纯数字命名
|
||||||
title_path = Path(title)
|
if isfile \
|
||||||
if title_path.suffix.lower() in settings.RMT_MEDIAEXT \
|
and title.isdigit() \
|
||||||
and title_path.stem.isdigit() \
|
and len(title) < 5:
|
||||||
and len(title_path.stem) < 5:
|
self.begin_episode = int(title)
|
||||||
self.begin_episode = int(title_path.stem)
|
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
return
|
return
|
||||||
|
# 全名为Season xx 及 Sxx 直接返回
|
||||||
|
season_full_res = re.search(r"^Season\s+(\d{1,3})$|^S(\d{1,3})$", title)
|
||||||
|
if season_full_res:
|
||||||
|
self.type = MediaType.TV
|
||||||
|
season = season_full_res.group(1)
|
||||||
|
if season:
|
||||||
|
self.begin_season = int(season)
|
||||||
|
self.total_season = 1
|
||||||
|
return
|
||||||
# 去掉名称中第1个[]的内容
|
# 去掉名称中第1个[]的内容
|
||||||
title = re.sub(r'%s' % self._name_no_begin_re, "", title, count=1)
|
title = re.sub(r'%s' % self._name_no_begin_re, "", title, count=1)
|
||||||
# 把xxxx-xxxx年份换成前一个年份,常出现在季集上
|
# 把xxxx-xxxx年份换成前一个年份,常出现在季集上
|
||||||
@@ -130,12 +146,47 @@ class MetaVideo(MetaBase):
|
|||||||
# 处理part
|
# 处理part
|
||||||
if self.part and self.part.upper() == "PART":
|
if self.part and self.part.upper() == "PART":
|
||||||
self.part = None
|
self.part = None
|
||||||
|
# 没有中文标题时,尝试中描述中获取中文名
|
||||||
|
if not self.cn_name and self.en_name and self.subtitle:
|
||||||
|
if self.__is_pinyin(self.en_name):
|
||||||
|
# 英文名是拼音
|
||||||
|
cn_name = self.__get_title_from_description(self.subtitle)
|
||||||
|
if cn_name and len(cn_name) == len(self.en_name.split()):
|
||||||
|
# 中文名和拼音单词数相同,认为是中文名
|
||||||
|
self.cn_name = cn_name
|
||||||
# 制作组/字幕组
|
# 制作组/字幕组
|
||||||
self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None
|
self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None
|
||||||
# 自定义占位符
|
# 自定义占位符
|
||||||
self.customization = CustomizationMatcher().match(title=original_title) or None
|
self.customization = CustomizationMatcher().match(title=original_title) or None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_title_from_description(description: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从描述中提取标题
|
||||||
|
"""
|
||||||
|
if not description:
|
||||||
|
return None
|
||||||
|
titles = re.split(r'[\s/|]+', description)
|
||||||
|
if StringUtils.is_chinese(titles[0]):
|
||||||
|
return titles[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __is_pinyin(name_str: str) -> bool:
|
||||||
|
"""
|
||||||
|
判断是否拼音
|
||||||
|
"""
|
||||||
|
if not name_str:
|
||||||
|
return False
|
||||||
|
for n in name_str.lower().split():
|
||||||
|
if not is_pinyin(n):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def __fix_name(self, name: str):
|
def __fix_name(self, name: str):
|
||||||
|
"""
|
||||||
|
去掉名字中不需要的干扰字符
|
||||||
|
"""
|
||||||
if not name:
|
if not name:
|
||||||
return name
|
return name
|
||||||
name = re.sub(r'%s' % self._name_nostring_re, '', name,
|
name = re.sub(r'%s' % self._name_nostring_re, '', name,
|
||||||
@@ -157,6 +208,9 @@ class MetaVideo(MetaBase):
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
def __init_name(self, token: str):
|
def __init_name(self, token: str):
|
||||||
|
"""
|
||||||
|
识别名称
|
||||||
|
"""
|
||||||
if not token:
|
if not token:
|
||||||
return
|
return
|
||||||
# 回收标题
|
# 回收标题
|
||||||
@@ -250,6 +304,9 @@ class MetaVideo(MetaBase):
|
|||||||
self._last_token_type = "enname"
|
self._last_token_type = "enname"
|
||||||
|
|
||||||
def __init_part(self, token: str):
|
def __init_part(self, token: str):
|
||||||
|
"""
|
||||||
|
识别Part
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
if not self.year \
|
if not self.year \
|
||||||
@@ -273,6 +330,9 @@ class MetaVideo(MetaBase):
|
|||||||
# self._stop_name_flag = False
|
# self._stop_name_flag = False
|
||||||
|
|
||||||
def __init_year(self, token: str):
|
def __init_year(self, token: str):
|
||||||
|
"""
|
||||||
|
识别年份
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
if not token.isdigit():
|
if not token.isdigit():
|
||||||
@@ -295,6 +355,9 @@ class MetaVideo(MetaBase):
|
|||||||
self._stop_name_flag = True
|
self._stop_name_flag = True
|
||||||
|
|
||||||
def __init_resource_pix(self, token: str):
|
def __init_resource_pix(self, token: str):
|
||||||
|
"""
|
||||||
|
识别分辨率
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
re_res = re.findall(r"%s" % self._resources_pix_re, token, re.IGNORECASE)
|
re_res = re.findall(r"%s" % self._resources_pix_re, token, re.IGNORECASE)
|
||||||
@@ -331,6 +394,9 @@ class MetaVideo(MetaBase):
|
|||||||
self.resource_pix = re_res.group(1).lower()
|
self.resource_pix = re_res.group(1).lower()
|
||||||
|
|
||||||
def __init_season(self, token: str):
|
def __init_season(self, token: str):
|
||||||
|
"""
|
||||||
|
识别季
|
||||||
|
"""
|
||||||
re_res = re.findall(r"%s" % self._season_re, token, re.IGNORECASE)
|
re_res = re.findall(r"%s" % self._season_re, token, re.IGNORECASE)
|
||||||
if re_res:
|
if re_res:
|
||||||
self._last_token_type = "season"
|
self._last_token_type = "season"
|
||||||
@@ -380,6 +446,9 @@ class MetaVideo(MetaBase):
|
|||||||
self.begin_season = 1
|
self.begin_season = 1
|
||||||
|
|
||||||
def __init_episode(self, token: str):
|
def __init_episode(self, token: str):
|
||||||
|
"""
|
||||||
|
识别集
|
||||||
|
"""
|
||||||
re_res = re.findall(r"%s" % self._episode_re, token, re.IGNORECASE)
|
re_res = re.findall(r"%s" % self._episode_re, token, re.IGNORECASE)
|
||||||
if re_res:
|
if re_res:
|
||||||
self._last_token_type = "episode"
|
self._last_token_type = "episode"
|
||||||
@@ -450,6 +519,9 @@ class MetaVideo(MetaBase):
|
|||||||
self._last_token_type = "EPISODE"
|
self._last_token_type = "EPISODE"
|
||||||
|
|
||||||
def __init_resource_type(self, token):
|
def __init_resource_type(self, token):
|
||||||
|
"""
|
||||||
|
识别资源类型
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
source_res = re.search(r"(%s)" % self._source_re, token, re.IGNORECASE)
|
source_res = re.search(r"(%s)" % self._source_re, token, re.IGNORECASE)
|
||||||
@@ -488,6 +560,9 @@ class MetaVideo(MetaBase):
|
|||||||
self._last_token = effect.upper()
|
self._last_token = effect.upper()
|
||||||
|
|
||||||
def __init_video_encode(self, token: str):
|
def __init_video_encode(self, token: str):
|
||||||
|
"""
|
||||||
|
识别视频编码
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
if not self.year \
|
if not self.year \
|
||||||
@@ -528,6 +603,9 @@ class MetaVideo(MetaBase):
|
|||||||
self.video_encode = f"{self.video_encode} 10bit"
|
self.video_encode = f"{self.video_encode} 10bit"
|
||||||
|
|
||||||
def __init_audio_encode(self, token: str):
|
def __init_audio_encode(self, token: str):
|
||||||
|
"""
|
||||||
|
识别音频编码
|
||||||
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
if not self.year \
|
if not self.year \
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import traceback
|
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
import cn2an
|
import cn2an
|
||||||
@@ -15,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:屏蔽词
|
||||||
@@ -24,9 +23,9 @@ 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.find('#') == 0:
|
if not word or word.startswith("#"):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "):
|
if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "):
|
||||||
@@ -54,17 +53,18 @@ class WordsMatcher(metaclass=Singleton):
|
|||||||
strings = word.split(" <> ")
|
strings = word.split(" <> ")
|
||||||
offsets = strings[1].split(" >> ")
|
offsets = strings[1].split(" >> ")
|
||||||
strings[1] = offsets[0]
|
strings[1] = offsets[0]
|
||||||
title, message, state = self.__episode_offset(title, strings[0], strings[1],
|
title, message, state = self.__episode_offset(title, strings[0], strings[1], offsets[1])
|
||||||
offsets[1])
|
|
||||||
else:
|
else:
|
||||||
# 屏蔽词
|
# 屏蔽词
|
||||||
|
if not word.strip():
|
||||||
|
continue
|
||||||
title, message, state = self.__replace_regex(title, word, "")
|
title, message, state = self.__replace_regex(title, word, "")
|
||||||
|
|
||||||
if state:
|
if state:
|
||||||
appley_words.append(word)
|
appley_words.append(word)
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"自定义识别词预处理标题失败:{str(err)} - {traceback.format_exc()}")
|
logger.warn(f"自定义识别词 {word} 预处理标题失败:{str(err)} - 标题:{title}")
|
||||||
|
|
||||||
return title, appley_words
|
return title, appley_words
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ class WordsMatcher(metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
return re.sub(r'%s' % replaced, r'%s' % replace, title), "", True
|
return re.sub(r'%s' % replaced, r'%s' % replace, title), "", True
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"自定义识别词正则替换失败:{str(err)} - {traceback.format_exc()}")
|
logger.warn(f"自定义识别词正则替换失败:{str(err)} - 标题:{title},被替换词:{replaced},替换词:{replace}")
|
||||||
return title, str(err), False
|
return title, str(err), False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -131,5 +131,5 @@ class WordsMatcher(metaclass=Singleton):
|
|||||||
title = re.sub(episode_offset_re, r'%s' % episode_num[1], title)
|
title = re.sub(episode_offset_re, r'%s' % episode_num[1], title)
|
||||||
return title, "", True
|
return title, "", True
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"自定义识别词集数偏移失败:{str(err)} - {traceback.format_exc()}")
|
logger.warn(f"自定义识别词集数偏移失败:{str(err)} - 标题:{title},前定位词:{front},后定位词:{back},偏移量:{offset}")
|
||||||
return title, str(err), False
|
return title, str(err), False
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
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
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.meta import MetaAnime, MetaVideo, MetaBase
|
from app.core.meta import MetaAnime, MetaVideo, MetaBase
|
||||||
from app.core.meta.words import WordsMatcher
|
from app.core.meta.words import WordsMatcher
|
||||||
|
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)
|
||||||
# 判断是否处理文件
|
# 判断是否处理文件
|
||||||
if title and Path(title).suffix.lower() in settings.RMT_MEDIAEXT:
|
if title and Path(title).suffix.lower() in settings.RMT_MEDIAEXT:
|
||||||
isfile = True
|
isfile = True
|
||||||
|
# 去掉后缀
|
||||||
|
title = Path(title).stem
|
||||||
else:
|
else:
|
||||||
isfile = False
|
isfile = False
|
||||||
# 识别
|
# 识别
|
||||||
@@ -35,9 +39,12 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
|||||||
meta.apply_words = apply_words or []
|
meta.apply_words = apply_words or []
|
||||||
# 修正媒体信息
|
# 修正媒体信息
|
||||||
if metainfo.get('tmdbid'):
|
if metainfo.get('tmdbid'):
|
||||||
meta.tmdbid = metainfo['tmdbid']
|
try:
|
||||||
|
meta.tmdbid = int(metainfo['tmdbid'])
|
||||||
|
except ValueError as _:
|
||||||
|
logger.warn("tmdbid 必须是数字")
|
||||||
if metainfo.get('doubanid'):
|
if metainfo.get('doubanid'):
|
||||||
meta.tmdbid = metainfo['doubanid']
|
meta.doubanid = metainfo['doubanid']
|
||||||
if metainfo.get('type'):
|
if metainfo.get('type'):
|
||||||
meta.type = metainfo['type']
|
meta.type = metainfo['type']
|
||||||
if metainfo.get('begin_season'):
|
if metainfo.get('begin_season'):
|
||||||
@@ -61,7 +68,7 @@ def MetaInfoPath(path: Path) -> MetaBase:
|
|||||||
:param path: 路径
|
:param path: 路径
|
||||||
"""
|
"""
|
||||||
# 文件元数据,不包含后缀
|
# 文件元数据,不包含后缀
|
||||||
file_meta = MetaInfo(title=path.stem)
|
file_meta = MetaInfo(title=path.name)
|
||||||
# 上级目录元数据
|
# 上级目录元数据
|
||||||
dir_meta = MetaInfo(title=path.parent.name)
|
dir_meta = MetaInfo(title=path.parent.name)
|
||||||
# 合并元数据
|
# 合并元数据
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from typing import Generator, Optional, Tuple
|
import traceback
|
||||||
|
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
|
||||||
|
|
||||||
@@ -34,22 +37,31 @@ class ModuleManager(metaclass=Singleton):
|
|||||||
for module in modules:
|
for module in modules:
|
||||||
module_id = module.__name__
|
module_id = module.__name__
|
||||||
self._modules[module_id] = module
|
self._modules[module_id] = module
|
||||||
# 生成实例
|
try:
|
||||||
_module = module()
|
# 生成实例
|
||||||
# 初始化模块
|
_module = module()
|
||||||
if self.check_setting(_module.init_setting()):
|
# 初始化模块
|
||||||
# 通过模板开关控制加载
|
if self.check_setting(_module.init_setting()):
|
||||||
_module.init_module()
|
# 通过模板开关控制加载
|
||||||
self._running_modules[module_id] = _module
|
_module.init_module()
|
||||||
logger.info(f"Moudle Loaded:{module_id}")
|
self._running_modules[module_id] = _module
|
||||||
|
logger.info(f"Moudle Loaded:{module_id}")
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"Load Moudle Error:{module_id},{str(err)} - {traceback.format_exc()}", exc_info=True)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""
|
"""
|
||||||
停止所有模块
|
停止所有模块
|
||||||
"""
|
"""
|
||||||
for _, module in self._running_modules.items():
|
logger.info("正在停止所有模块...")
|
||||||
|
for module_id, module in self._running_modules.items():
|
||||||
if hasattr(module, "stop"):
|
if hasattr(module, "stop"):
|
||||||
module.stop()
|
try:
|
||||||
|
module.stop()
|
||||||
|
logger.info(f"Moudle Stoped:{module_id}")
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"Stop Moudle Error:{module_id},{str(err)} - {traceback.format_exc()}", exc_info=True)
|
||||||
|
logger.info("所有模块停止完成")
|
||||||
|
|
||||||
def reload(self):
|
def reload(self):
|
||||||
"""
|
"""
|
||||||
@@ -57,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
|
||||||
@@ -78,13 +94,26 @@ class ModuleManager(metaclass=Singleton):
|
|||||||
if not setting:
|
if not setting:
|
||||||
return True
|
return True
|
||||||
switch, value = setting
|
switch, value = setting
|
||||||
if getattr(settings, switch) and value is True:
|
option = getattr(settings, switch)
|
||||||
|
if not option:
|
||||||
|
return False
|
||||||
|
if option and value is True:
|
||||||
return True
|
return True
|
||||||
if value in getattr(settings, switch):
|
if value in option:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_modules(self, method: str) -> Generator:
|
def get_running_module(self, module_id: str) -> Any:
|
||||||
|
"""
|
||||||
|
根据模块id获取模块运行实例
|
||||||
|
"""
|
||||||
|
if not module_id:
|
||||||
|
return None
|
||||||
|
if not self._running_modules:
|
||||||
|
return None
|
||||||
|
return self._running_modules.get(module_id)
|
||||||
|
|
||||||
|
def get_running_modules(self, method: str) -> Generator:
|
||||||
"""
|
"""
|
||||||
获取实现了同一方法的模块列表
|
获取实现了同一方法的模块列表
|
||||||
"""
|
"""
|
||||||
@@ -94,3 +123,30 @@ class ModuleManager(metaclass=Singleton):
|
|||||||
if hasattr(module, method) \
|
if hasattr(module, method) \
|
||||||
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:
|
||||||
|
"""
|
||||||
|
根据模块id获取模块
|
||||||
|
"""
|
||||||
|
if not module_id:
|
||||||
|
return None
|
||||||
|
if not self._modules:
|
||||||
|
return None
|
||||||
|
return self._modules.get(module_id)
|
||||||
|
|
||||||
|
def get_modules(self) -> dict:
|
||||||
|
"""
|
||||||
|
获取模块列表
|
||||||
|
"""
|
||||||
|
return self._modules
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,54 +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
|
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,
|
||||||
|
"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(
|
||||||
@@ -61,42 +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_uri_token(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_uri_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校验不通过"
|
def verify_apikey(apikey: str = Security(__get_api_key)) -> str:
|
||||||
)
|
"""
|
||||||
return apikey
|
使用 API Key 进行身份认证
|
||||||
|
:param apikey: API Key,从 URL 查询参数或请求头中获取
|
||||||
|
:return: 返回校验通过的 API Key
|
||||||
|
"""
|
||||||
|
return __verify_key(apikey, settings.API_TOKEN, "API_KEY")
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ from .subscribe import Subscribe
|
|||||||
from .systemconfig import SystemConfig
|
from .systemconfig import SystemConfig
|
||||||
from .transferhistory import TransferHistory
|
from .transferhistory import TransferHistory
|
||||||
from .user import User
|
from .user import User
|
||||||
|
from .userconfig import UserConfig
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -29,6 +29,11 @@ class PluginData(Base):
|
|||||||
def del_plugin_data_by_key(db: Session, plugin_id: str, key: str):
|
def del_plugin_data_by_key(db: Session, plugin_id: str, key: str):
|
||||||
db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).delete()
|
db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).delete()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_update
|
||||||
|
def del_plugin_data(db: Session, plugin_id: str):
|
||||||
|
db.query(PluginData).filter(PluginData.plugin_id == plugin_id).delete()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def get_plugin_data_by_plugin_id(db: Session, plugin_id: str):
|
def get_plugin_data_by_plugin_id(db: Session, plugin_id: str):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -25,6 +25,10 @@ class Site(Base):
|
|||||||
cookie = Column(String)
|
cookie = Column(String)
|
||||||
# User-Agent
|
# User-Agent
|
||||||
ua = Column(String)
|
ua = Column(String)
|
||||||
|
# ApiKey
|
||||||
|
apikey = Column(String)
|
||||||
|
# Token
|
||||||
|
token = Column(String)
|
||||||
# 是否使用代理 0-否,1-是
|
# 是否使用代理 0-否,1-是
|
||||||
proxy = Column(Integer)
|
proxy = Column(Integer)
|
||||||
# 过滤规则
|
# 过滤规则
|
||||||
@@ -34,13 +38,15 @@ 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)
|
||||||
# 流控次数
|
# 流控次数
|
||||||
limit_count = Column(Integer, default=0)
|
limit_count = Column(Integer, default=0)
|
||||||
# 流控间隔
|
# 流控间隔
|
||||||
limit_seconds = Column(Integer, default=0)
|
limit_seconds = Column(Integer, default=0)
|
||||||
|
# 超时时间
|
||||||
|
timeout = Column(Integer, default=0)
|
||||||
# 是否启用
|
# 是否启用
|
||||||
is_active = Column(Boolean(), default=True)
|
is_active = Column(Boolean(), default=True)
|
||||||
# 创建时间
|
# 创建时间
|
||||||
@@ -63,6 +69,12 @@ class Site(Base):
|
|||||||
result = db.query(Site).order_by(Site.pri).all()
|
result = db.query(Site).order_by(Site.pri).all()
|
||||||
return list(result)
|
return list(result)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_domains_by_ids(db: Session, ids: list):
|
||||||
|
result = db.query(Site.domain).filter(Site.id.in_(ids)).all()
|
||||||
|
return [r[0] for r in result]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_update
|
@db_update
|
||||||
def reset(db: Session):
|
def reset(db: Session):
|
||||||
|
|||||||
37
app/db/models/sitestatistic.py
Normal file
37
app/db/models/sitestatistic.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import db_query, db_update, Base
|
||||||
|
|
||||||
|
|
||||||
|
class SiteStatistic(Base):
|
||||||
|
"""
|
||||||
|
站点统计表
|
||||||
|
"""
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
# 域名Key
|
||||||
|
domain = Column(String, index=True)
|
||||||
|
# 成功次数
|
||||||
|
success = Column(Integer)
|
||||||
|
# 失败次数
|
||||||
|
fail = Column(Integer)
|
||||||
|
# 平均耗时 秒
|
||||||
|
seconds = Column(Integer)
|
||||||
|
# 最后一次访问状态 0-成功 1-失败
|
||||||
|
lst_state = Column(Integer)
|
||||||
|
# 最后访问时间
|
||||||
|
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
# 耗时记录 Json
|
||||||
|
note = Column(JSON)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_by_domain(db: Session, domain: str):
|
||||||
|
return db.query(SiteStatistic).filter(SiteStatistic.domain == domain).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_update
|
||||||
|
def reset(db: Session):
|
||||||
|
db.query(SiteStatistic).delete()
|
||||||
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
|
||||||
@@ -135,6 +141,25 @@ class Subscribe(Base):
|
|||||||
subscribe.delete(db, subscribe.id)
|
subscribe.delete(db, subscribe.id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def list_by_username(db: Session, username: str, state: str = None, mtype: str = None):
|
||||||
|
if mtype:
|
||||||
|
if state:
|
||||||
|
result = db.query(Subscribe).filter(Subscribe.state == state,
|
||||||
|
Subscribe.username == username,
|
||||||
|
Subscribe.type == mtype).all()
|
||||||
|
else:
|
||||||
|
result = db.query(Subscribe).filter(Subscribe.username == username,
|
||||||
|
Subscribe.type == mtype).all()
|
||||||
|
else:
|
||||||
|
if state:
|
||||||
|
result = db.query(Subscribe).filter(Subscribe.state == state,
|
||||||
|
Subscribe.username == username).all()
|
||||||
|
else:
|
||||||
|
result = db.query(Subscribe).filter(Subscribe.username == username).all()
|
||||||
|
return list(result)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def list_by_type(db: Session, mtype: str, days: int):
|
def list_by_type(db: Session, mtype: str, days: int):
|
||||||
|
|||||||
78
app/db/models/subscribehistory.py
Normal file
78
app/db/models/subscribehistory.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import db_query, Base
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeHistory(Base):
|
||||||
|
"""
|
||||||
|
订阅历史表
|
||||||
|
"""
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
# 标题
|
||||||
|
name = Column(String, nullable=False, index=True)
|
||||||
|
# 年份
|
||||||
|
year = Column(String)
|
||||||
|
# 类型
|
||||||
|
type = Column(String)
|
||||||
|
# 搜索关键字
|
||||||
|
keyword = Column(String)
|
||||||
|
tmdbid = Column(Integer, index=True)
|
||||||
|
imdbid = Column(String)
|
||||||
|
tvdbid = Column(Integer)
|
||||||
|
doubanid = Column(String, index=True)
|
||||||
|
bangumiid = Column(Integer, index=True)
|
||||||
|
# 季号
|
||||||
|
season = Column(Integer)
|
||||||
|
# 海报
|
||||||
|
poster = Column(String)
|
||||||
|
# 背景图
|
||||||
|
backdrop = Column(String)
|
||||||
|
# 评分,float
|
||||||
|
vote = Column(Float)
|
||||||
|
# 简介
|
||||||
|
description = Column(String)
|
||||||
|
# 过滤规则
|
||||||
|
filter = Column(String)
|
||||||
|
# 包含
|
||||||
|
include = Column(String)
|
||||||
|
# 排除
|
||||||
|
exclude = Column(String)
|
||||||
|
# 质量
|
||||||
|
quality = Column(String)
|
||||||
|
# 分辨率
|
||||||
|
resolution = Column(String)
|
||||||
|
# 特效
|
||||||
|
effect = Column(String)
|
||||||
|
# 总集数
|
||||||
|
total_episode = Column(Integer)
|
||||||
|
# 开始集数
|
||||||
|
start_episode = Column(Integer)
|
||||||
|
# 订阅完成时间
|
||||||
|
date = Column(String)
|
||||||
|
# 订阅用户
|
||||||
|
username = Column(String)
|
||||||
|
# 订阅站点
|
||||||
|
sites = Column(JSON)
|
||||||
|
# 是否洗版
|
||||||
|
best_version = Column(Integer, default=0)
|
||||||
|
# 保存路径
|
||||||
|
save_path = Column(String)
|
||||||
|
# 是否使用 imdbid 搜索
|
||||||
|
search_imdbid = Column(Integer, default=0)
|
||||||
|
# 自定义识别词
|
||||||
|
custom_words = Column(String)
|
||||||
|
# 自定义媒体类别
|
||||||
|
media_category = Column(String)
|
||||||
|
# 过滤规则组
|
||||||
|
filter_groups = Column(JSON, default=list)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def list_by_type(db: Session, mtype: str, page: int = 1, count: int = 30):
|
||||||
|
result = db.query(SubscribeHistory).filter(
|
||||||
|
SubscribeHistory.type == mtype
|
||||||
|
).order_by(
|
||||||
|
SubscribeHistory.date.desc()
|
||||||
|
).offset((page - 1) * count).limit(count).all()
|
||||||
|
return list(result)
|
||||||
@@ -1,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
|
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,32 +52,40 @@ 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
|
||||||
def list_by_title(db: Session, title: str, page: int = 1, count: int = 30, status: bool = None):
|
def list_by_title(db: Session, title: str, page: int = 1, count: int = 30, status: bool = None):
|
||||||
if status is not None:
|
if status is not None:
|
||||||
result = db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%'),
|
result = db.query(TransferHistory).filter(
|
||||||
TransferHistory.status == status).order_by(
|
TransferHistory.status == status
|
||||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
).order_by(
|
||||||
count).all()
|
TransferHistory.date.desc()
|
||||||
|
).offset((page - 1) * count).limit(count).all()
|
||||||
else:
|
else:
|
||||||
result = db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%')).order_by(
|
result = db.query(TransferHistory).filter(or_(
|
||||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
TransferHistory.title.like(f'%{title}%'),
|
||||||
count).all()
|
TransferHistory.src.like(f'%{title}%'),
|
||||||
|
TransferHistory.dest.like(f'%{title}%'),
|
||||||
|
)).order_by(
|
||||||
|
TransferHistory.date.desc()
|
||||||
|
).offset((page - 1) * count).limit(count).all()
|
||||||
return list(result)
|
return list(result)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
def list_by_page(db: Session, page: int = 1, count: int = 30, status: bool = None):
|
def list_by_page(db: Session, page: int = 1, count: int = 30, status: bool = None):
|
||||||
if status is not None:
|
if status is not None:
|
||||||
result = db.query(TransferHistory).filter(TransferHistory.status == status).order_by(
|
result = db.query(TransferHistory).filter(
|
||||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
TransferHistory.status == status
|
||||||
count).all()
|
).order_by(
|
||||||
|
TransferHistory.date.desc()
|
||||||
|
).offset((page - 1) * count).limit(count).all()
|
||||||
else:
|
else:
|
||||||
result = db.query(TransferHistory).order_by(TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
result = db.query(TransferHistory).order_by(
|
||||||
count).all()
|
TransferHistory.date.desc()
|
||||||
|
).offset((page - 1) * count).limit(count).all()
|
||||||
return list(result)
|
return list(result)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -79,8 +95,17 @@ 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
|
||||||
|
@db_query
|
||||||
|
def get_by_dest(db: Session, dest: str):
|
||||||
|
return db.query(TransferHistory).filter(TransferHistory.dest == dest).first()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
@@ -113,10 +138,13 @@ class TransferHistory(Base):
|
|||||||
@db_query
|
@db_query
|
||||||
def count_by_title(db: Session, title: str, status: bool = None):
|
def count_by_title(db: Session, title: str, status: bool = None):
|
||||||
if status is not None:
|
if status is not None:
|
||||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%'),
|
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.status == status).first()[0]
|
||||||
TransferHistory.status == status).first()[0]
|
|
||||||
else:
|
else:
|
||||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%')).first()[0]
|
return db.query(func.count(TransferHistory.id)).filter(or_(
|
||||||
|
TransferHistory.title.like(f'%{title}%'),
|
||||||
|
TransferHistory.src.like(f'%{title}%'),
|
||||||
|
TransferHistory.dest.like(f'%{title}%')
|
||||||
|
)).first()[0]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
@@ -203,3 +231,11 @@ class TransferHistory(Base):
|
|||||||
"download_hash": download_hash
|
"download_hash": download_hash
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def list_by_date(db: Session, date: str):
|
||||||
|
"""
|
||||||
|
查询某时间之后的转移历史
|
||||||
|
"""
|
||||||
|
return db.query(TransferHistory).filter(TransferHistory.date > date).order_by(TransferHistory.id.desc()).all()
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
from sqlalchemy import Boolean, Column, Integer, JSON, Sequence, String
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@@ -11,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)
|
||||||
@@ -23,25 +22,46 @@ class User(Base):
|
|||||||
is_superuser = Column(Boolean(), default=False)
|
is_superuser = Column(Boolean(), default=False)
|
||||||
# 头像
|
# 头像
|
||||||
avatar = Column(String)
|
avatar = Column(String)
|
||||||
|
# 是否启用otp二次验证
|
||||||
@staticmethod
|
is_otp = Column(Boolean(), default=False)
|
||||||
@db_query
|
# otp秘钥
|
||||||
def authenticate(db: Session, name: str, password: str):
|
otp_secret = Column(String, default=None)
|
||||||
user = db.query(User).filter(User.name == name).first()
|
# 用户权限 json
|
||||||
if not user:
|
permissions = Column(JSON, default=dict)
|
||||||
return None
|
# 用户个性化设置 json
|
||||||
if not verify_password(password, str(user.hashed_password)):
|
settings = Column(JSON, default=dict)
|
||||||
return None
|
|
||||||
return 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)
|
||||||
if user:
|
if user:
|
||||||
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
|
||||||
|
def update_otp_by_name(self, db: Session, name: str, otp: bool, secret: str):
|
||||||
|
user = self.get_by_name(db, name)
|
||||||
|
if user:
|
||||||
|
user.update(db, {
|
||||||
|
'is_otp': otp,
|
||||||
|
'otp_secret': secret
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|||||||
38
app/db/models/userconfig.py
Normal file
38
app/db/models/userconfig.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Sequence, UniqueConstraint, Index, JSON
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import db_query, db_update, Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserConfig(Base):
|
||||||
|
"""
|
||||||
|
用户配置表
|
||||||
|
"""
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
# 用户名
|
||||||
|
username = Column(String, index=True)
|
||||||
|
# 配置键
|
||||||
|
key = Column(String)
|
||||||
|
# 值
|
||||||
|
value = Column(JSON)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# 用户名和配置键联合唯一
|
||||||
|
UniqueConstraint('username', 'key'),
|
||||||
|
Index('ix_userconfig_username_key', 'username', 'key'),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@db_query
|
||||||
|
def get_by_key(db: Session, username: str, key: str):
|
||||||
|
return db.query(UserConfig) \
|
||||||
|
.filter(UserConfig.username == username) \
|
||||||
|
.filter(UserConfig.key == key) \
|
||||||
|
.first()
|
||||||
|
|
||||||
|
@db_update
|
||||||
|
def delete_by_key(self, db: Session, username: str, key: str):
|
||||||
|
userconfig = self.get_by_key(db=db, username=username, key=key)
|
||||||
|
if userconfig:
|
||||||
|
userconfig.delete(db=db, rid=userconfig.id)
|
||||||
|
return True
|
||||||
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,19 +34,20 @@ 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)
|
||||||
|
|
||||||
def del_data(self, plugin_id: str, key: str) -> Any:
|
def del_data(self, plugin_id: str, key: str = None) -> Any:
|
||||||
"""
|
"""
|
||||||
删除插件数据
|
删除插件数据
|
||||||
:param plugin_id: 插件id
|
:param plugin_id: 插件id
|
||||||
:param key: 数据key
|
:param key: 数据key
|
||||||
"""
|
"""
|
||||||
PluginData.del_plugin_data_by_key(self._db, plugin_id, key)
|
if key:
|
||||||
|
PluginData.del_plugin_data_by_key(self._db, plugin_id, key)
|
||||||
|
else:
|
||||||
|
PluginData.del_plugin_data(self._db, plugin_id)
|
||||||
|
|
||||||
def truncate(self):
|
def truncate(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -63,6 +67,12 @@ class SiteOper(DbOper):
|
|||||||
"""
|
"""
|
||||||
return Site.get_by_domain(self._db, domain)
|
return Site.get_by_domain(self._db, domain)
|
||||||
|
|
||||||
|
def get_domains_by_ids(self, ids: List[int]) -> List[str]:
|
||||||
|
"""
|
||||||
|
按ID获取站点域名
|
||||||
|
"""
|
||||||
|
return Site.get_domains_by_ids(self._db, ids)
|
||||||
|
|
||||||
def exists(self, domain: str) -> bool:
|
def exists(self, domain: str) -> bool:
|
||||||
"""
|
"""
|
||||||
判断站点是否存在
|
判断站点是否存在
|
||||||
@@ -92,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
|
|
||||||
@@ -4,6 +4,7 @@ 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):
|
||||||
@@ -85,8 +86,34 @@ class SubscribeOper(DbOper):
|
|||||||
subscribe.update(self._db, payload)
|
subscribe.update(self._db, payload)
|
||||||
return subscribe
|
return subscribe
|
||||||
|
|
||||||
|
def list_by_tmdbid(self, tmdbid: int, season: int = None) -> List[Subscribe]:
|
||||||
|
"""
|
||||||
|
获取指定tmdb_id的订阅
|
||||||
|
"""
|
||||||
|
return Subscribe.get_by_tmdbid(self._db, tmdbid=tmdbid, season=season)
|
||||||
|
|
||||||
|
def list_by_username(self, username: str, state: str = None, mtype: str = None) -> List[Subscribe]:
|
||||||
|
"""
|
||||||
|
获取指定用户的订阅
|
||||||
|
"""
|
||||||
|
return Subscribe.list_by_username(self._db, username=username, state=state, mtype=mtype)
|
||||||
|
|
||||||
def list_by_type(self, mtype: str, days: int = 7) -> Subscribe:
|
def list_by_type(self, mtype: str, days: int = 7) -> Subscribe:
|
||||||
"""
|
"""
|
||||||
获取指定类型的订阅
|
获取指定类型的订阅
|
||||||
"""
|
"""
|
||||||
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,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,20 @@ 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:
|
||||||
|
"""
|
||||||
|
按转移路径查询转移记录
|
||||||
|
:param dest: 数据key
|
||||||
|
"""
|
||||||
|
return TransferHistory.get_by_dest(self._db, dest)
|
||||||
|
|
||||||
def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
|
def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
|
||||||
"""
|
"""
|
||||||
@@ -112,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,
|
||||||
@@ -135,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,
|
||||||
@@ -162,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,
|
||||||
@@ -177,3 +193,10 @@ class TransferHistoryOper(DbOper):
|
|||||||
errmsg="未识别到媒体信息"
|
errmsg="未识别到媒体信息"
|
||||||
)
|
)
|
||||||
return his
|
return his
|
||||||
|
|
||||||
|
def list_by_date(self, date: str) -> List[TransferHistory]:
|
||||||
|
"""
|
||||||
|
查询某时间之后的转移历史
|
||||||
|
:param date: 日期
|
||||||
|
"""
|
||||||
|
return TransferHistory.list_by_date(self._db, date)
|
||||||
|
|||||||
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
|
|
||||||
89
app/db/userconfig_oper.py
Normal file
89
app/db/userconfig_oper.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from typing import Any, Union, Dict, Optional
|
||||||
|
|
||||||
|
from app.db import DbOper
|
||||||
|
from app.db.models.userconfig import UserConfig
|
||||||
|
from app.schemas.types import UserConfigKey
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
|
|
||||||
|
class UserConfigOper(DbOper, metaclass=Singleton):
|
||||||
|
# 配置缓存
|
||||||
|
__USERCONF: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
加载配置到内存
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
for item in UserConfig.list(self._db):
|
||||||
|
self.__set_config_cache(username=item.username, key=item.key, value=item.value)
|
||||||
|
|
||||||
|
def set(self, username: str, key: Union[str, UserConfigKey], value: Any):
|
||||||
|
"""
|
||||||
|
设置用户配置
|
||||||
|
"""
|
||||||
|
if isinstance(key, UserConfigKey):
|
||||||
|
key = key.value
|
||||||
|
# 更新内存
|
||||||
|
self.__set_config_cache(username=username, key=key, value=value)
|
||||||
|
# 写入数据库
|
||||||
|
conf = UserConfig.get_by_key(db=self._db, username=username, key=key)
|
||||||
|
if conf:
|
||||||
|
if value:
|
||||||
|
conf.update(self._db, {"value": value})
|
||||||
|
else:
|
||||||
|
conf.delete(self._db, conf.id)
|
||||||
|
else:
|
||||||
|
conf = UserConfig(username=username, key=key, value=value)
|
||||||
|
conf.create(self._db)
|
||||||
|
|
||||||
|
def get(self, username: str, key: Union[str, UserConfigKey] = None) -> Any:
|
||||||
|
"""
|
||||||
|
获取用户配置
|
||||||
|
"""
|
||||||
|
if not username:
|
||||||
|
return self.__USERCONF
|
||||||
|
if isinstance(key, UserConfigKey):
|
||||||
|
key = key.value
|
||||||
|
if not key:
|
||||||
|
return self.__get_config_caches(username=username)
|
||||||
|
return self.__get_config_cache(username=username, key=key)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
if self._db:
|
||||||
|
self._db.close()
|
||||||
|
|
||||||
|
def __set_config_cache(self, username: str, key: str, value: Any):
|
||||||
|
"""
|
||||||
|
设置配置缓存
|
||||||
|
"""
|
||||||
|
if not username or not key:
|
||||||
|
return
|
||||||
|
cache = self.__USERCONF
|
||||||
|
if not cache:
|
||||||
|
cache = {}
|
||||||
|
user_cache = cache.get(username)
|
||||||
|
if not user_cache:
|
||||||
|
user_cache = {}
|
||||||
|
cache[username] = user_cache
|
||||||
|
user_cache[key] = value
|
||||||
|
self.__USERCONF = cache
|
||||||
|
|
||||||
|
def __get_config_caches(self, username: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取配置缓存
|
||||||
|
"""
|
||||||
|
if not username or not self.__USERCONF:
|
||||||
|
return None
|
||||||
|
return self.__USERCONF.get(username)
|
||||||
|
|
||||||
|
def __get_config_cache(self, username: str, key: str) -> Any:
|
||||||
|
"""
|
||||||
|
获取配置缓存
|
||||||
|
"""
|
||||||
|
if not username or not key or not self.__USERCONF:
|
||||||
|
return None
|
||||||
|
user_cache = self.__get_config_caches(username)
|
||||||
|
if not user_cache:
|
||||||
|
return None
|
||||||
|
return user_cache.get(key)
|
||||||
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()
|
||||||
@@ -61,7 +61,7 @@ class PlaywrightHelper:
|
|||||||
ua: str = None,
|
ua: str = None,
|
||||||
proxies: dict = None,
|
proxies: dict = None,
|
||||||
headless: bool = False,
|
headless: bool = False,
|
||||||
timeout: int = 30) -> str:
|
timeout: int = 20) -> str:
|
||||||
"""
|
"""
|
||||||
获取网页源码
|
获取网页源码
|
||||||
:param url: 网页地址
|
:param url: 网页地址
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|
||||||
# 读取文件
|
# 读取文件
|
||||||
|
|||||||
96
app/helper/directory.py
Normal file
96
app/helper/directory.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.core.context import MediaInfo
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.schemas.types import SystemConfigKey, MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryHelper:
|
||||||
|
"""
|
||||||
|
下载目录/媒体库目录帮助类
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.systemconfig = SystemConfigOper()
|
||||||
|
|
||||||
|
def get_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||||
|
"""
|
||||||
|
获取所有下载目录
|
||||||
|
"""
|
||||||
|
dir_confs: List[dict] = self.systemconfig.get(SystemConfigKey.Directories)
|
||||||
|
if not dir_confs:
|
||||||
|
return []
|
||||||
|
return [schemas.TransferDirectoryConf(**d) for d in dir_confs]
|
||||||
|
|
||||||
|
def get_download_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||||
|
"""
|
||||||
|
获取所有下载目录
|
||||||
|
"""
|
||||||
|
return sorted([d for d in self.get_dirs() if d.download_path], key=lambda x: x.priority)
|
||||||
|
|
||||||
|
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 src_path: 源目录,有值时直接匹配
|
||||||
|
:param dest_path: 目标目录,有值时直接匹配
|
||||||
|
:param fileitem: 文件项,使用文件路径匹配
|
||||||
|
:param local: 是否本地目录
|
||||||
|
"""
|
||||||
|
# 处理类型
|
||||||
|
if media:
|
||||||
|
media_type = media.type.value
|
||||||
|
else:
|
||||||
|
media_type = MediaType.UNKNOWN.value
|
||||||
|
dirs = self.get_dirs()
|
||||||
|
# 按照配置顺序查找
|
||||||
|
for d in dirs:
|
||||||
|
# 下载目录
|
||||||
|
download_path = Path(d.download_path)
|
||||||
|
# 媒体库目录
|
||||||
|
library_path = Path(d.library_path)
|
||||||
|
# 下载目录不匹配, 不符合条件, 通常处理`下载`匹配
|
||||||
|
if src_path and download_path != src_path:
|
||||||
|
continue
|
||||||
|
# 媒体库目录不匹配, 或监控方式为None(即不自动整理), 不符合条件, 通常处理`整理`匹配
|
||||||
|
if dest_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
|
||||||
|
# 目录类型为全部的,符合条件
|
||||||
|
if not d.media_type:
|
||||||
|
return d
|
||||||
|
# 目录类型相等,目录类别为全部,符合条件
|
||||||
|
if d.media_type == media_type and not d.media_category:
|
||||||
|
return d
|
||||||
|
# 目录类型相等,目录类别相等,符合条件
|
||||||
|
if d.media_type == media_type and d.media_category == media.category:
|
||||||
|
return d
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -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 None, None, None
|
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)
|
||||||
@@ -10,34 +10,54 @@ class MessageHelper(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
消息队列管理器,包括系统消息和用户消息
|
消息队列管理器,包括系统消息和用户消息
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.sys_queue = queue.Queue()
|
self.sys_queue = queue.Queue()
|
||||||
self.user_queue = queue.Queue()
|
self.user_queue = queue.Queue()
|
||||||
|
|
||||||
def put(self, message: Any, role: str = "sys", note: Union[list, dict] = None):
|
def put(self, message: Any, role: str = "plugin", title: str = None, note: Union[list, dict] = None):
|
||||||
"""
|
"""
|
||||||
存消息
|
存消息
|
||||||
:param message: 消息
|
:param message: 消息
|
||||||
:param role: 消息通道 sys/user
|
:param role: 消息通道 systm:系统消息,plugin:插件消息,user:用户消息
|
||||||
|
:param title: 标题
|
||||||
:param note: 附件json
|
:param note: 附件json
|
||||||
"""
|
"""
|
||||||
if role == "sys":
|
if role in ["system", "plugin"]:
|
||||||
self.sys_queue.put(message)
|
# 没有标题时获取插件名称
|
||||||
|
if role == "plugin" and not title:
|
||||||
|
title = "插件通知"
|
||||||
|
# 系统通知,默认
|
||||||
|
self.sys_queue.put(json.dumps({
|
||||||
|
"type": role,
|
||||||
|
"title": title,
|
||||||
|
"text": message,
|
||||||
|
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||||
|
"note": note
|
||||||
|
}))
|
||||||
else:
|
else:
|
||||||
if isinstance(message, str):
|
if isinstance(message, str):
|
||||||
self.user_queue.put(message)
|
# 非系统的文本通知
|
||||||
|
self.user_queue.put(json.dumps({
|
||||||
|
"title": title,
|
||||||
|
"text": message,
|
||||||
|
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||||
|
"note": note
|
||||||
|
}))
|
||||||
elif hasattr(message, "to_dict"):
|
elif hasattr(message, "to_dict"):
|
||||||
|
# 非系统的复杂结构通知,如媒体信息/种子列表等。
|
||||||
content = message.to_dict()
|
content = message.to_dict()
|
||||||
|
content['title'] = title
|
||||||
content['date'] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
content['date'] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||||
content['note'] = json.dumps(note) if note else None
|
content['note'] = note
|
||||||
self.user_queue.put(json.dumps(content))
|
self.user_queue.put(json.dumps(content))
|
||||||
|
|
||||||
def get(self, role: str = "sys") -> Optional[str]:
|
def get(self, role: str = "system") -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
取消息
|
取消息
|
||||||
:param role: 消息通道 sys/user
|
:param role: 消息通道 systm:系统消息,plugin:插件消息,user:用户消息
|
||||||
"""
|
"""
|
||||||
if role == "sys":
|
if role == "system":
|
||||||
if not self.sys_queue.empty():
|
if not self.sys_queue.empty():
|
||||||
return self.sys_queue.get(block=False)
|
return self.sys_queue.get(block=False)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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,12 +14,12 @@ class ModuleHelper:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, package_path, 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: 父包名
|
||||||
:param filter_func: 子模块过滤函数,入参为模块名和模块对象,返回True则导入,否则不导入
|
:param filter_func: 子模块过滤函数,入参为模块名和模块对象,返回True则导入,否则不导入
|
||||||
:return:
|
:return: 导入的模块对象列表
|
||||||
"""
|
"""
|
||||||
|
|
||||||
submodules: list = []
|
submodules: list = []
|
||||||
@@ -36,7 +37,59 @@ class ModuleHelper:
|
|||||||
if isinstance(obj, type) and filter_func(name, obj):
|
if isinstance(obj, type) and filter_func(name, obj):
|
||||||
submodules.append(obj)
|
submodules.append(obj)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
|
logger.debug(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
|
||||||
|
|
||||||
|
return submodules
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_with_pre_filter(cls, package_path: str, filter_func=lambda name, obj: True) -> List[Any]:
|
||||||
|
"""
|
||||||
|
导入子模块
|
||||||
|
:param package_path: 父包名
|
||||||
|
:param filter_func: 子模块过滤函数,入参为模块名和模块对象,返回True则导入,否则不导入
|
||||||
|
:return: 导入的模块对象列表
|
||||||
|
"""
|
||||||
|
|
||||||
|
submodules: list = []
|
||||||
|
packages = importlib.import_module(package_path)
|
||||||
|
|
||||||
|
def reload_module_objects(target_module):
|
||||||
|
"""加载模块并返回对象"""
|
||||||
|
importlib.reload(target_module)
|
||||||
|
# reload后,重新过滤已经重新加载后的模块中的对象
|
||||||
|
return [
|
||||||
|
obj for name, obj in target_module.__dict__.items()
|
||||||
|
if not name.startswith('_') and isinstance(obj, type) and filter_func(name, obj)
|
||||||
|
]
|
||||||
|
|
||||||
|
def reload_sub_modules(parent_module, parent_module_name):
|
||||||
|
"""重新加载一级子模块"""
|
||||||
|
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__):
|
||||||
|
full_sub_module_name = f'{parent_module_name}.{sub_module_name}'
|
||||||
|
try:
|
||||||
|
full_sub_module = importlib.import_module(full_sub_module_name)
|
||||||
|
importlib.reload(full_sub_module)
|
||||||
|
except Exception as sub_err:
|
||||||
|
logger.debug(f'加载子模块 {full_sub_module_name} 失败:{str(sub_err)} - {traceback.format_exc()}')
|
||||||
|
|
||||||
|
# 遍历包中的所有子模块
|
||||||
|
for importer, package_name, is_pkg in pkgutil.iter_modules(packages.__path__):
|
||||||
|
if package_name.startswith('_'):
|
||||||
|
continue
|
||||||
|
full_package_name = f'{package_path}.{package_name}'
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(full_package_name)
|
||||||
|
# 预检查模块中的对象
|
||||||
|
candidates = [(name, obj) for name, obj in module.__dict__.items() if
|
||||||
|
not name.startswith('_') and isinstance(obj, type)]
|
||||||
|
# 确定是否需要重新加载
|
||||||
|
if any(filter_func(name, obj) for name, obj in candidates):
|
||||||
|
# 如果子模块是包,重新加载其子模块
|
||||||
|
if is_pkg:
|
||||||
|
reload_sub_modules(module, full_package_name)
|
||||||
|
submodules.extend(reload_module_objects(module))
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
|
||||||
|
|
||||||
return submodules
|
return submodules
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user