mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-08 01:03:08 +08:00
Compare commits
961 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c37e02009f | ||
|
|
a96b8a4e07 | ||
|
|
79b4d5fb8e | ||
|
|
de128f5e6a | ||
|
|
ef8ddcde07 | ||
|
|
eaff557d70 | ||
|
|
38f7a31200 | ||
|
|
97f16289c9 | ||
|
|
e15f5ab93e | ||
|
|
15fd312765 | ||
|
|
eea316865f | ||
|
|
05bbfbbd54 | ||
|
|
6039a9d0d5 | ||
|
|
0159b02916 | ||
|
|
8bbd4dc913 | ||
|
|
9e3ded6ad5 | ||
|
|
fe63275a6b | ||
|
|
81ed465607 | ||
|
|
d9aa281ce1 | ||
|
|
56648d664e | ||
|
|
da49d5577a | ||
|
|
f3dbdefdb1 | ||
|
|
d4302759e6 | ||
|
|
914f192fb2 | ||
|
|
522b554e36 | ||
|
|
4c54ab5319 | ||
|
|
d7f4ed069c | ||
|
|
7ea0c5ee4c | ||
|
|
e773a9d9d4 | ||
|
|
b570542fab | ||
|
|
09716e98ba | ||
|
|
9236b361e2 | ||
|
|
f281d8c068 | ||
|
|
83ed17d5c1 | ||
|
|
e2671dd4ed | ||
|
|
4c4d640331 | ||
|
|
6c4307c918 | ||
|
|
5a7062c699 | ||
|
|
7da01f7404 | ||
|
|
2b695cb8c6 | ||
|
|
599817eec7 | ||
|
|
11fa33be0a | ||
|
|
b5ac9d4ce4 | ||
|
|
78f0ac0042 | ||
|
|
00ecd7adc5 | ||
|
|
c39cb3bffc | ||
|
|
2fa902bfff | ||
|
|
f8bcd351ae | ||
|
|
6013d99bf6 | ||
|
|
e7c3977f7b | ||
|
|
47e1218fe0 | ||
|
|
a71a95892f | ||
|
|
b5f53e309f | ||
|
|
3164ba2d98 | ||
|
|
89854d188d | ||
|
|
79c7475435 | ||
|
|
2ee477c35e | ||
|
|
5bcd90c569 | ||
|
|
1a49c7c59e | ||
|
|
d995932a1c | ||
|
|
1b0bbbbbfd | ||
|
|
2aa93fa341 | ||
|
|
a970f90c6f | ||
|
|
44f612fed5 | ||
|
|
564a48dd8f | ||
|
|
9d029de56a | ||
|
|
2dd3fc5d8c | ||
|
|
9c335dbdfb | ||
|
|
0e30ea92f1 | ||
|
|
a0ced4e43c | ||
|
|
cfaaf65edc | ||
|
|
35be18bb1a | ||
|
|
02296e1758 | ||
|
|
0b84b05cdd | ||
|
|
99e3d5acca | ||
|
|
8001511484 | ||
|
|
8420b2ea85 | ||
|
|
9af883acbb | ||
|
|
e21ba5ad51 | ||
|
|
1293fafd34 | ||
|
|
4bcc6bd733 | ||
|
|
53a514feb6 | ||
|
|
e697889aad | ||
|
|
8b0fba054e | ||
|
|
32ff385444 | ||
|
|
8456c7f4a3 | ||
|
|
fcbfb63645 | ||
|
|
1fa7d15982 | ||
|
|
a173978f6b | ||
|
|
2f069afc77 | ||
|
|
ea998b4e41 | ||
|
|
ba27d02854 | ||
|
|
f78df58906 | ||
|
|
308683a7e9 | ||
|
|
b3f4a6f251 | ||
|
|
d1841d8f15 | ||
|
|
c8d6de3e9b | ||
|
|
938f5c8cea | ||
|
|
d166930b0a | ||
|
|
e1ac3c0d15 | ||
|
|
59da489e05 | ||
|
|
be12c736fb | ||
|
|
71c52aae7b | ||
|
|
dbfe2af53c | ||
|
|
cca898f5b6 | ||
|
|
9abd780aa2 | ||
|
|
2e89eeca2c | ||
|
|
dbb3bead6b | ||
|
|
d0b88ec7f6 | ||
|
|
5898bc7eb1 | ||
|
|
cfe113f6c3 | ||
|
|
83500128c9 | ||
|
|
2bff3a80da | ||
|
|
3dd7b33f3e | ||
|
|
8de487b0bf | ||
|
|
ce88a6818f | ||
|
|
6172832f41 | ||
|
|
a0ed228f4b | ||
|
|
01fd56a019 | ||
|
|
087fcd340a | ||
|
|
b3b09f3c03 | ||
|
|
11d17bf21a | ||
|
|
b1ee80edee | ||
|
|
107d496adb | ||
|
|
9f1112b58d | ||
|
|
989d6e3fe7 | ||
|
|
3999c64853 | ||
|
|
760e3d6de0 | ||
|
|
02111a3b9f | ||
|
|
e6af2c0f34 | ||
|
|
bd4c639761 | ||
|
|
d39b7ec021 | ||
|
|
63ca5f5017 | ||
|
|
2202cf457b | ||
|
|
5d04b7abd6 | ||
|
|
0588d5d5f3 | ||
|
|
5a59e443d7 | ||
|
|
470f4df979 | ||
|
|
84bda71330 | ||
|
|
ea883255cb | ||
|
|
e9abb69fb5 | ||
|
|
ff63390794 | ||
|
|
78b3135276 | ||
|
|
15bd2c09ed | ||
|
|
34d44857e4 | ||
|
|
dccded2d3e | ||
|
|
295cafc060 | ||
|
|
c792e97f67 | ||
|
|
d30a02987d | ||
|
|
84d4c9cf73 | ||
|
|
21ecd1f708 | ||
|
|
248b9a8e8c | ||
|
|
3c7abfada6 | ||
|
|
f363656e0a | ||
|
|
e9ee9dbce1 | ||
|
|
ab0b8653ab | ||
|
|
20711e17fb | ||
|
|
a89bd8b816 | ||
|
|
3692cfea64 | ||
|
|
81d9d39029 | ||
|
|
f5a61ceff1 | ||
|
|
404a7b8337 | ||
|
|
71ce3a2920 | ||
|
|
3a27656769 | ||
|
|
27b1e0ffd5 | ||
|
|
1401ea74dd | ||
|
|
cb93a63970 | ||
|
|
da4ff99570 | ||
|
|
b3c0dc813b | ||
|
|
a7b51d9fcc | ||
|
|
76f1de42a8 | ||
|
|
bad016b2b4 | ||
|
|
5cd48d5447 | ||
|
|
41ff5363ea | ||
|
|
85014f4acb | ||
|
|
d9a68daddd | ||
|
|
141e78f274 | ||
|
|
de98ccd33c | ||
|
|
d490dadfdd | ||
|
|
f46bbf73ba | ||
|
|
17eba86f7a | ||
|
|
fdf25b8c66 | ||
|
|
516cb443b9 | ||
|
|
7c4c3b3f9a | ||
|
|
e298a1a8a0 | ||
|
|
fd9eef2089 | ||
|
|
78dab04c96 | ||
|
|
c34475653f | ||
|
|
eb6a6eee0a | ||
|
|
48f6a45194 | ||
|
|
c8ae6bcc78 | ||
|
|
7f6beb2a78 | ||
|
|
ea160afd90 | ||
|
|
29df0813fd | ||
|
|
b014c4a4e5 | ||
|
|
f173c21695 | ||
|
|
dc41f4946a | ||
|
|
fed754f03a | ||
|
|
382d9ed525 | ||
|
|
e3707f39bb | ||
|
|
9df8d3d360 | ||
|
|
5b3c310cda | ||
|
|
79d692771e | ||
|
|
f74ffed3ae | ||
|
|
0325d7f4f1 | ||
|
|
3926298907 | ||
|
|
d98376b490 | ||
|
|
219690afc0 | ||
|
|
bcb1fc1600 | ||
|
|
923be7e1e9 | ||
|
|
951353ee0b | ||
|
|
52bdfa7f9a | ||
|
|
4af29aa76d | ||
|
|
8efa6a742b | ||
|
|
ada5e1cca5 | ||
|
|
859191203f | ||
|
|
cab4055315 | ||
|
|
cacee7abfe | ||
|
|
61694f4c2b | ||
|
|
9c328e3d1c | ||
|
|
b2fe86c744 | ||
|
|
600e32d3e4 | ||
|
|
3ad733bab4 | ||
|
|
1799b63abb | ||
|
|
d71dc13e32 | ||
|
|
f4633788e9 | ||
|
|
2250e7db39 | ||
|
|
b1bb0ced7a | ||
|
|
28aecd79c6 | ||
|
|
d097ef45eb | ||
|
|
dac718edc8 | ||
|
|
598ab23a2c | ||
|
|
8be6e28933 | ||
|
|
bd6805be58 | ||
|
|
c147d36cb2 | ||
|
|
7a5d210167 | ||
|
|
ef335f2b8e | ||
|
|
19eca11d17 | ||
|
|
ab99bd356a | ||
|
|
70f2d72532 | ||
|
|
0ca995da0f | ||
|
|
2a67abe62d | ||
|
|
03a07ac7bf | ||
|
|
f104c903ec | ||
|
|
6b74a8e266 | ||
|
|
cadd885dbf | ||
|
|
7e0cad8491 | ||
|
|
4c05e9fb2b | ||
|
|
42311f0118 | ||
|
|
951be74a21 | ||
|
|
c86a21d11d | ||
|
|
3fb02f6490 | ||
|
|
ca2c0392bb | ||
|
|
b8663ee735 | ||
|
|
4ab60423c1 | ||
|
|
1ea80e6870 | ||
|
|
6f1d4754be | ||
|
|
52288d98c0 | ||
|
|
d1368c4f84 | ||
|
|
4367c53bb0 | ||
|
|
d87f69da35 | ||
|
|
5ece44090e | ||
|
|
01be4f9549 | ||
|
|
94077917f3 | ||
|
|
8af981738c | ||
|
|
4d7982803e | ||
|
|
a1bba6da4a | ||
|
|
4eb3e16b37 | ||
|
|
1f0b40fe05 | ||
|
|
29e92a17e7 | ||
|
|
8cc4469282 | ||
|
|
a5e66071ba | ||
|
|
fb4e817993 | ||
|
|
8f26110e65 | ||
|
|
9f65a088c0 | ||
|
|
15c15388b6 | ||
|
|
950a43e001 | ||
|
|
9a28f8c365 | ||
|
|
32cb96fc44 | ||
|
|
f7982e3e43 | ||
|
|
d13602827c | ||
|
|
182adc77b6 | ||
|
|
ef4cdb41c8 | ||
|
|
9a60121914 | ||
|
|
6fb0c92183 | ||
|
|
96c4e0ba2f | ||
|
|
7afe82480c | ||
|
|
c37c8e7318 | ||
|
|
3d10ca4c8b | ||
|
|
4e515ec442 | ||
|
|
5eb37b5d28 | ||
|
|
7f95bab0d5 | ||
|
|
3fc267bcfa | ||
|
|
648f0b6ec1 | ||
|
|
be3c3ef37f | ||
|
|
a47f382c21 | ||
|
|
61c59b4405 | ||
|
|
8ee391688d | ||
|
|
68c7bf0a96 | ||
|
|
6dd517a490 | ||
|
|
9baa5e1d35 | ||
|
|
e675e4358a | ||
|
|
c9a6081a57 | ||
|
|
2de20f601b | ||
|
|
79c708c30e | ||
|
|
f38defb515 | ||
|
|
ac11d4eb30 | ||
|
|
221c31f481 | ||
|
|
7c3c6ee999 | ||
|
|
08560fc7c3 | ||
|
|
4659e7367f | ||
|
|
2fa11a4796 | ||
|
|
01a153902e | ||
|
|
5eb65046f0 | ||
|
|
bb64e57f7c | ||
|
|
0cb75d689c | ||
|
|
d7310ade86 | ||
|
|
dd7803c90a | ||
|
|
d8afa339de | ||
|
|
1b2f09b95f | ||
|
|
0414854832 | ||
|
|
9e6a7be5b1 | ||
|
|
e3c1407b62 | ||
|
|
7a9ee954c5 | ||
|
|
99a06dcba0 | ||
|
|
bb8fc14bc6 | ||
|
|
50d9dcf17b | ||
|
|
141b99d134 | ||
|
|
18457a4de7 | ||
|
|
a343d736ae | ||
|
|
df5c364185 | ||
|
|
edcec114ae | ||
|
|
605a7486b3 | ||
|
|
efe89f59b9 | ||
|
|
fdd4aef3d3 | ||
|
|
08aef1f47f | ||
|
|
c45f5e6ac4 | ||
|
|
f239cede07 | ||
|
|
b2eb952cd0 | ||
|
|
3a2fba0422 | ||
|
|
1034caa9fd | ||
|
|
8b243e23ab | ||
|
|
1f76dc1e2a | ||
|
|
ea5c2fb4cf | ||
|
|
e50b56d542 | ||
|
|
2206fafda9 | ||
|
|
345b74d881 | ||
|
|
d231d75446 | ||
|
|
afb5874350 | ||
|
|
1bd7b5c77e | ||
|
|
ba41de61cb | ||
|
|
ae40d32115 | ||
|
|
3fe4c9467e | ||
|
|
b89512cc33 | ||
|
|
f3b12bed20 | ||
|
|
08c7fff5ab | ||
|
|
9c20d1a270 | ||
|
|
b7b1aee878 | ||
|
|
f998b39152 | ||
|
|
ca01db31a9 | ||
|
|
a0b8cc6719 | ||
|
|
66b91abe90 | ||
|
|
9b17d55ac0 | ||
|
|
a7a0889867 | ||
|
|
af6cf306c8 | ||
|
|
20f35854f9 | ||
|
|
e5165c8fea | ||
|
|
0e36d003c0 | ||
|
|
ccc249f29d | ||
|
|
f4edb32886 | ||
|
|
475a84bfa6 | ||
|
|
3914ff4dd6 | ||
|
|
5bcbacf3a5 | ||
|
|
27238ac467 | ||
|
|
019d40c17a | ||
|
|
fa5b92214f | ||
|
|
32a5f67e72 | ||
|
|
d6e9c14183 | ||
|
|
87325d5bbd | ||
|
|
67ead871c1 | ||
|
|
691beb1186 | ||
|
|
b30d3c7dac | ||
|
|
5e048f0150 | ||
|
|
cb2cfe9d85 | ||
|
|
482fca9b8c | ||
|
|
42511b95d8 | ||
|
|
b18e901fbd | ||
|
|
a30e3f49a3 | ||
|
|
65d202e636 | ||
|
|
4373c0596b | ||
|
|
0136d9fe06 | ||
|
|
933c6d838c | ||
|
|
7ce656148f | ||
|
|
c05ffed6df | ||
|
|
6770ba3a35 | ||
|
|
3b73dfcdc6 | ||
|
|
100ff97017 | ||
|
|
4fe96178ee | ||
|
|
86d484fac0 | ||
|
|
db23b62fd1 | ||
|
|
b84c8fd7f1 | ||
|
|
c9f6c75069 | ||
|
|
846459c244 | ||
|
|
c4898d04aa | ||
|
|
c8bc6a4618 | ||
|
|
55dce26cb8 | ||
|
|
ae3b73a73f | ||
|
|
091df01b7c | ||
|
|
20c4c7d6e6 | ||
|
|
eb1e045d8f | ||
|
|
678638e9f1 | ||
|
|
d8b78d3051 | ||
|
|
eaf0d17118 | ||
|
|
81bcfef6ec | ||
|
|
0997691b23 | ||
|
|
d1f9647a63 | ||
|
|
64a04ba8ed | ||
|
|
726c130f1f | ||
|
|
215b56b9f2 | ||
|
|
516bd8bc30 | ||
|
|
8bc6e04665 | ||
|
|
94057cd5f1 | ||
|
|
2e80586436 | ||
|
|
faa6d7dadd | ||
|
|
071c81d52c | ||
|
|
52d4feb583 | ||
|
|
584e05e63e | ||
|
|
061ff322ab | ||
|
|
a2bcf8df9a | ||
|
|
6c85040eb6 | ||
|
|
2e5d892120 | ||
|
|
43d108aea9 | ||
|
|
c46b1dd116 | ||
|
|
d3fac56e9a | ||
|
|
b3f5b87b02 | ||
|
|
03abdf9cb4 | ||
|
|
42bc354e06 | ||
|
|
02e81a79b2 | ||
|
|
9fa4b8dfbe | ||
|
|
366f59623a | ||
|
|
d4c28500b7 | ||
|
|
5780344c43 | ||
|
|
18970efc1a | ||
|
|
5725584176 | ||
|
|
4e26168ab5 | ||
|
|
f694dee71d | ||
|
|
a9db0f6bbf | ||
|
|
7efcde89b9 | ||
|
|
1c07b306c3 | ||
|
|
6c59a5ebb0 | ||
|
|
4c7321a738 | ||
|
|
f42fd023bb | ||
|
|
9b8a4ebdd4 | ||
|
|
443e2d8104 | ||
|
|
2c61d439ca | ||
|
|
e01268222c | ||
|
|
27ff77b504 | ||
|
|
bf8893d71b | ||
|
|
54b09a17c2 | ||
|
|
b01621049b | ||
|
|
e5dc40e3c1 | ||
|
|
44d4bcdd19 | ||
|
|
b899b23d04 | ||
|
|
fa23012adb | ||
|
|
d836b385ae | ||
|
|
15a0bc6c12 | ||
|
|
22791e361d | ||
|
|
47b7dade5d | ||
|
|
c57d13afcc | ||
|
|
8db1c2952c | ||
|
|
28c19bc4e3 | ||
|
|
fbef1735b0 | ||
|
|
9869af992b | ||
|
|
b6cb241b8a | ||
|
|
7edf8e7c30 | ||
|
|
452161f1b8 | ||
|
|
f75abb27b6 | ||
|
|
30311e8e56 | ||
|
|
adff3b22e9 | ||
|
|
013c0dea3b | ||
|
|
c593c3ba16 | ||
|
|
61b74735de | ||
|
|
952cae50e2 | ||
|
|
7a9f89e86c | ||
|
|
f14d8bec1b | ||
|
|
697d5a815b | ||
|
|
cfeaa2674d | ||
|
|
08f046f059 | ||
|
|
a66912f41a | ||
|
|
f244728a96 | ||
|
|
576ac08a05 | ||
|
|
e874b3f294 | ||
|
|
90ff0fc793 | ||
|
|
259e8fc2e1 | ||
|
|
5c0be93913 | ||
|
|
e84a5c74f6 | ||
|
|
5145527d0e | ||
|
|
e3f7f873c0 | ||
|
|
84a2db2247 | ||
|
|
4902d5ebed | ||
|
|
243391ee30 | ||
|
|
c424de65b3 | ||
|
|
2077eede8c | ||
|
|
876d1e01b4 | ||
|
|
dec022fd89 | ||
|
|
83829cbe27 | ||
|
|
8249f9356f | ||
|
|
b5fc6cdd1e | ||
|
|
51b959cff8 | ||
|
|
36880a8b7d | ||
|
|
380cc7552f | ||
|
|
0f1c8cb226 | ||
|
|
7435fb0c10 | ||
|
|
1a03981463 | ||
|
|
4cb7a488a9 | ||
|
|
c69762d4c9 | ||
|
|
03d9bf6d05 | ||
|
|
6a08b4ba7f | ||
|
|
99218515ea | ||
|
|
c3a0a839c3 | ||
|
|
351513bcbc | ||
|
|
ed5dec1b0f | ||
|
|
c62b29edc4 | ||
|
|
c224a7c07b | ||
|
|
a7b244a4b4 | ||
|
|
b564f70c63 | ||
|
|
551f32491d | ||
|
|
2826b9411d | ||
|
|
4bf9045784 | ||
|
|
114788e3ed | ||
|
|
bb729bf976 | ||
|
|
bedc885232 | ||
|
|
21e39611bc | ||
|
|
73e7e547ea | ||
|
|
bc25d71b88 | ||
|
|
ff8a9dc8c7 | ||
|
|
4ee7daa673 | ||
|
|
aca1673ee3 | ||
|
|
87ece98471 | ||
|
|
4c16cd7bfb | ||
|
|
712af24a72 | ||
|
|
b7d2168f8e | ||
|
|
65ad7123f9 | ||
|
|
ce42e48b37 | ||
|
|
45b53da056 | ||
|
|
70f93e02e4 | ||
|
|
e4b63eacae | ||
|
|
96f17e2bc2 | ||
|
|
7eb77875f1 | ||
|
|
bbc27bbe19 | ||
|
|
3691b2a10b | ||
|
|
08a3d02daf | ||
|
|
57abc7816b | ||
|
|
69c277777e | ||
|
|
5f88fe81e3 | ||
|
|
d043dbd89e | ||
|
|
53a2887717 | ||
|
|
28d181db44 | ||
|
|
7d3f43e488 | ||
|
|
62df3f7c84 | ||
|
|
1338a061c4 | ||
|
|
4f26f0607a | ||
|
|
b72aa314b6 | ||
|
|
082ec8d718 | ||
|
|
e785f20c5a | ||
|
|
0050a96faf | ||
|
|
31b460f89f | ||
|
|
89cd2bbadc | ||
|
|
7d19467b6c | ||
|
|
97667249d5 | ||
|
|
2e2472a387 | ||
|
|
4b10028690 | ||
|
|
e0a492d8ab | ||
|
|
52e89747b7 | ||
|
|
59b947fa65 | ||
|
|
212e2f1287 | ||
|
|
685be88c46 | ||
|
|
8297b3e199 | ||
|
|
75c5844d64 | ||
|
|
ad5ca69bbb | ||
|
|
6befa35a26 | ||
|
|
4fec6aede4 | ||
|
|
68a3bc8732 | ||
|
|
ba2745266a | ||
|
|
2fcf5039ff | ||
|
|
b37dc4471e | ||
|
|
ffc5c48830 | ||
|
|
dbe3701032 | ||
|
|
751d405aac | ||
|
|
9224169f31 | ||
|
|
62c1a924e8 | ||
|
|
9fdd838b7a | ||
|
|
510911b7a3 | ||
|
|
36e68f44dc | ||
|
|
374e633ca7 | ||
|
|
ec8c9c996a | ||
|
|
3c753686c6 | ||
|
|
5f4580282e | ||
|
|
5d9e0b699c | ||
|
|
5debfca89a | ||
|
|
3eeb9e299a | ||
|
|
9c4aba10bf | ||
|
|
7b37d86527 | ||
|
|
55c061176d | ||
|
|
5dc11b07e3 | ||
|
|
0bb67824bd | ||
|
|
ac1dcbed3c | ||
|
|
d0a586a46b | ||
|
|
fa8dcea7da | ||
|
|
76a94a80ef | ||
|
|
9139c1297e | ||
|
|
4dba739d54 | ||
|
|
fe80f86518 | ||
|
|
7307105dcd | ||
|
|
1c7715d94c | ||
|
|
4dd2d6d307 | ||
|
|
7cfd05a7a5 | ||
|
|
8eab38c91e | ||
|
|
6ad78fa875 | ||
|
|
781cffb255 | ||
|
|
2a7fc7bbe6 | ||
|
|
f65da9b202 | ||
|
|
0cf11db76a | ||
|
|
37bada89ef | ||
|
|
38d6467740 | ||
|
|
3bc639bcab | ||
|
|
7baa07474c | ||
|
|
8e304f77b4 | ||
|
|
93ec8df713 | ||
|
|
8854acf908 | ||
|
|
143ffd18b7 | ||
|
|
212f9c250f | ||
|
|
fa62943679 | ||
|
|
3f95962ced | ||
|
|
e68aab423e | ||
|
|
49d51ca13e | ||
|
|
f6b5994fe5 | ||
|
|
8ad75e93a9 | ||
|
|
796133e26f | ||
|
|
8414c5df0a | ||
|
|
1fcdf633ba | ||
|
|
b503dee631 | ||
|
|
0837950334 | ||
|
|
95787f6ef6 | ||
|
|
3943a7a793 | ||
|
|
9f0bd2b933 | ||
|
|
053c89bf9f | ||
|
|
8739a67679 | ||
|
|
cb41086fa3 | ||
|
|
84cbeaada2 | ||
|
|
344742871c | ||
|
|
95df1c4c1c | ||
|
|
593211c037 | ||
|
|
f80e5739ca | ||
|
|
17fcd77b8e | ||
|
|
f0666986f0 | ||
|
|
854fafd880 | ||
|
|
bdd45304c8 | ||
|
|
c372d0451e | ||
|
|
38eff64c95 | ||
|
|
9326676bb6 | ||
|
|
7df1d807bb | ||
|
|
cce543274e | ||
|
|
3b7c1fed74 | ||
|
|
e0dfbc213a | ||
|
|
d76fa9bb00 | ||
|
|
e59a498826 | ||
|
|
e6452d68bb | ||
|
|
0d830b237b | ||
|
|
470ebb7b79 | ||
|
|
a6819c08bf | ||
|
|
16ba4587e1 | ||
|
|
911651a5f7 | ||
|
|
3f94f5f709 | ||
|
|
16289d86b6 | ||
|
|
17450c7c70 | ||
|
|
eac9fc02fa | ||
|
|
1a026ffb12 | ||
|
|
85477a4bd3 | ||
|
|
f8221bb526 | ||
|
|
85a581f0cd | ||
|
|
ae7b48ad9f | ||
|
|
59907af4f4 | ||
|
|
e63f52bee5 | ||
|
|
b9b8b86019 | ||
|
|
bfca8a52d6 | ||
|
|
99ccbfef22 | ||
|
|
5e2f4b413d | ||
|
|
a0ec38a6a9 | ||
|
|
eae89b2d36 | ||
|
|
e5926a489d | ||
|
|
8acfde7906 | ||
|
|
24a164f47e | ||
|
|
72fbbffa02 | ||
|
|
95a87f3e33 | ||
|
|
55206ea092 | ||
|
|
c138cda735 | ||
|
|
d0a92531ac | ||
|
|
96fc32efd0 | ||
|
|
a9a0acc091 | ||
|
|
fa6f2c01e0 | ||
|
|
05a0026ea4 | ||
|
|
8f352c23c8 | ||
|
|
8bc883b621 | ||
|
|
6a34c7196c | ||
|
|
58ded2ef5e | ||
|
|
2b462a1b9c | ||
|
|
a6d0504900 | ||
|
|
7717afab69 | ||
|
|
683ba4cfad | ||
|
|
921783d6bb | ||
|
|
b7e9e8ee21 | ||
|
|
dadad74085 | ||
|
|
e405c98bae | ||
|
|
9d4bec7d81 | ||
|
|
d6a73d6017 | ||
|
|
b4a780aba7 | ||
|
|
f15f98fcfc | ||
|
|
4bb8b01301 | ||
|
|
aa8cb889f8 | ||
|
|
9e31c53fa5 | ||
|
|
4b23f3f076 | ||
|
|
52fac09021 | ||
|
|
bb67e902c5 | ||
|
|
6206c5f4a3 | ||
|
|
de3d3de411 | ||
|
|
91896946d8 | ||
|
|
cc545490cd | ||
|
|
4cfa051dfc | ||
|
|
41a45b1a8d | ||
|
|
66c7ca0b96 | ||
|
|
214a766d7d | ||
|
|
310dd7c229 | ||
|
|
4b91510695 | ||
|
|
f52deb3ff2 | ||
|
|
9be9006013 | ||
|
|
fc2312a045 | ||
|
|
c593f6423c | ||
|
|
200e5ff027 | ||
|
|
d7f2bbb121 | ||
|
|
f4a1f420c5 | ||
|
|
ed8e02bb38 | ||
|
|
4049468444 | ||
|
|
f8d5e3f438 | ||
|
|
fc50540ab1 | ||
|
|
624365542c | ||
|
|
bb93919707 | ||
|
|
3acb2b254c | ||
|
|
ff900c5d01 | ||
|
|
8171124503 | ||
|
|
dbd858b27d | ||
|
|
df5337947c | ||
|
|
ddf6f5c0b6 | ||
|
|
d879e54bb7 | ||
|
|
7666fa6db3 | ||
|
|
cef33d370a | ||
|
|
76cd4048e3 | ||
|
|
6505aa9efb | ||
|
|
81a29d3604 | ||
|
|
86d7dceb84 | ||
|
|
5775accd35 | ||
|
|
fda8e3fdb6 | ||
|
|
3f72f89b15 | ||
|
|
6727b65ed4 | ||
|
|
583a04167a | ||
|
|
6fc9bd4ea0 | ||
|
|
1361ed1a16 | ||
|
|
2781ed2ae1 | ||
|
|
dd9258dc42 | ||
|
|
7c39a99e60 | ||
|
|
96a30e8e24 | ||
|
|
004047b6bb | ||
|
|
10ee8d33fa | ||
|
|
1bbb92d92b | ||
|
|
c246c036c9 | ||
|
|
b435b84782 | ||
|
|
9607c398ff | ||
|
|
2e2ce32c54 | ||
|
|
4298e36d74 | ||
|
|
e3a29178b6 | ||
|
|
613a4220d7 | ||
|
|
91b3fe5b1d | ||
|
|
8bb4db227a | ||
|
|
b82f232642 | ||
|
|
62c92820f0 | ||
|
|
80bb49776a | ||
|
|
cad7687de6 | ||
|
|
f0a680abc6 | ||
|
|
318ba9816b | ||
|
|
89ff7a4603 | ||
|
|
4586a0c1fe | ||
|
|
2682a80815 | ||
|
|
6f159958a1 | ||
|
|
d59ed1e160 | ||
|
|
66a1f25465 | ||
|
|
e5e33d4486 | ||
|
|
b77c17a999 | ||
|
|
e698e30826 | ||
|
|
e448cafb21 | ||
|
|
45faf0cf18 | ||
|
|
91e3788b73 | ||
|
|
a890b4f01d | ||
|
|
c958e0e458 | ||
|
|
b831d71bf7 | ||
|
|
0cc104ef11 | ||
|
|
b9c441108a | ||
|
|
4bdacf7ac1 | ||
|
|
7435b7c702 | ||
|
|
42c7371d16 | ||
|
|
afe5ee9abb | ||
|
|
14c0063e7c | ||
|
|
064cf4c5c3 | ||
|
|
c9452d29c1 | ||
|
|
781de29591 | ||
|
|
a202b5efdd | ||
|
|
f02ac2eaef | ||
|
|
c82ab161d0 | ||
|
|
538c20ee56 | ||
|
|
995a672bf3 | ||
|
|
7acbd0904b | ||
|
|
3b95453363 | ||
|
|
bd91ea5c50 | ||
|
|
f387846732 | ||
|
|
7b0ba6112e | ||
|
|
6f927be081 | ||
|
|
1e7f5bf04e | ||
|
|
6ee934a745 | ||
|
|
0d626ad4b8 | ||
|
|
3379a68476 | ||
|
|
6afdfa3b97 | ||
|
|
6337a72b0f | ||
|
|
4135df693c | ||
|
|
75bd4d4b77 | ||
|
|
5d9b45a2f8 | ||
|
|
2c4ef1f3a9 | ||
|
|
1ad39faf24 | ||
|
|
dc88fb74fd | ||
|
|
062e9e467d | ||
|
|
8b8473b92c | ||
|
|
dd76909d45 | ||
|
|
ebbd48dcf6 | ||
|
|
aa27af811f | ||
|
|
81d6fcbe3f | ||
|
|
8a00a9c389 | ||
|
|
3c96f1c687 | ||
|
|
e0497f590a | ||
|
|
40cf80406e | ||
|
|
a469136049 | ||
|
|
3c4a9e2352 | ||
|
|
f568b44bc0 | ||
|
|
195456df44 | ||
|
|
2a455becc7 | ||
|
|
300d6c2da0 | ||
|
|
3676ea3f61 | ||
|
|
2de7788287 | ||
|
|
033fe6b15a | ||
|
|
de5be56039 | ||
|
|
8a9683b389 | ||
|
|
9cbd462a84 | ||
|
|
7d13471496 | ||
|
|
43d9237c3f | ||
|
|
d96148e54e | ||
|
|
9a74177d73 | ||
|
|
36dbdb57f0 | ||
|
|
29568b43d8 | ||
|
|
637ede39c7 | ||
|
|
2ea789fb49 | ||
|
|
fb4bdabe6d | ||
|
|
dac782098d | ||
|
|
236775d390 | ||
|
|
76268cecb1 | ||
|
|
fe58edd42e | ||
|
|
330ac9342a | ||
|
|
398397d7e4 | ||
|
|
4d8e2a5d03 | ||
|
|
33d483d4ab | ||
|
|
f60ce50338 | ||
|
|
8ccabc4820 | ||
|
|
23a85f8631 | ||
|
|
ee790860a3 | ||
|
|
a1ec1b9963 | ||
|
|
5e06444ccc | ||
|
|
0151b1d4f0 | ||
|
|
08b00033b9 | ||
|
|
682eda9270 | ||
|
|
84c21d65c7 | ||
|
|
b68515e427 | ||
|
|
d87ea4199c | ||
|
|
748fbdb6de | ||
|
|
7da2a69dd0 | ||
|
|
ca5794e069 | ||
|
|
35afcd73ec | ||
|
|
2051ada800 | ||
|
|
c091fbdb27 | ||
|
|
751bb76d42 | ||
|
|
b4432b923a | ||
|
|
19ca957283 | ||
|
|
e44409a7db | ||
|
|
192217c832 | ||
|
|
f68bbd04fd | ||
|
|
a2d2ac7c73 | ||
|
|
b3281b3b1e | ||
|
|
ac4e3579a3 | ||
|
|
a5c9ff9fa1 | ||
|
|
0903730ab6 | ||
|
|
406bff1a3b | ||
|
|
990e5a8a1a | ||
|
|
13c5474e86 | ||
|
|
492a8e3964 | ||
|
|
f60d2e358c | ||
|
|
7b9249894b | ||
|
|
478fe3d164 | ||
|
|
8915eb88ca | ||
|
|
eb552a126b | ||
|
|
913c07e257 | ||
|
|
e71760afa4 | ||
|
|
25e3005f2e | ||
|
|
eb5031f519 | ||
|
|
901442b5ef | ||
|
|
fa04ebe851 | ||
|
|
4669303f9b | ||
|
|
f9c6cd1e3d | ||
|
|
e591a7117d | ||
|
|
d43a5aa68a | ||
|
|
4039724a79 | ||
|
|
8828447367 | ||
|
|
73d3e2ee1b | ||
|
|
66e1829e64 | ||
|
|
fb8b0c29df | ||
|
|
bb8fbdc66b | ||
|
|
86340f9821 | ||
|
|
2e2c281748 | ||
|
|
15ab5ce70d | ||
|
|
c202c534c5 | ||
|
|
1fcb40f6d7 | ||
|
|
0ab201f9cf | ||
|
|
2baa7c20e7 | ||
|
|
7072ce4dbc | ||
|
|
a0951e7a97 | ||
|
|
f102630909 | ||
|
|
98d1ff3204 | ||
|
|
46f0cfe517 | ||
|
|
a623b0c1ad | ||
|
|
a89e05a5aa | ||
|
|
da93328d50 | ||
|
|
9bb803abfd | ||
|
|
3d1e4a2863 | ||
|
|
b6c95e1f93 | ||
|
|
99eac825e7 | ||
|
|
a204c45ce0 | ||
|
|
45381d4449 | ||
|
|
b086bbf015 | ||
|
|
24d75058ad | ||
|
|
e4da7989a4 | ||
|
|
ce62355197 | ||
|
|
b1308501be | ||
|
|
568484f493 | ||
|
|
102e48106e | ||
|
|
0711a1b51e | ||
|
|
93fca22c64 | ||
|
|
10315c8269 |
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
# Ignore git
|
||||
.github
|
||||
.git
|
||||
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 项目讨论
|
||||
url: https://github.com/jxxghp/MoviePilot/discussions/new/choose
|
||||
about: discussion
|
||||
- name: Telegram 频道
|
||||
url: https://t.me/moviepilot_channel
|
||||
about: 更新日志
|
||||
|
||||
17
.github/ISSUE_TEMPLATE/discussion.yml
vendored
17
.github/ISSUE_TEMPLATE/discussion.yml
vendored
@@ -1,17 +0,0 @@
|
||||
name: 项目讨论
|
||||
description: discussion
|
||||
title: "[Discussion]: "
|
||||
labels: ["discussion"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
[BUG](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=bug&template=bug_report.yml&title=%5BBUG%5D%3A) 与 [Feature Request](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D%3A+) 请转到对应位置提交。
|
||||
- type: textarea
|
||||
id: discussion
|
||||
attributes:
|
||||
label: 项目讨论
|
||||
description: 请详细描述需要讨论的内容。
|
||||
placeholder: "项目讨论"
|
||||
validations:
|
||||
required: true
|
||||
12
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
12
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -14,6 +14,18 @@ body:
|
||||
description: 目前使用的程序版本
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: type
|
||||
attributes:
|
||||
label: 功能改进类型
|
||||
description: 你需要在下面哪个方面改进功能
|
||||
options:
|
||||
- 主程序
|
||||
- 插件
|
||||
- Docker
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-request
|
||||
attributes:
|
||||
|
||||
57
.github/workflows/build-docker.yml
vendored
57
.github/workflows/build-docker.yml
vendored
@@ -1,57 +0,0 @@
|
||||
name: MoviePilot Docker
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Docker Image
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USERNAME }}/moviepilot
|
||||
|
||||
-
|
||||
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: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
-
|
||||
name: Set Up Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
-
|
||||
name: Login DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
-
|
||||
name: Build Image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
MOVIEPILOT_FRONTEND_VERSION=${{ env.app_version }}
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USERNAME }}/moviepilot:latest
|
||||
${{ secrets.DOCKER_USERNAME }}/moviepilot:${{ env.app_version }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
65
.github/workflows/build-windows.yml
vendored
Normal file
65
.github/workflows/build-windows.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: MoviePilot Windows Builder
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- version.py
|
||||
|
||||
jobs:
|
||||
Windows-build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Release Version
|
||||
id: release_version
|
||||
run: |
|
||||
$app_version = Select-String -Path "version.py" -Pattern "APP_VERSION\s=\s'v(.*)'" | ForEach-Object { $_.Matches.Groups[1].Value }
|
||||
$env:GITHUB_ENV += "app_version=$app_version"
|
||||
|
||||
- name: Init Python 3.11.4
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11.4'
|
||||
|
||||
- name: Install Dependent Packages
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install wheel pyinstaller
|
||||
pip install -r requirements.txt
|
||||
shell: pwsh
|
||||
|
||||
- name: Pyinstaller
|
||||
run: |
|
||||
pyinstaller windows.spec
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Windows File
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: windows
|
||||
path: dist/MoviePilot.exe
|
||||
|
||||
- name: Generate Release
|
||||
id: generate_release
|
||||
uses: actions/create-release@latest
|
||||
with:
|
||||
tag_name: v${{ env.app_version }}
|
||||
release_name: v${{ env.app_version }}
|
||||
body: ${{ github.event.commits[0].message }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload Release Asset
|
||||
uses: dwenegar/upload-release-assets@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
release_id: ${{ steps.generate_release.outputs.id }}
|
||||
assets_path: |
|
||||
dist/MoviePilot.exe
|
||||
59
.github/workflows/build.yml
vendored
Normal file
59
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: MoviePilot Docker Builder
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- version.py
|
||||
|
||||
jobs:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Docker Image
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Release version
|
||||
id: release_version
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USERNAME }}/moviepilot
|
||||
tags: |
|
||||
type=raw,value=${{ env.app_version }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set Up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64/v8
|
||||
push: true
|
||||
build-args: |
|
||||
MOVIEPILOT_VERSION=${{ env.app_version }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha, scope=${{ github.workflow }}
|
||||
cache-to: type=gha, scope=${{ github.workflow }}
|
||||
79
Dockerfile
79
Dockerfile
@@ -1,40 +1,18 @@
|
||||
FROM python:3.11.4-slim-bullseye
|
||||
ARG MOVIEPILOT_FRONTEND_VERSION
|
||||
ARG MOVIEPILOT_VERSION
|
||||
ENV LANG="C.UTF-8" \
|
||||
HOME="/moviepilot" \
|
||||
TERM="xterm" \
|
||||
TZ="Asia/Shanghai" \
|
||||
PUID=0 \
|
||||
PGID=0 \
|
||||
UMASK=000 \
|
||||
MOVIEPILOT_AUTO_UPDATE=true \
|
||||
MOVIEPILOT_CN_UPDATE=false \
|
||||
PORT=3001 \
|
||||
NGINX_PORT=3000 \
|
||||
CONFIG_DIR="/config" \
|
||||
API_TOKEN="moviepilot" \
|
||||
AUTH_SITE="iyuu" \
|
||||
DOWNLOAD_PATH="/downloads" \
|
||||
DOWNLOAD_CATEGORY="false" \
|
||||
TORRENT_TAG="MOVIEPILOT" \
|
||||
LIBRARY_PATH="" \
|
||||
LIBRARY_CATEGORY="false" \
|
||||
TRANSFER_TYPE="copy" \
|
||||
COOKIECLOUD_HOST="https://nastool.org/cookiecloud" \
|
||||
COOKIECLOUD_KEY="" \
|
||||
COOKIECLOUD_PASSWORD="" \
|
||||
MESSAGER="telegram" \
|
||||
TELEGRAM_TOKEN="" \
|
||||
TELEGRAM_CHAT_ID="" \
|
||||
DOWNLOADER="qbittorrent" \
|
||||
QB_HOST="127.0.0.1:8080" \
|
||||
QB_USER="admin" \
|
||||
QB_PASSWORD="adminadmin" \
|
||||
MEDIASERVER="emby" \
|
||||
EMBY_HOST="http://127.0.0.1:8096" \
|
||||
EMBY_API_KEY=""
|
||||
MOVIEPILOT_AUTO_UPDATE=true \
|
||||
MOVIEPILOT_AUTO_UPDATE_DEV=false \
|
||||
CONFIG_DIR="/config"
|
||||
WORKDIR "/app"
|
||||
COPY . .
|
||||
RUN apt-get update \
|
||||
RUN apt-get update -y \
|
||||
&& apt-get -y install \
|
||||
musl-dev \
|
||||
nginx \
|
||||
@@ -47,29 +25,28 @@ RUN apt-get update \
|
||||
curl \
|
||||
busybox \
|
||||
dumb-init \
|
||||
jq \
|
||||
haproxy \
|
||||
&& \
|
||||
if [ "$(uname -m)" = "x86_64" ]; \
|
||||
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
|
||||
elif [ "$(uname -m)" = "aarch64" ]; \
|
||||
then ln -s /usr/lib/aarch64-linux-musl/libc.so /lib/libc.musl-aarch64.so.1; \
|
||||
fi \
|
||||
&& cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
||||
&& cp /app/update /usr/local/bin/mp_update \
|
||||
&& chmod +x /app/start /usr/local/bin/mp_update \
|
||||
&& mkdir -p ${HOME} \
|
||||
&& groupadd -r moviepilot -g 911 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 911 \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf \
|
||||
/tmp/* \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y build-essential \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install Cython \
|
||||
&& pip install -r requirements.txt \
|
||||
&& playwright install-deps chromium \
|
||||
&& python_ver=$(python3 -V | awk '{print $2}') \
|
||||
&& echo "/app/" > /usr/local/lib/python${python_ver%.*}/site-packages/app.pth \
|
||||
&& echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \
|
||||
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
|
||||
&& locale-gen zh_CN.UTF-8 \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/v${MOVIEPILOT_FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
|
||||
&& mv /dist /public \
|
||||
&& apt-get remove -y build-essential \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
@@ -78,6 +55,22 @@ RUN apt-get update \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY . .
|
||||
RUN cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
||||
&& cp -f /app/update /usr/local/bin/mp_update \
|
||||
&& cp -f /app/entrypoint /entrypoint \
|
||||
&& chmod +x /entrypoint /usr/local/bin/mp_update \
|
||||
&& mkdir -p ${HOME} /var/lib/haproxy/server-state \
|
||||
&& groupadd -r moviepilot -g 911 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 911 \
|
||||
&& python_ver=$(python3 -V | awk '{print $2}') \
|
||||
&& echo "/app/" > /usr/local/lib/python${python_ver%.*}/site-packages/app.pth \
|
||||
&& echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \
|
||||
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
|
||||
&& locale-gen zh_CN.UTF-8 \
|
||||
&& FRONTEND_VERSION=$(curl -sL "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | jq -r .tag_name) \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
|
||||
&& mv /dist /public
|
||||
EXPOSE 3000
|
||||
VOLUME ["/config"]
|
||||
ENTRYPOINT [ "/app/start" ]
|
||||
VOLUME [ "/config" ]
|
||||
ENTRYPOINT [ "/entrypoint" ]
|
||||
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
263
README.md
263
README.md
@@ -15,26 +15,25 @@ Docker:https://hub.docker.com/r/jxxghp/moviepilot
|
||||
|
||||
## 安装
|
||||
|
||||
1. **安装CookieCloud插件**
|
||||
### 1. **安装CookieCloud插件**
|
||||
|
||||
站点信息需要通过CookieCloud同步获取,因此需要安装CookieCloud插件,将浏览器中的站点Cookie数据同步到云端后再同步到MoviePilot使用。 插件下载地址请点击 [这里](https://github.com/easychen/CookieCloud/releases)。
|
||||
|
||||
2. **安装CookieCloud服务端(可选)**
|
||||
### 2. **安装CookieCloud服务端(可选)**
|
||||
|
||||
MoviePilot内置了公共的CookieCloud服务器,如果需要自建服务,可参考 [CookieCloud](https://github.com/easychen/CookieCloud) 项目进行安装。
|
||||
```shell
|
||||
docker pull easychen/cookiecloud:latest
|
||||
```
|
||||
MoviePilot内置了公共CookieCloud服务器,如果需要自建服务,可参考 [CookieCloud](https://github.com/easychen/CookieCloud) 项目进行搭建,docker镜像请点击 [这里](https://hub.docker.com/r/easychen/cookiecloud)。
|
||||
|
||||
3. **安装配套管理软件**
|
||||
**声明:** 本项目不会收集用户敏感数据,Cookie同步也是基于CookieCloud项目实现,非本项目提供的能力。技术角度上CookieCloud采用端到端加密,在个人不泄露`用户KEY`和`端对端加密密码`的情况下第三方无法窃取任何用户信息(包括服务器持有者)。如果你不放心,可以不使用公共服务或者不使用本项目,但如果使用后发生了任何信息泄露与本项目无关!
|
||||
|
||||
MoviePilot跟NAStool一样,需要配套下载器和媒体服务器使用。
|
||||
### 3. **安装配套管理软件**
|
||||
|
||||
MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- 下载器支持:qBittorrent、Transmission,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,推荐使用QB。
|
||||
- 媒体服务器支持:Jellyfin、Emby、Plex,推荐使用Emby。
|
||||
|
||||
4. **安装MoviePilot**
|
||||
### 4. **安装MoviePilot**
|
||||
|
||||
目前仅提供docker镜像,后续可能会提供更多安装方式。
|
||||
目前仅提供docker镜像,点击 [这里](https://hub.docker.com/r/jxxghp/moviepilot) 或执行命令:
|
||||
|
||||
```shell
|
||||
docker pull jxxghp/moviepilot:latest
|
||||
@@ -42,115 +41,153 @@ docker pull jxxghp/moviepilot:latest
|
||||
|
||||
## 配置
|
||||
|
||||
项目的所有配置均通过环境变量进行设置,部分环境建立容器后会自动显示待配置项,如未自动显示配置项则需要手动增加对应环境变量。
|
||||
项目的所有配置均通过环境变量进行设置,支持两种配置方式:
|
||||
- 在docker环境变量部分进行参数配置,部分环境建立容器后会自动显示待配置项,如未自动显示配置项则需要手动增加对应环境变量。
|
||||
- 下载 [app.env](https://github.com/jxxghp/MoviePilot/raw/main/config/app.env) 文件,修改好配置后放置到配置文件映射路径根目录,配置项可根据说明自主增减。
|
||||
|
||||
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件 > 默认值,部分参数如路径映射、站点认证、权限端口等必须通过环境变量进行配置。
|
||||
|
||||
> $\color{red}{*}$ 号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
|
||||
|
||||
### 1. **基础设置**
|
||||
|
||||
- **NGINX_PORT:** WEB服务端口,默认`3000`,可自行修改,但不能为`3001`
|
||||
- **SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
|
||||
- **SUPERUSER_PASSWORD:** 超级管理员初始密码,默认`password`,建议修改为复杂密码
|
||||
- **API_TOKEN:** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
||||
- **PROXY_HOST:** 网络代理(可选),访问themoviedb需要使用代理访问,格式为`http(s)://ip:port`
|
||||
- **NGINX_PORT $\color{red}{*}$ :** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突(仅支持环境变量配置)
|
||||
- **PORT $\color{red}{*}$ :** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突(仅支持环境变量配置)
|
||||
- **PUID**:运行程序用户的`uid`,默认`0`(仅支持环境变量配置)
|
||||
- **PGID**:运行程序用户的`gid`,默认`0`(仅支持环境变量配置)
|
||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`(仅支持环境变量配置)
|
||||
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`false`,默认`true` **注意:如果出现网络问题可以配置`PROXY_HOST`,具体看下方`PROXY_HOST`解释**(仅支持环境变量配置)
|
||||
- **MOVIEPILOT_AUTO_UPDATE_DEV**:重启时更新到未发布的开发版本代码,`true`/`false`,默认`false`(仅支持环境变量配置)
|
||||
---
|
||||
- **SUPERUSER $\color{red}{*}$ :** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
|
||||
- **SUPERUSER_PASSWORD $\color{red}{*}$ :** 超级管理员初始密码,默认`password`,建议修改为复杂密码
|
||||
- **API_TOKEN $\color{red}{*}$ :** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
||||
- **PROXY_HOST:** 网络代理,访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`、`socks5://user:pass@host:port`(可选)
|
||||
- **TMDB_API_DOMAIN:** TMDB API地址,默认`api.themoviedb.org`,也可配置为`api.tmdb.org`或其它中转代理服务地址,能连通即可
|
||||
- **DOWNLOAD_PATH:** 下载保存目录,**注意:需要将`moviepilot`及`下载器`的映射路径保持一致**,否则会导致下载文件无法转移
|
||||
- **DOWNLOAD_MOVIE_PATH:** 电影下载保存目录,**必须是DOWNLOAD_PATH的下级路径**,不设置则下载到DOWNLOAD_PATH
|
||||
- **DOWNLOAD_TV_PATH:** 电视剧下载保存目录,**必须是DOWNLOAD_PATH的下级路径**,不设置则下载到DOWNLOAD_PATH
|
||||
- **DOWNLOAD_CATEGORY:** 下载二级分类开关,`true`/`false`,默认`false`,开启后会根据配置`category.yaml`自动在下载目录下建立二级目录分类
|
||||
- **DOWNLOAD_SUBTITLE:** 下载站点字幕,`true`/`false`,默认`true`
|
||||
- **REFRESH_MEDIASERVER:** 入库刷新媒体库,`true`/`false`,默认`true`
|
||||
- **TMDB_IMAGE_DOMAIN:** TMDB图片地址,默认`image.tmdb.org`,可配置为其它中转代理以加速TMDB图片显示,如:`static-mdb.v.geilijiasu.com`
|
||||
---
|
||||
- **SCRAP_METADATA:** 刮削入库的媒体文件,`true`/`false`,默认`true`
|
||||
- **TORRENT_TAG:** 种子标签,默认为`MOVIEPILOT`,设置后只有MoviePilot添加的下载才会处理,留空所有下载器中的任务均会处理
|
||||
- **LIBRARY_PATH:** 媒体库目录,多个目录使用`,`分隔
|
||||
- **LIBRARY_MOVIE_NAME:** 电影媒体库目录名,默认`电影`
|
||||
- **LIBRARY_TV_NAME:** 电视剧媒体库目录名,默认`电视剧`
|
||||
- **LIBRARY_CATEGORY:** 媒体库二级分类开关,`true`/`false`,默认`false`,开启后会根据配置`category.yaml`自动在媒体库目录下建立二级目录分类
|
||||
- **TRANSFER_TYPE:** 转移方式,支持`link`/`copy`/`move`/`softlink`
|
||||
- **COOKIECLOUD_HOST:** CookieCloud服务器地址,格式:`http://ip:port`,必须配置,否则无法添加站点
|
||||
- **COOKIECLOUD_KEY:** CookieCloud用户KEY
|
||||
- **COOKIECLOUD_PASSWORD:** CookieCloud端对端加密密码
|
||||
- **COOKIECLOUD_INTERVAL:** CookieCloud同步间隔(分钟)
|
||||
- **USER_AGENT:** CookieCloud对应的浏览器UA,可选,设置后可增加连接站点的成功率,同步站点后可以在管理界面中修改
|
||||
- **SCRAP_SOURCE:** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
|
||||
- **SCRAP_FOLLOW_TMDB:** 新增已入库媒体是否跟随TMDB信息变化,`true`/`false`,默认`true`
|
||||
---
|
||||
- **TRANSFER_TYPE $\color{red}{*}$ :** 整理转移方式,支持`link`/`copy`/`move`/`softlink` **注意:在`link`和`softlink`转移方式下,转移后的文件会继承源文件的权限掩码,不受`UMASK`影响**
|
||||
- **LIBRARY_PATH $\color{red}{*}$ :** 媒体库目录,多个目录使用`,`分隔
|
||||
- **LIBRARY_MOVIE_NAME:** 电影媒体库目录名称(不是完整路径),默认`电影`
|
||||
- **LIBRARY_TV_NAME:** 电视剧媒体库目录称(不是完整路径),默认`电视剧`
|
||||
- **LIBRARY_ANIME_NAME:** 动漫媒体库目录称(不是完整路径),默认`电视剧/动漫`
|
||||
- **LIBRARY_CATEGORY:** 媒体库二级分类开关,`true`/`false`,默认`false`,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在媒体库目录下建立二级目录分类
|
||||
---
|
||||
- **COOKIECLOUD_HOST $\color{red}{*}$ :** CookieCloud服务器地址,格式:`http(s)://ip:port`,不配置默认使用内建服务器`https://movie-pilot.org/cookiecloud`
|
||||
- **COOKIECLOUD_KEY $\color{red}{*}$ :** CookieCloud用户KEY
|
||||
- **COOKIECLOUD_PASSWORD $\color{red}{*}$ :** CookieCloud端对端加密密码
|
||||
- **COOKIECLOUD_INTERVAL $\color{red}{*}$ :** CookieCloud同步间隔(分钟)
|
||||
- **USER_AGENT $\color{red}{*}$ :** CookieCloud保存Cookie对应的浏览器UA,建议配置,设置后可增加连接站点的成功率,同步站点后可以在管理界面中修改
|
||||
- **OCR_HOST:** OCR识别服务器地址,格式:`http(s)://ip:port`,用于识别站点验证码实现自动登录获取Cookie等,不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
|
||||
---
|
||||
- **SUBSCRIBE_MODE:** 订阅模式,`rss`/`spider`,默认`spider`,`rss`模式通过定时刷新RSS来匹配订阅(RSS地址会自动获取,也可手动维护),对站点压力小,同时可设置订阅刷新周期,24小时运行,但订阅和下载通知不能过滤和显示免费,推荐使用rss模式。
|
||||
- **SUBSCRIBE_RSS_INTERVAL:** RSS订阅模式刷新时间间隔(分钟),默认`30`分钟,不能小于5分钟。
|
||||
- **SUBSCRIBE_SEARCH:** 订阅搜索,`true`/`false`,默认`false`,开启后会每隔24小时对所有订阅进行全量搜索,以补齐缺失剧集(一般情况下正常订阅即可,订阅搜索只做为兜底,会增加站点压力,不建议开启)。
|
||||
- **SEARCH_SOURCE:** 媒体信息搜索来源,`themoviedb`/`douban`,默认`themoviedb`
|
||||
---
|
||||
- **AUTO_DOWNLOAD_USER:** 远程交互搜索时自动择优下载的用户ID,多个用户使用,分割,未设置需要选择资源或者回复`0`
|
||||
- **MESSAGER $\color{red}{*}$ :** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
||||
|
||||
- `wechat`设置项:
|
||||
|
||||
**MESSAGER:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
||||
- **WECHAT_CORPID:** WeChat企业ID
|
||||
- **WECHAT_APP_SECRET:** WeChat应用Secret
|
||||
- **WECHAT_APP_ID:** WeChat应用ID
|
||||
- **WECHAT_TOKEN:** WeChat消息回调的Token
|
||||
- **WECHAT_ENCODING_AESKEY:** WeChat消息回调的EncodingAESKey
|
||||
- **WECHAT_ADMINS:** WeChat管理员列表,多个管理员用英文逗号分隔(可选)
|
||||
- **WECHAT_PROXY:** WeChat代理服务器(后面不要加/)
|
||||
|
||||
`wechat`设置项:
|
||||
- `telegram`设置项:
|
||||
|
||||
- **WECHAT_CORPID:** WeChat企业ID
|
||||
- **WECHAT_APP_SECRET:** WeChat应用Secret
|
||||
- **WECHAT_APP_ID:** WeChat应用ID
|
||||
- **WECHAT_TOKEN:** WeChat消息回调的Token
|
||||
- **WECHAT_ENCODING_AESKEY:** WeChat消息回调的EncodingAESKey
|
||||
- **WECHAT_ADMINS:** WeChat管理员列表,多个管理员用英文逗号分隔(可选)
|
||||
- **WECHAT_PROXY:** WeChat代理服务器(后面不要加/)
|
||||
- **TELEGRAM_TOKEN:** Telegram Bot Token
|
||||
- **TELEGRAM_CHAT_ID:** Telegram Chat ID
|
||||
- **TELEGRAM_USERS:** Telegram 用户ID,多个使用,分隔,只有用户ID在列表中才可以使用Bot,如未设置则均可以使用Bot
|
||||
- **TELEGRAM_ADMINS:** Telegram 管理员ID,多个使用,分隔,只有管理员才可以操作Bot菜单,如未设置则均可以操作菜单(可选)
|
||||
|
||||
`telegram`设置项:
|
||||
- `slack`设置项:
|
||||
|
||||
- **TELEGRAM_TOKEN:** Telegram Bot Token
|
||||
- **TELEGRAM_CHAT_ID:** Telegram Chat ID
|
||||
- **TELEGRAM_USERS:** Telegram 用户ID,多个使用,分隔,只有用户ID在列表中才可以使用Bot,如未设置则均可以使用Bot
|
||||
- **TELEGRAM_ADMINS:** Telegram 管理员ID,多个使用,分隔,只有管理员才可以操作Bot菜单,如未设置则均可以操作菜单
|
||||
- **SLACK_OAUTH_TOKEN:** Slack Bot User OAuth Token
|
||||
- **SLACK_APP_TOKEN:** Slack App-Level Token
|
||||
- **SLACK_CHANNEL:** Slack 频道名称,默认`全体`(可选)
|
||||
|
||||
- `synologychat`设置项:
|
||||
|
||||
`slack`设置项:
|
||||
- **SYNOLOGYCHAT_WEBHOOK:** 在Synology Chat中创建机器人,获取机器人`传入URL`
|
||||
- **SYNOLOGYCHAT_TOKEN:** SynologyChat机器人`令牌`
|
||||
|
||||
- **SLACK_OAUTH_TOKEN:** Slack Bot User OAuth Token
|
||||
- **SLACK_APP_TOKEN:** Slack App-Level Token
|
||||
- **SLACK_CHANNEL:** Slack 频道名称,默认`全体`
|
||||
---
|
||||
- **DOWNLOAD_PATH $\color{red}{*}$ :** 下载保存目录,**注意:需要将`moviepilot`及`下载器`的映射路径保持一致**,否则会导致下载文件无法转移
|
||||
- **DOWNLOAD_MOVIE_PATH:** 电影下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_TV_PATH:** 电视剧下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_ANIME_PATH:** 动漫下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_CATEGORY:** 下载二级分类开关,`true`/`false`,默认`false`,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在下载目录下建立二级目录分类
|
||||
- **DOWNLOAD_SUBTITLE:** 下载站点字幕,`true`/`false`,默认`true`
|
||||
- **DOWNLOADER_MONITOR:** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
|
||||
- **TORRENT_TAG:** 下载器种子标签,默认为`MOVIEPILOT`,设置后只有MoviePilot添加的下载才会处理,留空所有下载器中的任务均会处理
|
||||
- **DOWNLOADER $\color{red}{*}$ :** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
|
||||
|
||||
- `qbittorrent`设置项:
|
||||
|
||||
**DOWNLOADER:** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
|
||||
- **QB_HOST:** qbittorrent地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **QB_USER:** qbittorrent用户名
|
||||
- **QB_PASSWORD:** qbittorrent密码
|
||||
- **QB_CATEGORY:** qbittorrent分类自动管理,`true`/`false`,默认`false`,开启后会将下载二级分类传递到下载器,由下载器管理下载目录,需要同步开启`DOWNLOAD_CATEGORY`
|
||||
|
||||
`qbittorrent`设置项:
|
||||
- `transmission`设置项:
|
||||
|
||||
- **QB_HOST:** qbittorrent地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **QB_USER:** qbittorrent用户名
|
||||
- **QB_PASSWORD:** qbittorrent密码
|
||||
- **TR_HOST:** transmission地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **TR_USER:** transmission用户名
|
||||
- **TR_PASSWORD:** transmission密码
|
||||
|
||||
`transmission`设置项:
|
||||
---
|
||||
- **REFRESH_MEDIASERVER:** 入库后是否刷新媒体服务器,`true`/`false`,默认`true`
|
||||
- **MEDIASERVER $\color{red}{*}$ :** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
|
||||
|
||||
- **TR_HOST:** transmission地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **TR_USER:** transmission用户名
|
||||
- **TR_PASSWORD:** transmission密码
|
||||
- `emby`设置项:
|
||||
|
||||
**MEDIASERVER:** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
|
||||
- **EMBY_HOST:** Emby服务器地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **EMBY_API_KEY:** Emby Api Key,在`设置->高级->API密钥`处生成
|
||||
|
||||
**MEDIASERVER_SYNC_INTERVAL:** 媒体服务器同步间隔(小时),默认`6`,留空则不同步
|
||||
- `jellyfin`设置项:
|
||||
|
||||
`emby`设置项:
|
||||
- **JELLYFIN_HOST:** Jellyfin服务器地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **JELLYFIN_API_KEY:** Jellyfin Api Key,在`设置->高级->API密钥`处生成
|
||||
|
||||
- **EMBY_HOST:** Emby服务器地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **EMBY_API_KEY:** Emby Api Key,在`设置->高级->API密钥`处生成
|
||||
- `plex`设置项:
|
||||
|
||||
`jellyfin`设置项:
|
||||
- **PLEX_HOST:** Plex服务器地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **PLEX_TOKEN:** Plex网页Url中的`X-Plex-Token`,通过浏览器F12->网络从请求URL中获取
|
||||
|
||||
- **JELLYFIN_HOST:** Jellyfin服务器地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **JELLYFIN_API_KEY:** Jellyfin Api Key,在`设置->高级->API密钥`处生成
|
||||
|
||||
`plex`设置项:
|
||||
|
||||
- **PLEX_HOST:** Plex服务器地址,格式:`ip:port`,https需要添加`https://`前缀
|
||||
- **PLEX_TOKEN:** Plex网页Url中的`X-Plex-Token`,通过浏览器F12->网络从请求URL中获取
|
||||
- **MEDIASERVER_SYNC_INTERVAL:** 媒体服务器同步间隔(小时),默认`6`,留空则不同步
|
||||
- **MEDIASERVER_SYNC_BLACKLIST:** 媒体服务器同步黑名单,多个媒体库名称使用,分割
|
||||
|
||||
|
||||
### 2. **用户认证**
|
||||
|
||||
- **AUTH_SITE:** 认证站点,支持`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`iyuu`
|
||||
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数(**仅能通过docker环境变量配置**)
|
||||
|
||||
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
|
||||
- **AUTH_SITE $\color{red}{*}$ :** 认证站点,支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`ptlsp`/`xingtan`
|
||||
|
||||
| 站点 | 参数 |
|
||||
|----|-------------------------------------------------------|
|
||||
| iyuu | `IYUU_SIGN`:IYUU登录令牌 |
|
||||
| hhclub | `HHCLUB_USERNAME`:用户名<br/>`HHCLUB_PASSKEY`:密钥 |
|
||||
| audiences | `AUDIENCES_UID`:用户ID<br/>`AUDIENCES_PASSKEY`:密钥 |
|
||||
| hddolby | `HDDOLBY_ID`:用户ID<br/>`HDDOLBY_PASSKEY`:密钥 |
|
||||
| zmpt | `ZMPT_UID`:用户ID<br/>`ZMPT_PASSKEY`:密钥 |
|
||||
| freefarm | `FREEFARM_UID`:用户ID<br/>`FREEFARM_PASSKEY`:密钥 |
|
||||
| hdfans | `HDFANS_UID`:用户ID<br/>`HDFANS_PASSKEY`:密钥 |
|
||||
| 站点 | 参数 |
|
||||
|:------------:|:-----------------------------------------------------:|
|
||||
| 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_UID`:密钥 |
|
||||
| 1ptba | `1PTBA_UID`:用户ID<br/>`1PTBA_PASSKEY`:密钥 |
|
||||
| icc2022 | `ICC2022_UID`:用户ID<br/>`ICC2022_PASSKEY`:密钥 |
|
||||
| leaves | `LEAVES_UID`:用户ID<br/>`LEAVES_PASSKEY`:密钥 |
|
||||
| 1ptba | `1PTBA_UID`:用户ID<br/>`1PTBA_PASSKEY`:密钥 |
|
||||
| icc2022 | `ICC2022_UID`:用户ID<br/>`ICC2022_PASSKEY`:密钥 |
|
||||
| ptlsp | `PTLSP_UID`:用户ID<br/>`PTLSP_PASSKEY`:密钥 |
|
||||
| xingtan | `XINGTAN_UID`:用户ID<br/>`XINGTAN_PASSKEY`:密钥 |
|
||||
|
||||
|
||||
### 2. **进阶配置**
|
||||
@@ -166,10 +203,12 @@ docker pull jxxghp/moviepilot:latest
|
||||
> `original_title`: 原语种标题
|
||||
> `name`: 识别名称
|
||||
> `year`: 年份
|
||||
> `edition`: 版本
|
||||
> `resourceType`:资源类型
|
||||
> `effect`:特效
|
||||
> `edition`: 版本(资源类型+特效)
|
||||
> `videoFormat`: 分辨率
|
||||
> `releaseGroup`: 制作组/字幕组
|
||||
> `effect`: 特效
|
||||
> `customization`: 自定义占位符
|
||||
> `videoCodec`: 视频编码
|
||||
> `audioCodec`: 音频编码
|
||||
> `tmdbid`: TMDBID
|
||||
@@ -190,6 +229,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
> `season`: 季号
|
||||
> `episode`: 集号
|
||||
> `season_episode`: 季集 SxxExx
|
||||
> `episode_title`: 集标题
|
||||
|
||||
`TV_RENAME_FORMAT`默认配置格式:
|
||||
|
||||
@@ -198,9 +238,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
```
|
||||
|
||||
|
||||
### 3. **过滤规则**
|
||||
|
||||
在`设定`-`规则`中设定,规则说明:
|
||||
### 3. **优先级规则**
|
||||
|
||||
- 仅支持使用内置规则进行排列组合,内置规则有:`蓝光原盘`、`4K`、`1080P`、`中文字幕`、`特效字幕`、`H265`、`H264`、`杜比`、`HDR`、`REMUX`、`WEB-DL`、`免费`、`国语配音` 等
|
||||
- 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高
|
||||
@@ -209,17 +247,17 @@ docker pull jxxghp/moviepilot:latest
|
||||
|
||||
## 使用
|
||||
|
||||
- 通过CookieCloud同步快速同步站点,不需要使用的站点可在WEB管理界面中禁用。
|
||||
- 通过下载器监控实现自动整理入库刮削。
|
||||
- 通过微信/Telegram/Slack远程管理,其中Telegram将会自动添加操作菜单。微信回调相对路径为`/api/v1/message/`。
|
||||
- 通过WEB进行管理,将WEB添加到手机桌面获得类App使用效果,管理界面端口:`3000`。
|
||||
- 设置媒体服务器Webhook,通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot`,其中`moviepilot`为设置的`API_TOKEN`。
|
||||
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr,可使用Overseerr/Jellyseerr浏览订阅。
|
||||
- 通过CookieCloud同步快速同步站点,不需要使用的站点可在WEB管理界面中禁用,无法同步的站点可手动新增。
|
||||
- 通过WEB进行管理,将WEB添加到手机桌面获得类App使用效果,管理界面端口:`3000`,后台API端口:`3001`。
|
||||
- 通过下载器监控或使用目录监控插件实现自动整理入库刮削(二选一)。
|
||||
- 通过微信/Telegram/Slack/SynologyChat远程管理,其中微信/Telegram将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示);微信需要在官方页面设置回调地址,SynologyChat需要设置机器人传入地址,地址相对路径为:`/api/v1/message/`。
|
||||
- 设置媒体服务器Webhook,通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot`(`3001`端口),其中`moviepilot`为设置的`API_TOKEN`。
|
||||
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr(`API服务端口`),可使用Overseerr/Jellyseerr浏览订阅。
|
||||
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`
|
||||
|
||||
**注意**
|
||||
|
||||
1) 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
|
||||
2) 使用反向代理时,需要添加以下配置,否则可能会导致部分功能无法访问(`ip:port`修改为实际值):
|
||||
### **注意**
|
||||
- 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
|
||||
- 使用反向代理时,需要添加以下配置,否则可能会导致部分功能无法访问(`ip:port`修改为实际值):
|
||||
```nginx configuration
|
||||
location / {
|
||||
proxy_pass http://ip:port;
|
||||
@@ -229,11 +267,24 @@ location / {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
- 新建的企业微信应用需要固定公网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;
|
||||
}
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, rss
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, filebrowser, transfer
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
||||
@@ -19,4 +19,5 @@ api_router.include_router(system.router, prefix="/system", tags=["system"])
|
||||
api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"])
|
||||
api_router.include_router(download.router, prefix="/download", tags=["download"])
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(rss.router, prefix="/rss", tags=["rss"])
|
||||
api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
|
||||
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from requests import Session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.dashboard import DashboardChain
|
||||
@@ -11,26 +11,27 @@ from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.scheduler import Scheduler
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
from app.utils.timer import TimerUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
|
||||
def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def statistic(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询媒体数量统计信息
|
||||
"""
|
||||
media_statistic = DashboardChain().media_statistic()
|
||||
if media_statistic:
|
||||
return schemas.Statistic(
|
||||
movie_count=media_statistic.movie_count,
|
||||
tv_count=media_statistic.tv_count,
|
||||
episode_count=media_statistic.episode_count,
|
||||
user_count=media_statistic.user_count
|
||||
)
|
||||
media_statistics: Optional[List[schemas.Statistic]] = DashboardChain(db).media_statistic()
|
||||
if media_statistics:
|
||||
# 汇总各媒体库统计信息
|
||||
ret_statistic = schemas.Statistic()
|
||||
for media_statistic in media_statistics:
|
||||
ret_statistic.movie_count += media_statistic.movie_count
|
||||
ret_statistic.tv_count += media_statistic.tv_count
|
||||
ret_statistic.episode_count += media_statistic.episode_count
|
||||
ret_statistic.user_count += media_statistic.user_count
|
||||
return ret_statistic
|
||||
else:
|
||||
return schemas.Statistic()
|
||||
|
||||
@@ -40,12 +41,7 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询存储空间信息
|
||||
"""
|
||||
if settings.LIBRARY_PATH:
|
||||
total_storage, free_storage = SystemUtils.space_usage(
|
||||
[Path(path) for path in settings.LIBRARY_PATH.split(",")]
|
||||
)
|
||||
else:
|
||||
total_storage, free_storage = 0, 0
|
||||
total_storage, free_storage = SystemUtils.space_usage(settings.LIBRARY_PATHS)
|
||||
return schemas.Storage(
|
||||
total_storage=total_storage,
|
||||
used_storage=total_storage - free_storage
|
||||
@@ -61,19 +57,23 @@ def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
|
||||
def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def downloader(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询下载器信息
|
||||
"""
|
||||
transfer_info = DashboardChain().downloader_info()
|
||||
transfer_info = DashboardChain(db).downloader_info()
|
||||
free_space = SystemUtils.free_space(Path(settings.DOWNLOAD_PATH))
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=transfer_info.download_speed,
|
||||
upload_speed=transfer_info.upload_speed,
|
||||
download_size=transfer_info.download_size,
|
||||
upload_size=transfer_info.upload_size,
|
||||
free_space=free_space
|
||||
)
|
||||
if transfer_info:
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=transfer_info.download_speed,
|
||||
upload_speed=transfer_info.upload_speed,
|
||||
download_size=transfer_info.download_size,
|
||||
upload_size=transfer_info.upload_size,
|
||||
free_space=free_space
|
||||
)
|
||||
else:
|
||||
return schemas.DownloaderInfo()
|
||||
|
||||
|
||||
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
|
||||
@@ -81,37 +81,7 @@ def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询后台服务信息
|
||||
"""
|
||||
# 返回计时任务
|
||||
schedulers = []
|
||||
# 去重
|
||||
added = []
|
||||
jobs = Scheduler().list()
|
||||
# 按照下次运行时间排序
|
||||
jobs.sort(key=lambda x: x.next_run_time)
|
||||
for job in jobs:
|
||||
if job.name not in added:
|
||||
added.append(job.name)
|
||||
else:
|
||||
continue
|
||||
if not StringUtils.is_chinese(job.name):
|
||||
continue
|
||||
if not job.next_run_time:
|
||||
status = "已停止"
|
||||
next_run = ""
|
||||
else:
|
||||
next_run = TimerUtils.time_difference(job.next_run_time)
|
||||
if not next_run:
|
||||
status = "正在运行"
|
||||
else:
|
||||
status = "阻塞" if job.pending else "等待"
|
||||
schedulers.append(schemas.ScheduleInfo(
|
||||
id=job.id,
|
||||
name=job.name,
|
||||
status=status,
|
||||
next_run=next_run
|
||||
))
|
||||
|
||||
return schedulers
|
||||
return Scheduler().list()
|
||||
|
||||
|
||||
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
|
||||
@@ -122,3 +92,19 @@ def transfer(days: int = 7, db: Session = Depends(get_db),
|
||||
"""
|
||||
transfer_stat = TransferHistory.statistic(db, days)
|
||||
return [stat[1] for stat in transfer_stat]
|
||||
|
||||
|
||||
@router.get("/cpu", summary="获取当前CPU使用率", response_model=int)
|
||||
def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取当前CPU使用率
|
||||
"""
|
||||
return SystemUtils.cpu_usage()
|
||||
|
||||
|
||||
@router.get("/memory", summary="获取当前内存使用量和使用率", response_model=List[int])
|
||||
def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取当前内存使用率
|
||||
"""
|
||||
return SystemUtils.memory_usage()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.schemas import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
@@ -30,29 +32,46 @@ def douban_img(imgurl: str) -> Any:
|
||||
|
||||
@router.get("/recognize/{doubanid}", summary="豆瓣ID识别", response_model=schemas.Context)
|
||||
def recognize_doubanid(doubanid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = DoubanChain().recognize_by_doubanid(doubanid=doubanid)
|
||||
context = DoubanChain(db).recognize_by_doubanid(doubanid=doubanid)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
else:
|
||||
return schemas.Context()
|
||||
|
||||
|
||||
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
|
||||
def movie_showing(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣正在热映
|
||||
"""
|
||||
movies = DoubanChain(db).movie_showing(page=page, count=count)
|
||||
if not movies:
|
||||
return []
|
||||
medias = [MediaInfo(douban_info=movie) for movie in movies]
|
||||
return [media.to_dict() for media in medias]
|
||||
|
||||
|
||||
@router.get("/movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
|
||||
def douban_movies(sort: str = "R",
|
||||
tags: str = "",
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣电影信息
|
||||
"""
|
||||
movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
movies = DoubanChain(db).douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
if not movies:
|
||||
return []
|
||||
medias = [MediaInfo(douban_info=movie) for movie in movies]
|
||||
@@ -67,12 +86,13 @@ def douban_tvs(sort: str = "R",
|
||||
tags: str = "",
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
tvs = DoubanChain(db).douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
if not tvs:
|
||||
return []
|
||||
medias = [MediaInfo(douban_info=tv) for tv in tvs]
|
||||
@@ -86,42 +106,47 @@ def douban_tvs(sort: str = "R",
|
||||
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
||||
def movie_top250(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
movies = DoubanChain().movie_top250(page=page, count=count)
|
||||
movies = DoubanChain(db).movie_top250(page=page, count=count)
|
||||
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
|
||||
|
||||
|
||||
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
def tv_weekly_chinese(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
中国每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
|
||||
tvs = DoubanChain(db).tv_weekly_chinese(page=page, count=count)
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
|
||||
|
||||
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
def tv_weekly_global(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
全球每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
|
||||
tvs = DoubanChain(db).tv_weekly_global(page=page, count=count)
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
|
||||
|
||||
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
|
||||
def douban_info(doubanid: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def douban_info(doubanid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询豆瓣媒体信息
|
||||
"""
|
||||
doubaninfo = DoubanChain().douban_info(doubanid=doubanid)
|
||||
doubaninfo = DoubanChain(db).douban_info(doubanid=doubanid)
|
||||
if doubaninfo:
|
||||
return MediaInfo(douban_info=doubaninfo).to_dict()
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
@@ -9,8 +10,7 @@ from app.chain.media import MediaChain
|
||||
from app.core.context import MediaInfo, Context, TorrentInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db.models.user import User
|
||||
from app.db.userauth import get_current_active_superuser
|
||||
from app.db import get_db
|
||||
from app.schemas import NotExistMediaInfo, MediaType
|
||||
|
||||
router = APIRouter()
|
||||
@@ -18,18 +18,20 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
||||
def read_downloading(
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询正在下载的任务
|
||||
"""
|
||||
return DownloadChain().downloading()
|
||||
return DownloadChain(db).downloading()
|
||||
|
||||
|
||||
@router.post("/", summary="添加下载", response_model=schemas.Response)
|
||||
def add_downloading(
|
||||
media_in: schemas.MediaInfo,
|
||||
torrent_in: schemas.TorrentInfo,
|
||||
current_user: User = Depends(get_current_active_superuser)) -> Any:
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
添加下载任务
|
||||
"""
|
||||
@@ -47,7 +49,7 @@ def add_downloading(
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrentinfo
|
||||
)
|
||||
did = DownloadChain().download_single(context=context)
|
||||
did = DownloadChain(db).download_single(context=context)
|
||||
return schemas.Response(success=True if did else False, data={
|
||||
"download_id": did
|
||||
})
|
||||
@@ -55,6 +57,7 @@ def add_downloading(
|
||||
|
||||
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[NotExistMediaInfo])
|
||||
def exists(media_in: schemas.MediaInfo,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询缺失媒体信息
|
||||
@@ -65,19 +68,19 @@ def exists(media_in: schemas.MediaInfo,
|
||||
if media_in.tmdb_id:
|
||||
mediainfo.from_dict(media_in.dict())
|
||||
elif media_in.douban_id:
|
||||
context = DoubanChain().recognize_by_doubanid(doubanid=media_in.douban_id)
|
||||
context = DoubanChain(db).recognize_by_doubanid(doubanid=media_in.douban_id)
|
||||
if context:
|
||||
mediainfo = context.media_info
|
||||
meta = context.meta_info
|
||||
else:
|
||||
context = MediaChain().recognize_by_title(title=f"{media_in.title} {media_in.year}")
|
||||
context = MediaChain(db).recognize_by_title(title=f"{media_in.title} {media_in.year}")
|
||||
if context:
|
||||
mediainfo = context.media_info
|
||||
meta = context.meta_info
|
||||
# 查询缺失信息
|
||||
if not mediainfo or not mediainfo.tmdb_id:
|
||||
raise HTTPException(status_code=404, detail="媒体信息不存在")
|
||||
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)
|
||||
exist_flag, no_exists = DownloadChain(db).get_no_exists_info(meta=meta, mediainfo=mediainfo)
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影已存在时返回空列表,存在时返回空对像列表
|
||||
return [] if exist_flag else [NotExistMediaInfo()]
|
||||
@@ -87,34 +90,37 @@ def exists(media_in: schemas.MediaInfo,
|
||||
return []
|
||||
|
||||
|
||||
@router.put("/{hashString}/start", summary="开始任务", response_model=schemas.Response)
|
||||
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
|
||||
def start_downloading(
|
||||
hashString: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
开如下载任务
|
||||
"""
|
||||
ret = DownloadChain().set_downloading(hashString, "start")
|
||||
ret = DownloadChain(db).set_downloading(hashString, "start")
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@router.put("/{hashString}/stop", summary="暂停任务", response_model=schemas.Response)
|
||||
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
|
||||
def stop_downloading(
|
||||
hashString: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
控制下载任务
|
||||
"""
|
||||
ret = DownloadChain().set_downloading(hashString, "stop")
|
||||
ret = DownloadChain(db).set_downloading(hashString, "stop")
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
||||
def remove_downloading(
|
||||
hashString: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
控制下载任务
|
||||
"""
|
||||
ret = DownloadChain().remove_downloading(hashString)
|
||||
ret = DownloadChain(db).remove_downloading(hashString)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
189
app/api/endpoints/filebrowser.py
Normal file
189
app/api/endpoints/filebrowser.py
Normal file
@@ -0,0 +1,189 @@
|
||||
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")
|
||||
@@ -6,11 +6,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas import MediaType
|
||||
from app.schemas.types import EventType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -62,19 +64,29 @@ def transfer_history(title: str = None,
|
||||
|
||||
@router.delete("/transfer", summary="删除转移历史记录", response_model=schemas.Response)
|
||||
def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
delete_file: bool = False,
|
||||
deletesrc: bool = False,
|
||||
deletedest: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除转移历史记录
|
||||
"""
|
||||
# 触发删除事件
|
||||
if delete_file:
|
||||
history = TransferHistory.get(db, history_in.id)
|
||||
if not history:
|
||||
return schemas.Response(success=False, msg="记录不存在")
|
||||
# 册除文件
|
||||
TransferChain().delete_files(Path(history.dest))
|
||||
history = TransferHistory.get(db, history_in.id)
|
||||
if not history:
|
||||
return schemas.Response(success=False, msg="记录不存在")
|
||||
# 册除媒体库文件
|
||||
if deletedest and history.dest:
|
||||
TransferChain(db).delete_files(Path(history.dest))
|
||||
# 删除源文件
|
||||
if deletesrc and history.src:
|
||||
TransferChain(db).delete_files(Path(history.src))
|
||||
# 发送事件
|
||||
eventmanager.send_event(
|
||||
EventType.DownloadFileDeleted,
|
||||
{
|
||||
"src": history.src
|
||||
}
|
||||
)
|
||||
# 删除记录
|
||||
TransferHistory.delete(db, history_in.id)
|
||||
return schemas.Response(success=True)
|
||||
@@ -82,15 +94,19 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
|
||||
@router.post("/transfer", summary="历史记录重新转移", response_model=schemas.Response)
|
||||
def redo_transfer_history(history_in: schemas.TransferHistory,
|
||||
mtype: str,
|
||||
new_tmdbid: int,
|
||||
mtype: str = None,
|
||||
new_tmdbid: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
历史记录重新转移
|
||||
历史记录重新转移,不输入 mtype 和 new_tmdbid 时,自动使用文件名重新识别
|
||||
"""
|
||||
hash_str = history_in.download_hash
|
||||
result = TransferChain().process(f"{hash_str} {new_tmdbid}|{mtype}")
|
||||
if result:
|
||||
if mtype and new_tmdbid:
|
||||
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id,
|
||||
mtype=MediaType(mtype), tmdbid=new_tmdbid)
|
||||
else:
|
||||
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id)
|
||||
if state:
|
||||
return schemas.Response(success=True)
|
||||
else:
|
||||
return schemas.Response(success=False, message="失败原因详见通知消息")
|
||||
return schemas.Response(success=False, message=errmsg)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import random
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -15,7 +14,7 @@ from app.core.security import get_password_hash
|
||||
from app.db import get_db
|
||||
from app.db.models.user import User
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.web import WebUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -36,7 +35,7 @@ async def login_access_token(
|
||||
if not user:
|
||||
# 请求协助认证
|
||||
logger.warn("登录用户本地不匹配,尝试辅助认证 ...")
|
||||
token = UserChain().user_authenticate(form_data.username, form_data.password)
|
||||
token = UserChain(db).user_authenticate(form_data.username, form_data.password)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码不正确")
|
||||
else:
|
||||
@@ -56,6 +55,9 @@ async def login_access_token(
|
||||
user.id, expires_delta=access_token_expires
|
||||
),
|
||||
token_type="bearer",
|
||||
super_user=user.is_superuser,
|
||||
user_name=user.name,
|
||||
avatar=user.avatar
|
||||
)
|
||||
|
||||
|
||||
@@ -64,37 +66,22 @@ def bing_wallpaper() -> Any:
|
||||
"""
|
||||
获取Bing每日壁纸
|
||||
"""
|
||||
url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
|
||||
try:
|
||||
resp = RequestUtils(timeout=5).get_res(url)
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return schemas.Response(success=False)
|
||||
if resp and resp.status_code == 200:
|
||||
try:
|
||||
result = resp.json()
|
||||
if isinstance(result, dict):
|
||||
for image in result.get('images') or []:
|
||||
return schemas.Response(success=False,
|
||||
message=f"https://cn.bing.com{image.get('url')}" if 'url' in image else '')
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
url = WebUtils.get_bing_wallpaper()
|
||||
if url:
|
||||
return schemas.Response(success=False,
|
||||
message=url)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@router.get("/tmdb", summary="TMDB电影海报", response_model=schemas.Response)
|
||||
def tmdb_wallpaper() -> Any:
|
||||
def tmdb_wallpaper(db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
获取TMDB电影海报
|
||||
"""
|
||||
infos = TmdbChain().tmdb_trending()
|
||||
if infos:
|
||||
# 随机一个电影
|
||||
while True:
|
||||
info = random.choice(infos)
|
||||
if info and info.get("backdrop_path"):
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
message=f"https://image.tmdb.org/t/p/original{info.get('backdrop_path')}"
|
||||
)
|
||||
wallpager = TmdbChain(db).get_random_wallpager()
|
||||
if wallpager:
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
message=wallpager
|
||||
)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
@@ -17,15 +17,30 @@ from app.schemas import MediaType
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/recognize", summary="识别媒体信息", response_model=schemas.Context)
|
||||
@router.get("/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context)
|
||||
def recognize(title: str,
|
||||
subtitle: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据标题、副标题识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = MediaChain().recognize_by_title(title=title, subtitle=subtitle)
|
||||
context = MediaChain(db).recognize_by_title(title=title, subtitle=subtitle)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
return schemas.Context()
|
||||
|
||||
|
||||
@router.get("/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context)
|
||||
def recognize(path: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = MediaChain(db).recognize_by_path(path)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
return schemas.Context()
|
||||
@@ -35,11 +50,12 @@ def recognize(title: str,
|
||||
def search_by_title(title: str,
|
||||
page: int = 1,
|
||||
count: int = 8,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
模糊搜索媒体信息列表
|
||||
"""
|
||||
_, medias = MediaChain().search(title=title)
|
||||
_, medias = MediaChain(db).search(title=title)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||
return []
|
||||
@@ -69,20 +85,21 @@ def exists(title: str = None,
|
||||
|
||||
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||
def tmdb_info(mediaid: str, type_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧
|
||||
"""
|
||||
mtype = MediaType(type_name)
|
||||
if mediaid.startswith("tmdb:"):
|
||||
result = TmdbChain().tmdb_info(int(mediaid[5:]), mtype)
|
||||
result = TmdbChain(db).tmdb_info(int(mediaid[5:]), mtype)
|
||||
return MediaInfo(tmdb_info=result).to_dict()
|
||||
elif mediaid.startswith("douban:"):
|
||||
# 查询豆瓣信息
|
||||
doubaninfo = DoubanChain().douban_info(doubanid=mediaid[7:])
|
||||
doubaninfo = DoubanChain(db).douban_info(doubanid=mediaid[7:])
|
||||
if not doubaninfo:
|
||||
return schemas.MediaInfo()
|
||||
result = DoubanChain().recognize_by_doubaninfo(doubaninfo)
|
||||
result = DoubanChain(db).recognize_by_doubaninfo(doubaninfo)
|
||||
if result:
|
||||
# TMDB
|
||||
return result.media_info.to_dict()
|
||||
|
||||
@@ -19,22 +19,23 @@ from app.schemas.types import SystemConfigKey, NotificationType
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def start_message_chain(body: Any, form: Any, args: Any):
|
||||
def start_message_chain(db: Session, body: Any, form: Any, args: Any):
|
||||
"""
|
||||
启动链式任务
|
||||
"""
|
||||
MessageChain().process(body=body, form=form, args=args)
|
||||
MessageChain(db).process(body=body, form=form, args=args)
|
||||
|
||||
|
||||
@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,
|
||||
db: Session = Depends(get_db)):
|
||||
"""
|
||||
用户消息响应
|
||||
"""
|
||||
body = await request.body()
|
||||
form = await request.form()
|
||||
args = request.query_params
|
||||
background_tasks.add_task(start_message_chain, body, form, args)
|
||||
background_tasks.add_task(start_message_chain, db, body, form, args)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -63,17 +64,18 @@ def wechat_verify(echostr: str, msg_signature: str,
|
||||
|
||||
|
||||
@router.get("/switchs", summary="查询通知消息渠道开关", response_model=List[NotificationSwitch])
|
||||
def read_switchs(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询通知消息渠道开关
|
||||
"""
|
||||
return_list = []
|
||||
# 读取数据库
|
||||
switchs = SystemConfigOper(db).get(SystemConfigKey.NotificationChannels)
|
||||
switchs = SystemConfigOper().get(SystemConfigKey.NotificationChannels)
|
||||
if not switchs:
|
||||
for noti in NotificationType:
|
||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True, telegram=True, slack=True))
|
||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
||||
telegram=True, slack=True,
|
||||
synologychat=True))
|
||||
else:
|
||||
for switch in switchs:
|
||||
return_list.append(NotificationSwitch(**switch))
|
||||
@@ -82,7 +84,6 @@ def read_switchs(db: Session = Depends(get_db),
|
||||
|
||||
@router.post("/switchs", summary="设置通知消息渠道开关", response_model=schemas.Response)
|
||||
def set_switchs(switchs: List[NotificationSwitch],
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询通知消息渠道开关
|
||||
@@ -91,6 +92,6 @@ def set_switchs(switchs: List[NotificationSwitch],
|
||||
for switch in switchs:
|
||||
switch_list.append(switch.dict())
|
||||
# 存入数据库
|
||||
SystemConfigOper(db).set(SystemConfigKey.NotificationChannels, switch_list)
|
||||
SystemConfigOper().set(SystemConfigKey.NotificationChannels, switch_list)
|
||||
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
@@ -22,28 +20,26 @@ def all_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/installed", summary="已安装插件", response_model=List[str])
|
||||
def installed_plugins(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def installed_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询用户已安装插件清单
|
||||
"""
|
||||
return SystemConfigOper(db).get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
|
||||
|
||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
||||
def install_plugin(plugin_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
安装插件
|
||||
"""
|
||||
# 已安装插件
|
||||
install_plugins = SystemConfigOper(db).get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 安装插件
|
||||
if plugin_id not in install_plugins:
|
||||
install_plugins.append(plugin_id)
|
||||
# 保存设置
|
||||
SystemConfigOper(db).set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 重载插件管理器
|
||||
PluginManager().init_config()
|
||||
return schemas.Response(success=True)
|
||||
@@ -93,19 +89,18 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
||||
|
||||
@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response)
|
||||
def uninstall_plugin(plugin_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
卸载插件
|
||||
"""
|
||||
# 删除已安装信息
|
||||
install_plugins = SystemConfigOper(db).get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
for plugin in install_plugins:
|
||||
if plugin == plugin_id:
|
||||
install_plugins.remove(plugin)
|
||||
break
|
||||
# 保存
|
||||
SystemConfigOper(db).set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 重载插件管理器
|
||||
PluginManager().init_config()
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
from app import schemas
|
||||
from app.chain.rss import RssChain
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models.rss import Rss
|
||||
from app.helper.rss import RssHelper
|
||||
from app.schemas import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def start_rss_refresh(rssid: int = None):
|
||||
"""
|
||||
启动自定义订阅刷新
|
||||
"""
|
||||
RssChain().refresh(rssid=rssid, manual=True)
|
||||
|
||||
|
||||
@router.get("/", summary="所有自定义订阅", response_model=List[schemas.Rss])
|
||||
def read_rsses(
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询所有自定义订阅
|
||||
"""
|
||||
return Rss.list(db)
|
||||
|
||||
|
||||
@router.post("/", summary="新增自定义订阅", response_model=schemas.Response)
|
||||
def create_rss(
|
||||
*,
|
||||
rss_in: schemas.Rss,
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
新增自定义订阅
|
||||
"""
|
||||
if rss_in.type:
|
||||
mtype = MediaType(rss_in.type)
|
||||
else:
|
||||
mtype = None
|
||||
rssid, errormsg = RssChain().add(
|
||||
mtype=mtype,
|
||||
**rss_in.dict()
|
||||
)
|
||||
if not rssid:
|
||||
return schemas.Response(success=False, message=errormsg)
|
||||
return schemas.Response(success=True, data={
|
||||
"id": rssid
|
||||
})
|
||||
|
||||
|
||||
@router.put("/", summary="更新自定义订阅", response_model=schemas.Response)
|
||||
def update_rss(
|
||||
*,
|
||||
rss_in: schemas.Rss,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
更新自定义订阅信息
|
||||
"""
|
||||
rss = Rss.get(db, rss_in.id)
|
||||
if not rss:
|
||||
return schemas.Response(success=False, message="自定义订阅不存在")
|
||||
|
||||
rss.update(db, rss_in.dict())
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/preview/{rssid}", summary="预览自定义订阅", response_model=List[schemas.TorrentInfo])
|
||||
def preview_rss(
|
||||
rssid: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据ID查询自定义订阅RSS报文
|
||||
"""
|
||||
rssinfo: Rss = Rss.get(db, rssid)
|
||||
if not rssinfo:
|
||||
return []
|
||||
torrents = RssHelper.parse(rssinfo.url, proxy=True if rssinfo.proxy else False) or []
|
||||
return [schemas.TorrentInfo(
|
||||
title=t.get("title"),
|
||||
description=t.get("description"),
|
||||
enclosure=t.get("enclosure"),
|
||||
size=t.get("size"),
|
||||
page_url=t.get("link"),
|
||||
pubdate=t["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if t.get("pubdate") else None,
|
||||
) for t in torrents]
|
||||
|
||||
|
||||
@router.get("/refresh/{rssid}", summary="刷新自定义订阅", response_model=schemas.Response)
|
||||
def refresh_rss(
|
||||
rssid: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据ID刷新自定义订阅
|
||||
"""
|
||||
background_tasks.add_task(start_rss_refresh,
|
||||
rssid=rssid)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/{rssid}", summary="查询自定义订阅详情", response_model=schemas.Rss)
|
||||
def read_rss(
|
||||
rssid: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据ID查询自定义订阅详情
|
||||
"""
|
||||
return Rss.get(db, rssid)
|
||||
|
||||
|
||||
@router.delete("/{rssid}", summary="删除自定义订阅", response_model=schemas.Response)
|
||||
def read_rss(
|
||||
rssid: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据ID删除自定义订阅
|
||||
"""
|
||||
Rss.delete(db, rssid)
|
||||
return schemas.Response(success=True)
|
||||
@@ -1,28 +1,33 @@
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
|
||||
async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def search_latest(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询搜索结果
|
||||
"""
|
||||
torrents = SearchChain().last_search_results()
|
||||
torrents = SearchChain(db).last_search_results()
|
||||
return [torrent.to_dict() for torrent in torrents]
|
||||
|
||||
|
||||
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=List[schemas.Context])
|
||||
def search_by_tmdbid(mediaid: str,
|
||||
mtype: str = None,
|
||||
area: str = "title",
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/
|
||||
@@ -31,15 +36,16 @@ def search_by_tmdbid(mediaid: str,
|
||||
tmdbid = int(mediaid.replace("tmdb:", ""))
|
||||
if mtype:
|
||||
mtype = MediaType(mtype)
|
||||
torrents = SearchChain().search_by_tmdbid(tmdbid=tmdbid, mtype=mtype)
|
||||
torrents = SearchChain(db).search_by_tmdbid(tmdbid=tmdbid, mtype=mtype, area=area)
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid.replace("douban:", "")
|
||||
# 识别豆瓣信息
|
||||
context = DoubanChain().recognize_by_doubanid(doubanid)
|
||||
context = DoubanChain(db).recognize_by_doubanid(doubanid)
|
||||
if not context or not context.media_info or not context.media_info.tmdb_id:
|
||||
raise HTTPException(status_code=404, detail="无法识别TMDB媒体信息!")
|
||||
torrents = SearchChain().search_by_tmdbid(tmdbid=context.media_info.tmdb_id,
|
||||
mtype=context.media_info.type)
|
||||
return []
|
||||
torrents = SearchChain(db).search_by_tmdbid(tmdbid=context.media_info.tmdb_id,
|
||||
mtype=context.media_info.type,
|
||||
area=area)
|
||||
else:
|
||||
return []
|
||||
return [torrent.to_dict() for torrent in torrents]
|
||||
@@ -49,9 +55,10 @@ def search_by_tmdbid(mediaid: str,
|
||||
async def search_by_title(keyword: str = None,
|
||||
page: int = 0,
|
||||
site: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
|
||||
"""
|
||||
torrents = SearchChain().search_by_title(title=keyword, page=page, site=site)
|
||||
torrents = SearchChain(db).search_by_title(title=keyword, page=page, site=site)
|
||||
return [torrent.to_dict() for torrent in torrents]
|
||||
|
||||
@@ -5,27 +5,22 @@ from sqlalchemy.orm import Session
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
from app import schemas
|
||||
from app.chain.cookiecloud import CookieCloudChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.event import EventManager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models.site import Site
|
||||
from app.db.models.siteicon import SiteIcon
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def start_cookiecloud_sync():
|
||||
"""
|
||||
后台启动CookieCloud站点同步
|
||||
"""
|
||||
CookieCloudChain().process(manual=True)
|
||||
|
||||
|
||||
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
|
||||
def read_sites(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
@@ -35,6 +30,33 @@ def read_sites(db: Session = Depends(get_db),
|
||||
return Site.list_order_by_pri(db)
|
||||
|
||||
|
||||
@router.post("/", summary="新增站点", response_model=schemas.Response)
|
||||
def add_site(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
site_in: schemas.Site,
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
新增站点
|
||||
"""
|
||||
if not site_in.url:
|
||||
return schemas.Response(success=False, message="站点地址不能为空")
|
||||
domain = StringUtils.get_url_domain(site_in.url)
|
||||
site_info = SitesHelper().get_indexer(domain)
|
||||
if not site_info:
|
||||
return schemas.Response(success=False, message="该站点不支持")
|
||||
if Site.get_by_domain(db, domain):
|
||||
return schemas.Response(success=False, message=f"{domain} 站点己存在")
|
||||
# 保存站点信息
|
||||
site_in.domain = domain
|
||||
site_in.name = site_info.get("name")
|
||||
site_in.id = None
|
||||
site = Site(**site_in.dict())
|
||||
site.create(db)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.put("/", summary="更新站点", response_model=schemas.Response)
|
||||
def update_site(
|
||||
*,
|
||||
@@ -52,16 +74,21 @@ def update_site(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/", summary="删除站点", response_model=schemas.Response)
|
||||
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
|
||||
def delete_site(
|
||||
site_in: schemas.Site,
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
删除站点
|
||||
"""
|
||||
Site.delete(db, site_in.id)
|
||||
Site.delete(db, site_id)
|
||||
# 插件站点删除
|
||||
EventManager().send_event(EventType.SiteDeleted,
|
||||
{
|
||||
"site_id": site_id
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -71,7 +98,7 @@ def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||
"""
|
||||
运行CookieCloud同步站点信息
|
||||
"""
|
||||
background_tasks.add_task(start_cookiecloud_sync)
|
||||
background_tasks.add_task(Scheduler().start, job_id="cookiecloud")
|
||||
return schemas.Response(success=True, message="CookieCloud同步任务已启动!")
|
||||
|
||||
|
||||
@@ -82,8 +109,15 @@ def cookie_cloud_sync(db: Session = Depends(get_db),
|
||||
清空所有站点数据并重新同步CookieCloud站点信息
|
||||
"""
|
||||
Site.reset(db)
|
||||
SystemConfigOper(db).set(SystemConfigKey.IndexerSites, [])
|
||||
CookieCloudChain().process(manual=True)
|
||||
SystemConfigOper().set(SystemConfigKey.IndexerSites, [])
|
||||
SystemConfigOper().set(SystemConfigKey.RssSites, [])
|
||||
# 启动定时服务
|
||||
Scheduler().start("cookiecloud", manual=True)
|
||||
# 插件站点删除
|
||||
EventManager().send_event(EventType.SiteDeleted,
|
||||
{
|
||||
"site_id": None
|
||||
})
|
||||
return schemas.Response(success=True, message="站点已重置!")
|
||||
|
||||
|
||||
@@ -105,9 +139,9 @@ def update_cookie(
|
||||
detail=f"站点 {site_id} 不存在!",
|
||||
)
|
||||
# 更新Cookie
|
||||
state, message = SiteChain().update_cookie(site_info=site_info,
|
||||
username=username,
|
||||
password=password)
|
||||
state, message = SiteChain(db).update_cookie(site_info=site_info,
|
||||
username=username,
|
||||
password=password)
|
||||
return schemas.Response(success=state, message=message)
|
||||
|
||||
|
||||
@@ -124,7 +158,7 @@ def test_site(site_id: int,
|
||||
status_code=404,
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
status, message = SiteChain().test(site.domain)
|
||||
status, message = SiteChain(db).test(site.domain)
|
||||
return schemas.Response(success=status, message=message)
|
||||
|
||||
|
||||
@@ -150,7 +184,7 @@ def site_icon(site_id: int,
|
||||
|
||||
|
||||
@router.get("/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo])
|
||||
def site_resource(site_id: int, keyword: str = None,
|
||||
def site_resource(site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -162,7 +196,7 @@ def site_resource(site_id: int, keyword: str = None,
|
||||
status_code=404,
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
torrents = SearchChain().browse(site.domain, keyword)
|
||||
torrents = TorrentsChain().browse(domain=site.domain)
|
||||
if not torrents:
|
||||
return []
|
||||
return [torrent.to_dict() for torrent in torrents]
|
||||
@@ -187,6 +221,23 @@ def read_site_by_domain(
|
||||
return site
|
||||
|
||||
|
||||
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
|
||||
def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
|
||||
"""
|
||||
获取站点列表
|
||||
"""
|
||||
# 选中的rss站点
|
||||
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
||||
# 所有站点
|
||||
all_site = Site.list_order_by_pri(db)
|
||||
if not selected_sites or not all_site:
|
||||
return []
|
||||
|
||||
# 选中的rss站点
|
||||
rss_sites = [site for site in all_site if site and site.id in selected_sites]
|
||||
return rss_sites
|
||||
|
||||
|
||||
@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site)
|
||||
def read_site(
|
||||
site_id: int,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from typing import List, Any, Optional
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -12,25 +12,19 @@ from app.db import get_db
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.models.user import User
|
||||
from app.db.userauth import get_current_active_user
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def start_subscribe_add(title: str, year: str,
|
||||
def start_subscribe_add(db: Session, title: str, year: str,
|
||||
mtype: MediaType, tmdbid: int, season: int, username: str):
|
||||
"""
|
||||
启动订阅任务
|
||||
"""
|
||||
SubscribeChain().add(title=title, year=year,
|
||||
mtype=mtype, tmdbid=tmdbid, season=season, username=username)
|
||||
|
||||
|
||||
def start_subscribe_search(sid: Optional[int], state: Optional[str]):
|
||||
"""
|
||||
启动订阅搜索任务
|
||||
"""
|
||||
SubscribeChain().search(sid=sid, state=state, manual=True)
|
||||
SubscribeChain(db).add(title=title, year=year,
|
||||
mtype=mtype, tmdbid=tmdbid, season=season, username=username)
|
||||
|
||||
|
||||
@router.get("/", summary="所有订阅", response_model=List[schemas.Subscribe])
|
||||
@@ -51,6 +45,7 @@ def read_subscribes(
|
||||
def create_subscribe(
|
||||
*,
|
||||
subscribe_in: schemas.Subscribe,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
@@ -66,15 +61,15 @@ def create_subscribe(
|
||||
title = subscribe_in.name
|
||||
else:
|
||||
title = None
|
||||
sid, message = SubscribeChain().add(mtype=mtype,
|
||||
title=title,
|
||||
year=subscribe_in.year,
|
||||
tmdbid=subscribe_in.tmdbid,
|
||||
season=subscribe_in.season,
|
||||
doubanid=subscribe_in.doubanid,
|
||||
username=current_user.name,
|
||||
best_version=subscribe_in.best_version,
|
||||
exist_ok=True)
|
||||
sid, message = SubscribeChain(db).add(mtype=mtype,
|
||||
title=title,
|
||||
year=subscribe_in.year,
|
||||
tmdbid=subscribe_in.tmdbid,
|
||||
season=subscribe_in.season,
|
||||
doubanid=subscribe_in.doubanid,
|
||||
username=current_user.name,
|
||||
best_version=subscribe_in.best_version,
|
||||
exist_ok=True)
|
||||
return schemas.Response(success=True if sid else False, message=message, data={
|
||||
"id": sid
|
||||
})
|
||||
@@ -93,12 +88,19 @@ def update_subscribe(
|
||||
subscribe = Subscribe.get(db, subscribe_in.id)
|
||||
if not subscribe:
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
if subscribe_in.sites:
|
||||
if subscribe_in.sites is not None:
|
||||
subscribe_in.sites = json.dumps(subscribe_in.sites)
|
||||
# 避免更新缺失集数
|
||||
subscribe_dict = subscribe_in.dict()
|
||||
if not subscribe_in.lack_episode:
|
||||
# 没有缺失集数时,缺失集数清空,避免更新为0
|
||||
subscribe_dict.pop("lack_episode")
|
||||
elif subscribe_in.total_episode:
|
||||
# 总集数增加时,缺失集数也要增加
|
||||
if subscribe_in.total_episode > (subscribe.total_episode or 0):
|
||||
subscribe_dict["lack_episode"] = (subscribe.lack_episode
|
||||
+ (subscribe_in.total_episode
|
||||
- (subscribe.total_episode or 0)))
|
||||
subscribe.update(db, subscribe_dict)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -113,9 +115,15 @@ def subscribe_mediaid(
|
||||
根据TMDBID或豆瓣ID查询订阅 tmdb:/douban:
|
||||
"""
|
||||
if mediaid.startswith("tmdb:"):
|
||||
result = Subscribe.exists(db, int(mediaid[5:]), season)
|
||||
tmdbid = mediaid[5:]
|
||||
if not tmdbid or not str(tmdbid).isdigit():
|
||||
return Subscribe()
|
||||
result = Subscribe.exists(db, int(tmdbid), season)
|
||||
elif mediaid.startswith("douban:"):
|
||||
result = Subscribe.get_by_doubanid(db, mediaid[7:])
|
||||
doubanid = mediaid[7:]
|
||||
if not doubanid:
|
||||
return Subscribe()
|
||||
result = Subscribe.get_by_doubanid(db, doubanid)
|
||||
else:
|
||||
result = None
|
||||
if result and result.sites:
|
||||
@@ -124,6 +132,61 @@ def subscribe_mediaid(
|
||||
return result if result else Subscribe()
|
||||
|
||||
|
||||
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response)
|
||||
def refresh_subscribes(
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
刷新所有订阅
|
||||
"""
|
||||
Scheduler().start("subscribe_refresh")
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
|
||||
def check_subscribes(
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
刷新订阅 TMDB 信息
|
||||
"""
|
||||
Scheduler().start("subscribe_tmdb")
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
|
||||
def search_subscribes(
|
||||
background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
搜索所有订阅
|
||||
"""
|
||||
background_tasks.add_task(
|
||||
Scheduler().start,
|
||||
job_id="subscribe_search",
|
||||
sid=None,
|
||||
state='R',
|
||||
manual=True
|
||||
)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response)
|
||||
def search_subscribe(
|
||||
subscribe_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据订阅编号搜索订阅
|
||||
"""
|
||||
background_tasks.add_task(
|
||||
Scheduler().start,
|
||||
job_id="subscribe_search",
|
||||
sid=subscribe_id,
|
||||
state=None,
|
||||
manual=True
|
||||
)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
|
||||
def read_subscribe(
|
||||
subscribe_id: int,
|
||||
@@ -149,9 +212,15 @@ def delete_subscribe_by_mediaid(
|
||||
根据TMDBID或豆瓣ID删除订阅 tmdb:/douban:
|
||||
"""
|
||||
if mediaid.startswith("tmdb:"):
|
||||
Subscribe().delete_by_tmdbid(db, int(mediaid[5:]), season)
|
||||
tmdbid = mediaid[5:]
|
||||
if not tmdbid or not str(tmdbid).isdigit():
|
||||
return schemas.Response(success=False)
|
||||
Subscribe().delete_by_tmdbid(db, int(tmdbid), season)
|
||||
elif mediaid.startswith("douban:"):
|
||||
Subscribe().delete_by_doubanid(db, mediaid[7:])
|
||||
doubanid = mediaid[7:]
|
||||
if not doubanid:
|
||||
return schemas.Response(success=False)
|
||||
Subscribe().delete_by_doubanid(db, doubanid)
|
||||
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -171,6 +240,7 @@ def delete_subscribe(
|
||||
|
||||
@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response)
|
||||
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
authorization: str = Header(None)) -> Any:
|
||||
"""
|
||||
Jellyseerr/Overseerr订阅
|
||||
@@ -198,6 +268,7 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||
# 添加订阅
|
||||
if media_type == MediaType.MOVIE:
|
||||
background_tasks.add_task(start_subscribe_add,
|
||||
db=db,
|
||||
mtype=media_type,
|
||||
tmdbid=tmdbId,
|
||||
title=subject,
|
||||
@@ -212,6 +283,7 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||
break
|
||||
for season in seasons:
|
||||
background_tasks.add_task(start_subscribe_add,
|
||||
db=db,
|
||||
mtype=media_type,
|
||||
tmdbid=tmdbId,
|
||||
title=subject,
|
||||
@@ -220,36 +292,3 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||
username=user_name)
|
||||
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response)
|
||||
def refresh_subscribes(
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
刷新所有订阅
|
||||
"""
|
||||
SubscribeChain().refresh()
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response)
|
||||
def search_subscribe(
|
||||
subscribe_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
搜索所有订阅
|
||||
"""
|
||||
background_tasks.add_task(start_subscribe_search, sid=subscribe_id, state=None)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
|
||||
def search_subscribes(
|
||||
background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
搜索所有订阅
|
||||
"""
|
||||
background_tasks.add_task(start_subscribe_search, sid=None, state='R')
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
import json
|
||||
import time
|
||||
import tailer
|
||||
from datetime import datetime
|
||||
from typing import Union
|
||||
|
||||
import tailer
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.search import SearchChain
|
||||
from app.core.config import settings
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
from version import APP_VERSION
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/env", summary="查询系统环境变量", response_model=schemas.Response)
|
||||
def get_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询系统环境变量
|
||||
查询系统环境变量,包括当前版本号
|
||||
"""
|
||||
info = settings.dict(
|
||||
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD", "API_TOKEN"}
|
||||
)
|
||||
info.update({
|
||||
"VERSION": APP_VERSION
|
||||
})
|
||||
return schemas.Response(success=True,
|
||||
data=settings.dict(
|
||||
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD", "API_TOKEN"}
|
||||
))
|
||||
data=info)
|
||||
|
||||
|
||||
@router.get("/progress/{process_type}", summary="实时进度")
|
||||
@@ -55,29 +64,27 @@ def get_progress(process_type: str, token: str):
|
||||
|
||||
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
|
||||
def get_setting(key: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询系统设置
|
||||
"""
|
||||
return schemas.Response(success=True, data={
|
||||
"value": SystemConfigOper(db).get(key)
|
||||
"value": SystemConfigOper().get(key)
|
||||
})
|
||||
|
||||
|
||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
||||
def set_setting(key: str, value: Union[list, dict, str, int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
更新系统设置
|
||||
"""
|
||||
SystemConfigOper(db).set(key, value)
|
||||
SystemConfigOper().set(key, value)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/message", summary="实时消息")
|
||||
def get_progress(token: str):
|
||||
def get_message(token: str):
|
||||
"""
|
||||
实时获取系统消息,返回格式为SSE
|
||||
"""
|
||||
@@ -112,11 +119,11 @@ def get_logging(token: str):
|
||||
def log_generator():
|
||||
log_path = settings.LOG_PATH / 'moviepilot.log'
|
||||
# 读取文件末尾50行,不使用tailer模块
|
||||
with open(log_path, 'r') as f:
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
for line in f.readlines()[-50:]:
|
||||
yield 'data: %s\n\n' % line
|
||||
while True:
|
||||
for text in tailer.follow(open(log_path, 'r')):
|
||||
for text in tailer.follow(open(log_path, 'r', encoding='utf-8')):
|
||||
yield 'data: %s\n\n' % (text or '')
|
||||
time.sleep(1)
|
||||
|
||||
@@ -148,3 +155,75 @@ def nettest(url: str,
|
||||
})
|
||||
else:
|
||||
return schemas.Response(success=False, message="网络连接失败!")
|
||||
|
||||
|
||||
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
|
||||
def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询Github所有Release版本
|
||||
"""
|
||||
version_res = RequestUtils().get_res(f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
|
||||
if version_res:
|
||||
ver_json = version_res.json()
|
||||
if ver_json:
|
||||
return schemas.Response(success=True, data=ver_json)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@router.get("/ruletest", summary="优先级规则测试", response_model=schemas.Response)
|
||||
def ruletest(title: str,
|
||||
subtitle: str = None,
|
||||
ruletype: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索
|
||||
"""
|
||||
torrent = schemas.TorrentInfo(
|
||||
title=title,
|
||||
description=subtitle,
|
||||
)
|
||||
if ruletype == "2":
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.BestVersionFilterRules)
|
||||
elif ruletype == "3":
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.SearchFilterRules)
|
||||
else:
|
||||
rule_string = SystemConfigOper().get(SystemConfigKey.SubscribeFilterRules)
|
||||
if not rule_string:
|
||||
return schemas.Response(success=False, message="优先级规则未设置!")
|
||||
|
||||
# 过滤
|
||||
result = SearchChain(db).filter_torrents(rule_string=rule_string,
|
||||
torrent_list=[torrent])
|
||||
if not result:
|
||||
return schemas.Response(success=False, message="不符合优先级规则!")
|
||||
return schemas.Response(success=True, data={
|
||||
"priority": 100 - result[0].pri_order + 1
|
||||
})
|
||||
|
||||
|
||||
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
|
||||
def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
重启系统
|
||||
"""
|
||||
if not SystemUtils.can_restart():
|
||||
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
|
||||
# 执行重启
|
||||
ret, msg = SystemUtils.restart()
|
||||
return schemas.Response(success=ret, message=msg)
|
||||
|
||||
|
||||
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
||||
def execute_command(jobid: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
执行命令
|
||||
"""
|
||||
if not jobid:
|
||||
return schemas.Response(success=False, message="命令不能为空!")
|
||||
if jobid == "subscribe_search":
|
||||
Scheduler().start(jobid, state = 'R')
|
||||
else:
|
||||
Scheduler().start(jobid)
|
||||
return schemas.Response(success=True)
|
||||
@@ -1,22 +1,25 @@
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason])
|
||||
def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def tmdb_seasons(tmdbid: int, db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询themoviedb所有季信息
|
||||
"""
|
||||
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
|
||||
seasons_info = TmdbChain(db).tmdb_seasons(tmdbid=tmdbid)
|
||||
if not seasons_info:
|
||||
return []
|
||||
else:
|
||||
@@ -26,15 +29,16 @@ def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -
|
||||
@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_similar(tmdbid: int,
|
||||
type_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询类似电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain().movie_similar(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain(db).movie_similar(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain().tv_similar(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain(db).tv_similar(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
@@ -46,15 +50,16 @@ def tmdb_similar(tmdbid: int,
|
||||
@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_recommend(tmdbid: int,
|
||||
type_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询推荐电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain().movie_recommend(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain(db).movie_recommend(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain().tv_recommend(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain(db).tv_recommend(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
@@ -67,15 +72,16 @@ def tmdb_recommend(tmdbid: int,
|
||||
def tmdb_credits(tmdbid: int,
|
||||
type_name: str,
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询演员阵容,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
|
||||
tmdbinfos = TmdbChain(db).movie_credits(tmdbid=tmdbid, page=page)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
|
||||
tmdbinfos = TmdbChain(db).tv_credits(tmdbid=tmdbid, page=page)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
@@ -86,11 +92,12 @@ def tmdb_credits(tmdbid: int,
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.TmdbPerson)
|
||||
def tmdb_person(person_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
tmdbinfo = TmdbChain().person_detail(person_id=person_id)
|
||||
tmdbinfo = TmdbChain(db).person_detail(person_id=person_id)
|
||||
if not tmdbinfo:
|
||||
return schemas.TmdbPerson()
|
||||
else:
|
||||
@@ -100,11 +107,12 @@ def tmdb_person(person_id: int,
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_person_credits(person_id: int,
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
tmdbinfo = TmdbChain().person_credits(person_id=person_id, page=page)
|
||||
tmdbinfo = TmdbChain(db).person_credits(person_id=person_id, page=page)
|
||||
if not tmdbinfo:
|
||||
return []
|
||||
else:
|
||||
@@ -116,15 +124,16 @@ def tmdb_movies(sort_by: str = "popularity.desc",
|
||||
with_genres: str = "",
|
||||
with_original_language: str = "",
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB电影信息
|
||||
"""
|
||||
movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
movies = TmdbChain(db).tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
if not movies:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=movie).to_dict() for movie in movies]
|
||||
@@ -135,15 +144,16 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
|
||||
with_genres: str = "",
|
||||
with_original_language: str = "",
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
tvs = TmdbChain(db).tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
if not tvs:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=tv).to_dict() for tv in tvs]
|
||||
@@ -151,11 +161,12 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
|
||||
|
||||
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_trending(page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
infos = TmdbChain().tmdb_trending(page=page)
|
||||
infos = TmdbChain(db).tmdb_trending(page=page)
|
||||
if not infos:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=info).to_dict() for info in infos]
|
||||
@@ -163,11 +174,12 @@ def tmdb_trending(page: int = 1,
|
||||
|
||||
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
|
||||
def tmdb_season_episodes(tmdbid: int, season: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询某季的所有信信息
|
||||
"""
|
||||
episodes_info = TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)
|
||||
episodes_info = TmdbChain(db).tmdb_episodes(tmdbid=tmdbid, season=season)
|
||||
if not episodes_info:
|
||||
return []
|
||||
else:
|
||||
|
||||
79
app/api/endpoints/transfer.py
Normal file
79
app/api/endpoints/transfer.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.schemas import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
||||
def manual_transfer(path: str,
|
||||
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),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
手动转移,支持自定义剧集识别格式
|
||||
:param path: 转移路径或文件
|
||||
: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 _: Token校验
|
||||
"""
|
||||
in_path = Path(path)
|
||||
if target:
|
||||
target = Path(target)
|
||||
if not target.exists():
|
||||
return schemas.Response(success=False, message=f"目标路径不存在")
|
||||
# 类型
|
||||
mtype = MediaType(type_name) if type_name else None
|
||||
# 自定义格式
|
||||
epformat = None
|
||||
if episode_offset or episode_part or episode_detail or episode_format:
|
||||
epformat = schemas.EpisodeFormat(
|
||||
format=episode_format,
|
||||
detail=episode_detail,
|
||||
part=episode_part,
|
||||
offset=episode_offset,
|
||||
)
|
||||
# 开始转移
|
||||
state, errormsg = TransferChain(db).manual_transfer(
|
||||
in_path=in_path,
|
||||
target=target,
|
||||
tmdbid=tmdbid,
|
||||
mtype=mtype,
|
||||
season=season,
|
||||
transfer_type=transfer_type,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize
|
||||
)
|
||||
# 失败
|
||||
if not state:
|
||||
if isinstance(errormsg, list):
|
||||
errormsg = f"整理完成,{len(errormsg)} 个文件转移失败!"
|
||||
return schemas.Response(success=False, message=errormsg)
|
||||
# 成功
|
||||
return schemas.Response(success=True)
|
||||
@@ -1,24 +1,27 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, Request, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.webhook import WebhookChain
|
||||
from app.core.config import settings
|
||||
from app.db import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def start_webhook_chain(body: Any, form: Any, args: Any):
|
||||
def start_webhook_chain(db: Session, body: Any, form: Any, args: Any):
|
||||
"""
|
||||
启动链式任务
|
||||
"""
|
||||
WebhookChain().message(body=body, form=form, args=args)
|
||||
WebhookChain(db).message(body=body, form=form, args=args)
|
||||
|
||||
|
||||
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||
async def webhook_message(background_tasks: BackgroundTasks,
|
||||
token: str, request: Request) -> Any:
|
||||
token: str, request: Request,
|
||||
db: Session = Depends(get_db),) -> Any:
|
||||
"""
|
||||
Webhook响应
|
||||
"""
|
||||
@@ -27,18 +30,19 @@ async def webhook_message(background_tasks: BackgroundTasks,
|
||||
body = await request.body()
|
||||
form = await request.form()
|
||||
args = request.query_params
|
||||
background_tasks.add_task(start_webhook_chain, body, form, args)
|
||||
background_tasks.add_task(start_webhook_chain, db, body, form, args)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||
async def webhook_message(background_tasks: BackgroundTasks,
|
||||
token: str, request: Request) -> Any:
|
||||
token: str, request: Request,
|
||||
db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
Webhook响应
|
||||
"""
|
||||
if token != settings.API_TOKEN:
|
||||
return schemas.Response(success=False, message="token认证不通过")
|
||||
args = request.query_params
|
||||
background_tasks.add_task(start_webhook_chain, None, None, args)
|
||||
background_tasks.add_task(start_webhook_chain, db, None, None, args)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from requests import Session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
@@ -31,7 +31,48 @@ def arr_system_status(apikey: str) -> Any:
|
||||
"appName": "MoviePilot",
|
||||
"instanceName": "moviepilot",
|
||||
"version": APP_VERSION,
|
||||
"urlBase": ""
|
||||
"buildTime": "",
|
||||
"isDebug": False,
|
||||
"isProduction": True,
|
||||
"isAdmin": True,
|
||||
"isUserInteractive": True,
|
||||
"startupPath": "/app",
|
||||
"appData": "/config",
|
||||
"osName": "debian",
|
||||
"osVersion": "",
|
||||
"isNetCore": True,
|
||||
"isLinux": True,
|
||||
"isOsx": False,
|
||||
"isWindows": False,
|
||||
"isDocker": True,
|
||||
"mode": "console",
|
||||
"branch": "main",
|
||||
"databaseType": "sqLite",
|
||||
"databaseVersion": {
|
||||
"major": 0,
|
||||
"minor": 0,
|
||||
"build": 0,
|
||||
"revision": 0,
|
||||
"majorRevision": 0,
|
||||
"minorRevision": 0
|
||||
},
|
||||
"authentication": "none",
|
||||
"migrationVersion": 0,
|
||||
"urlBase": "",
|
||||
"runtimeVersion": {
|
||||
"major": 0,
|
||||
"minor": 0,
|
||||
"build": 0,
|
||||
"revision": 0,
|
||||
"majorRevision": 0,
|
||||
"minorRevision": 0
|
||||
},
|
||||
"runtimeName": "",
|
||||
"startTime": "",
|
||||
"packageVersion": "",
|
||||
"packageAuthor": "jxxghp",
|
||||
"packageUpdateMechanism": "builtIn",
|
||||
"packageUpdateMechanismMessage": ""
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +89,35 @@ def arr_qualityProfile(apikey: str) -> Any:
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "默认"
|
||||
"name": "默认",
|
||||
"upgradeAllowed": True,
|
||||
"cutoff": 0,
|
||||
"items": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "默认",
|
||||
"quality": {
|
||||
"id": 0,
|
||||
"name": "默认",
|
||||
"source": "0",
|
||||
"resolution": 0
|
||||
},
|
||||
"items": [
|
||||
"string"
|
||||
],
|
||||
"allowed": True
|
||||
}
|
||||
],
|
||||
"minFormatScore": 0,
|
||||
"cutoffFormatScore": 0,
|
||||
"formatItems": [
|
||||
{
|
||||
"id": 0,
|
||||
"format": 0,
|
||||
"name": "默认",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -63,13 +132,10 @@ def arr_rootfolder(apikey: str) -> Any:
|
||||
status_code=403,
|
||||
detail="认证失败!",
|
||||
)
|
||||
library_path = "/"
|
||||
if settings.LIBRARY_PATH:
|
||||
library_path = settings.LIBRARY_PATH.split(",")[0]
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"path": library_path,
|
||||
"path": "/" if not settings.LIBRARY_PATHS else str(settings.LIBRARY_PATHS[0]),
|
||||
"accessible": True,
|
||||
"freeSpace": 0,
|
||||
"unmappedFolders": []
|
||||
@@ -235,11 +301,11 @@ def arr_movie_lookup(apikey: str, term: str, db: Session = Depends(get_db)) -> A
|
||||
)
|
||||
tmdbid = term.replace("tmdb:", "")
|
||||
# 查询媒体信息
|
||||
mediainfo = MediaChain().recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid))
|
||||
mediainfo = MediaChain(db).recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid))
|
||||
if not mediainfo:
|
||||
return [RadarrMovie()]
|
||||
# 查询是否已存在
|
||||
exists = MediaChain().media_exists(mediainfo=mediainfo)
|
||||
exists = MediaChain(db).media_exists(mediainfo=mediainfo)
|
||||
if not exists:
|
||||
# 文件不存在
|
||||
hasfile = False
|
||||
@@ -324,11 +390,11 @@ def arr_add_movie(apikey: str,
|
||||
"id": subscribe.id
|
||||
}
|
||||
# 添加订阅
|
||||
sid, message = SubscribeChain().add(title=movie.title,
|
||||
year=movie.year,
|
||||
mtype=MediaType.MOVIE,
|
||||
tmdbid=movie.tmdbId,
|
||||
userid="Seerr")
|
||||
sid, message = SubscribeChain(db).add(title=movie.title,
|
||||
year=movie.year,
|
||||
mtype=MediaType.MOVIE,
|
||||
tmdbid=movie.tmdbId,
|
||||
userid="Seerr")
|
||||
if sid:
|
||||
return {
|
||||
"id": sid
|
||||
@@ -487,7 +553,7 @@ def arr_series(apikey: str, db: Session = Depends(get_db)) -> Any:
|
||||
"seasonNumber": subscribe.season,
|
||||
"monitored": True,
|
||||
}],
|
||||
remotePoster=subscribe.image,
|
||||
remotePoster=subscribe.poster,
|
||||
year=subscribe.year,
|
||||
tmdbId=subscribe.tmdbid,
|
||||
tvdbId=subscribe.tvdbid,
|
||||
@@ -515,8 +581,8 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
|
||||
|
||||
# 获取TVDBID
|
||||
if not term.startswith("tvdb:"):
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
|
||||
mtype=MediaType.TV)
|
||||
mediainfo = MediaChain(db).recognize_media(meta=MetaInfo(term),
|
||||
mtype=MediaType.TV)
|
||||
if not mediainfo:
|
||||
return [SonarrSeries()]
|
||||
tvdbid = mediainfo.tvdb_id
|
||||
@@ -527,7 +593,7 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
|
||||
tvdbid = int(term.replace("tvdb:", ""))
|
||||
|
||||
# 查询TVDB信息
|
||||
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
|
||||
tvdbinfo = MediaChain(db).tvdb_info(tvdbid=tvdbid)
|
||||
if not tvdbinfo:
|
||||
return [SonarrSeries()]
|
||||
|
||||
@@ -539,11 +605,11 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
|
||||
|
||||
# 根据TVDB查询媒体信息
|
||||
if not mediainfo:
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
|
||||
mtype=MediaType.TV)
|
||||
mediainfo = MediaChain(db).recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
|
||||
mtype=MediaType.TV)
|
||||
|
||||
# 查询是否存在
|
||||
exists = MediaChain().media_exists(mediainfo)
|
||||
exists = MediaChain(db).media_exists(mediainfo)
|
||||
if exists:
|
||||
hasfile = True
|
||||
else:
|
||||
@@ -618,7 +684,7 @@ def arr_serie(apikey: str, tid: int, db: Session = Depends(get_db)) -> Any:
|
||||
"monitored": True,
|
||||
}],
|
||||
year=subscribe.year,
|
||||
remotePoster=subscribe.image,
|
||||
remotePoster=subscribe.poster,
|
||||
tmdbId=subscribe.tmdbid,
|
||||
tvdbId=subscribe.tvdbid,
|
||||
imdbId=subscribe.imdbid,
|
||||
@@ -666,12 +732,12 @@ def arr_add_series(apikey: str, tv: schemas.SonarrSeries,
|
||||
for season in left_seasons:
|
||||
if not season.get("monitored"):
|
||||
continue
|
||||
sid, message = SubscribeChain().add(title=tv.title,
|
||||
year=tv.year,
|
||||
season=season.get("seasonNumber"),
|
||||
tmdbid=tv.tmdbId,
|
||||
mtype=MediaType.TV,
|
||||
userid="Seerr")
|
||||
sid, message = SubscribeChain(db).add(title=tv.title,
|
||||
year=tv.year,
|
||||
season=season.get("seasonNumber"),
|
||||
tmdbid=tv.tmdbId,
|
||||
mtype=MediaType.TV,
|
||||
userid="Seerr")
|
||||
|
||||
if sid:
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Optional, Any, Tuple, List, Set, Union, Dict
|
||||
|
||||
from qbittorrentapi import TorrentFilesList
|
||||
from ruamel.yaml import CommentedMap
|
||||
from sqlalchemy.orm import Session
|
||||
from transmission_rpc import File
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -17,7 +18,7 @@ from app.core.meta import MetaBase
|
||||
from app.core.module import ModuleManager
|
||||
from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||
WebhookEventInfo
|
||||
WebhookEventInfo, TmdbEpisode
|
||||
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
|
||||
from app.utils.object import ObjectUtils
|
||||
|
||||
@@ -27,10 +28,11 @@ class ChainBase(metaclass=ABCMeta):
|
||||
处理链基类
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, db: Session = None):
|
||||
"""
|
||||
公共初始化
|
||||
"""
|
||||
self._db = db
|
||||
self.modulemanager = ModuleManager()
|
||||
self.eventmanager = EventManager()
|
||||
|
||||
@@ -95,8 +97,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
if isinstance(temp, list):
|
||||
result.extend(temp)
|
||||
else:
|
||||
# 返回结果非列表也非空,则继续执行下一模块
|
||||
continue
|
||||
# 中止继续执行
|
||||
break
|
||||
except Exception as err:
|
||||
logger.error(f"运行模块 {method} 出错:{module.__class__.__name__} - {err}\n{traceback.print_exc()}")
|
||||
return result
|
||||
@@ -113,6 +115,17 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("recognize_media", meta=meta, mtype=mtype, tmdbid=tmdbid)
|
||||
|
||||
def match_doubaninfo(self, name: str, mtype: str = None,
|
||||
year: str = None, season: int = None) -> Optional[dict]:
|
||||
"""
|
||||
搜索和匹配豆瓣信息
|
||||
:param name: 标题
|
||||
:param mtype: 类型
|
||||
:param year: 年份
|
||||
:param season: 季
|
||||
"""
|
||||
return self.run_module("match_doubaninfo", name=name, mtype=mtype, year=year, season=season)
|
||||
|
||||
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
"""
|
||||
补充抓取媒体信息图片
|
||||
@@ -195,19 +208,19 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return self.run_module("search_medias", meta=meta)
|
||||
|
||||
def search_torrents(self, site: CommentedMap,
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
keyword: str = None,
|
||||
keywords: List[str],
|
||||
mtype: MediaType = None,
|
||||
page: int = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
搜索一个站点的种子资源
|
||||
:param site: 站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param keyword: 搜索关键词,如有按关键词搜索,否则按媒体信息名称搜索
|
||||
:param keywords: 搜索关键词列表
|
||||
:param mtype: 媒体类型
|
||||
:param page: 页码
|
||||
:reutrn: 资源列表
|
||||
"""
|
||||
return self.run_module("search_torrents", mediainfo=mediainfo, site=site,
|
||||
keyword=keyword, page=page)
|
||||
return self.run_module("search_torrents", site=site, keywords=keywords,
|
||||
mtype=mtype, page=page)
|
||||
|
||||
def refresh_torrents(self, site: CommentedMap) -> List[TorrentInfo]:
|
||||
"""
|
||||
@@ -219,43 +232,45 @@ class ChainBase(metaclass=ABCMeta):
|
||||
|
||||
def filter_torrents(self, rule_string: str,
|
||||
torrent_list: List[TorrentInfo],
|
||||
season_episodes: Dict[int, list] = None) -> List[TorrentInfo]:
|
||||
season_episodes: Dict[int, list] = None,
|
||||
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
||||
"""
|
||||
过滤种子资源
|
||||
:param rule_string: 过滤规则
|
||||
:param torrent_list: 资源列表
|
||||
:param season_episodes: 季集数过滤 {season:[episodes]}
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 过滤后的资源列表,添加资源优先级
|
||||
"""
|
||||
return self.run_module("filter_torrents", rule_string=rule_string,
|
||||
torrent_list=torrent_list, season_episodes=season_episodes)
|
||||
torrent_list=torrent_list, season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
|
||||
def download(self, torrent_path: Path, download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None,
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None
|
||||
) -> Optional[Tuple[Optional[str], str]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
:param torrent_path: 种子文件地址
|
||||
:param content: 种子文件地址或者磁力链接
|
||||
:param download_dir: 下载目录
|
||||
:param cookie: cookie
|
||||
:param episodes: 需要下载的集数
|
||||
:param category: 种子分类
|
||||
:return: 种子Hash,错误信息
|
||||
"""
|
||||
return self.run_module("download", torrent_path=torrent_path, download_dir=download_dir,
|
||||
cookie=cookie, episodes=episodes, )
|
||||
return self.run_module("download", content=content, download_dir=download_dir,
|
||||
cookie=cookie, episodes=episodes, category=category)
|
||||
|
||||
def download_added(self, context: Context, torrent_path: Path, download_dir: Path) -> None:
|
||||
def download_added(self, context: Context, download_dir: Path, torrent_path: Path = None) -> None:
|
||||
"""
|
||||
添加下载任务成功后,从站点下载字幕,保存到下载目录
|
||||
:param context: 上下文,包括识别信息、媒体信息、种子信息
|
||||
:param torrent_path: 种子文件地址
|
||||
:param download_dir: 下载目录
|
||||
:param torrent_path: 种子文件地址
|
||||
:return: None,该方法可被多个模块同时处理
|
||||
"""
|
||||
if settings.DOWNLOAD_SUBTITLE:
|
||||
return self.run_module("download_added", context=context, torrent_path=torrent_path,
|
||||
download_dir=download_dir)
|
||||
return None
|
||||
return self.run_module("download_added", context=context, torrent_path=torrent_path,
|
||||
download_dir=download_dir)
|
||||
|
||||
def list_torrents(self, status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
@@ -267,23 +282,30 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("list_torrents", status=status, hashs=hashs)
|
||||
|
||||
def transfer(self, path: Path, mediainfo: MediaInfo, transfer_type: str) -> Optional[TransferInfo]:
|
||||
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transfer_type: str, target: Path = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
||||
"""
|
||||
文件转移
|
||||
:param path: 文件路径
|
||||
:param meta: 预识别的元数据
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移模式
|
||||
:param target: 转移目标路径
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
return self.run_module("transfer", path=path, mediainfo=mediainfo, transfer_type=transfer_type)
|
||||
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
|
||||
transfer_type=transfer_type, target=target,
|
||||
episodes_info=episodes_info)
|
||||
|
||||
def transfer_completed(self, hashs: Union[str, list], transinfo: TransferInfo) -> None:
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None) -> None:
|
||||
"""
|
||||
转移完成后的处理
|
||||
:param hashs: 种子Hash
|
||||
:param transinfo: 转移信息
|
||||
:param path: 源目录
|
||||
"""
|
||||
return self.run_module("transfer_completed", hashs=hashs, transinfo=transinfo)
|
||||
return self.run_module("transfer_completed", hashs=hashs, path=path)
|
||||
|
||||
def remove_torrents(self, hashs: Union[str, list]) -> bool:
|
||||
"""
|
||||
@@ -311,7 +333,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
|
||||
def torrent_files(self, tid: str) -> Optional[Union[TorrentFilesList, List[File]]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
获取种子文件
|
||||
:param tid: 种子Hash
|
||||
:return: 种子文件
|
||||
"""
|
||||
@@ -326,18 +348,16 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("media_exists", mediainfo=mediainfo, itemid=itemid)
|
||||
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> Optional[bool]:
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
|
||||
"""
|
||||
刷新媒体库
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param file_path: 文件路径
|
||||
:return: 成功或失败
|
||||
"""
|
||||
if settings.REFRESH_MEDIASERVER:
|
||||
return self.run_module("refresh_mediaserver", mediainfo=mediainfo, file_path=file_path)
|
||||
return None
|
||||
self.run_module("refresh_mediaserver", mediainfo=mediainfo, file_path=file_path)
|
||||
|
||||
def post_message(self, message: Notification) -> Optional[bool]:
|
||||
def post_message(self, message: Notification) -> None:
|
||||
"""
|
||||
发送消息
|
||||
:param message: 消息体
|
||||
@@ -352,7 +372,11 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"image": message.image,
|
||||
"userid": message.userid,
|
||||
})
|
||||
return self.run_module("post_message", message=message)
|
||||
logger.info(f"发送消息:channel={message.channel},"
|
||||
f"title={message.title}, "
|
||||
f"text={message.text},"
|
||||
f"userid={message.userid}")
|
||||
self.run_module("post_message", message=message)
|
||||
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
|
||||
"""
|
||||
@@ -379,18 +403,22 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 成功或失败
|
||||
"""
|
||||
if settings.SCRAP_METADATA:
|
||||
return self.run_module("scrape_metadata", path=path, mediainfo=mediainfo)
|
||||
return None
|
||||
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo)
|
||||
|
||||
def register_commands(self, commands: dict) -> None:
|
||||
def register_commands(self, commands: Dict[str, dict]) -> None:
|
||||
"""
|
||||
注册菜单命令
|
||||
"""
|
||||
return self.run_module("register_commands", commands=commands)
|
||||
self.run_module("register_commands", commands=commands)
|
||||
|
||||
def scheduler_job(self) -> None:
|
||||
"""
|
||||
定时任务,每10分钟调用一次,模块实现该接口以实现定时服务
|
||||
"""
|
||||
return self.run_module("scheduler_job")
|
||||
self.run_module("scheduler_job")
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""
|
||||
清理缓存,模块实现该接口响应清理缓存事件
|
||||
"""
|
||||
self.run_module("clear_cache")
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import base64
|
||||
from typing import Tuple, Optional, Union
|
||||
from typing import Tuple, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from lxml import etree
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.site import SiteChain
|
||||
from app.core.config import settings
|
||||
from app.db.siteicon_oper import SiteIconOper
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.siteicon_oper import SiteIconOper
|
||||
from app.helper.cloudflare import under_challenge
|
||||
from app.helper.cookiecloud import CookieCloudHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, NotificationType, MessageChannel
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.site import SiteUtils
|
||||
|
||||
|
||||
class CookieCloudChain(ChainBase):
|
||||
@@ -22,12 +25,13 @@ class CookieCloudChain(ChainBase):
|
||||
CookieCloud处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.siteoper = SiteOper()
|
||||
self.siteiconoper = SiteIconOper()
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
self.siteoper = SiteOper(self._db)
|
||||
self.siteiconoper = SiteIconOper(self._db)
|
||||
self.siteshelper = SitesHelper()
|
||||
self.sitechain = SiteChain()
|
||||
self.rsshelper = RssHelper()
|
||||
self.sitechain = SiteChain(self._db)
|
||||
self.message = MessageHelper()
|
||||
self.cookiecloud = CookieCloudHelper(
|
||||
server=settings.COOKIECLOUD_HOST,
|
||||
@@ -35,21 +39,6 @@ class CookieCloudChain(ChainBase):
|
||||
password=settings.COOKIECLOUD_PASSWORD
|
||||
)
|
||||
|
||||
def remote_sync(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
远程触发同步站点,发送消息
|
||||
"""
|
||||
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
|
||||
title="开始同步CookieCloud站点 ...", userid=userid))
|
||||
# 开始同步
|
||||
success, msg = self.process()
|
||||
if success:
|
||||
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
|
||||
title=f"同步站点成功,{msg}", userid=userid))
|
||||
else:
|
||||
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
|
||||
title=f"同步站点失败:{msg}", userid=userid))
|
||||
|
||||
def process(self, manual=False) -> Tuple[bool, str]:
|
||||
"""
|
||||
通过CookieCloud同步站点Cookie
|
||||
@@ -64,26 +53,77 @@ class CookieCloudChain(ChainBase):
|
||||
# 保存Cookie或新增站点
|
||||
_update_count = 0
|
||||
_add_count = 0
|
||||
_fail_count = 0
|
||||
for domain, cookie in cookies.items():
|
||||
# 获取站点信息
|
||||
indexer = self.siteshelper.get_indexer(domain)
|
||||
if self.siteoper.exists(domain):
|
||||
site_info = self.siteoper.get_by_domain(domain)
|
||||
if site_info:
|
||||
# 检查站点连通性
|
||||
status, msg = self.sitechain.test(domain)
|
||||
# 更新站点Cookie
|
||||
if status:
|
||||
logger.info(f"站点【{indexer.get('name')}】连通性正常,不同步CookieCloud数据")
|
||||
logger.info(f"站点【{site_info.name}】连通性正常,不同步CookieCloud数据")
|
||||
# 更新站点rss地址
|
||||
if not site_info.public and not site_info.rss:
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(
|
||||
url=site_info.url,
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT,
|
||||
proxy=True if site_info.proxy else False
|
||||
)
|
||||
if rss_url:
|
||||
logger.info(f"更新站点 {domain} RSS地址 ...")
|
||||
self.siteoper.update_rss(domain=domain, rss=rss_url)
|
||||
else:
|
||||
logger.warn(errmsg)
|
||||
continue
|
||||
# 更新站点Cookie
|
||||
logger.info(f"更新站点 {domain} Cookie ...")
|
||||
self.siteoper.update_cookie(domain=domain, cookies=cookie)
|
||||
_update_count += 1
|
||||
elif indexer:
|
||||
# 新增站点
|
||||
res = RequestUtils(cookies=cookie,
|
||||
ua=settings.USER_AGENT
|
||||
).get_res(url=indexer.get("domain"))
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
if not indexer.get("public") and not SiteUtils.is_logged_in(res.text):
|
||||
_fail_count += 1
|
||||
if under_challenge(res.text):
|
||||
logger.warn(f"站点 {indexer.get('name')} 被Cloudflare防护,无法登录,无法添加站点")
|
||||
continue
|
||||
logger.warn(
|
||||
f"站点 {indexer.get('name')} 登录失败,没有该站点账号或Cookie已失效,无法添加站点")
|
||||
continue
|
||||
elif res is not None:
|
||||
_fail_count += 1
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接状态码:{res.status_code},无法添加站点")
|
||||
continue
|
||||
else:
|
||||
_fail_count += 1
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
|
||||
continue
|
||||
# 获取rss地址
|
||||
rss_url = None
|
||||
if not indexer.get("public") and indexer.get("domain"):
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(url=indexer.get("domain"),
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT)
|
||||
if errmsg:
|
||||
logger.warn(errmsg)
|
||||
# 插入数据库
|
||||
logger.info(f"新增站点 {indexer.get('name')} ...")
|
||||
self.siteoper.add(name=indexer.get("name"),
|
||||
url=indexer.get("domain"),
|
||||
domain=domain,
|
||||
cookie=cookie,
|
||||
rss=rss_url,
|
||||
public=1 if indexer.get("public") else 0)
|
||||
_add_count += 1
|
||||
|
||||
# 保存站点图标
|
||||
if indexer:
|
||||
site_icon = self.siteiconoper.get_by_domain(domain)
|
||||
@@ -102,6 +142,8 @@ class CookieCloudChain(ChainBase):
|
||||
logger.warn(f"缓存站点 {indexer.get('name')} 图标失败")
|
||||
# 处理完成
|
||||
ret_msg = f"更新了{_update_count}个站点,新增了{_add_count}个站点"
|
||||
if _fail_count > 0:
|
||||
ret_msg += f",{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
|
||||
if manual:
|
||||
self.message.put(f"CookieCloud同步成功, {ret_msg}")
|
||||
logger.info(f"CookieCloud同步成功:{ret_msg}")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
|
||||
@@ -6,7 +8,7 @@ class DashboardChain(ChainBase):
|
||||
"""
|
||||
各类仪表板统计处理链
|
||||
"""
|
||||
def media_statistic(self) -> schemas.Statistic:
|
||||
def media_statistic(self) -> Optional[List[schemas.Statistic]]:
|
||||
"""
|
||||
媒体数量统计
|
||||
"""
|
||||
|
||||
@@ -41,7 +41,7 @@ class DoubanChain(ChainBase):
|
||||
if not mediainfo:
|
||||
logger.warn(f'{meta.name} 未识别到TMDB媒体信息')
|
||||
return Context(meta_info=meta, media_info=MediaInfo(douban_info=doubaninfo))
|
||||
logger.info(f'识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}{meta.season}')
|
||||
logger.info(f'识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year} {meta.season}')
|
||||
mediainfo.set_douban_info(doubaninfo)
|
||||
return Context(meta_info=meta, media_info=mediainfo)
|
||||
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Set, Dict, Union
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo, TorrentInfo, Context
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification
|
||||
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@@ -20,11 +27,11 @@ class DownloadChain(ChainBase):
|
||||
下载处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
self.torrent = TorrentHelper()
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.mediaserver = MediaServerOper()
|
||||
self.downloadhis = DownloadHistoryOper(self._db)
|
||||
self.mediaserver = MediaServerOper(self._db)
|
||||
|
||||
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
|
||||
channel: MessageChannel = None,
|
||||
@@ -33,8 +40,10 @@ class DownloadChain(ChainBase):
|
||||
发送添加下载的消息
|
||||
"""
|
||||
msg_text = ""
|
||||
if userid:
|
||||
msg_text = f"用户:{userid}"
|
||||
if torrent.site_name:
|
||||
msg_text = f"站点:{torrent.site_name}"
|
||||
msg_text = f"{msg_text}\n站点:{torrent.site_name}"
|
||||
if meta.resource_term:
|
||||
msg_text = f"{msg_text}\n质量:{meta.resource_term}"
|
||||
if torrent.size:
|
||||
@@ -45,9 +54,12 @@ class DownloadChain(ChainBase):
|
||||
msg_text = f"{msg_text}\n大小:{size}"
|
||||
if torrent.title:
|
||||
msg_text = f"{msg_text}\n种子:{torrent.title}"
|
||||
if torrent.pubdate:
|
||||
msg_text = f"{msg_text}\n发布时间:{torrent.pubdate}"
|
||||
if torrent.seeders:
|
||||
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
|
||||
msg_text = f"{msg_text}\n促销:{torrent.volume_factor}"
|
||||
if torrent.uploadvolumefactor and torrent.downloadvolumefactor:
|
||||
msg_text = f"{msg_text}\n促销:{torrent.volume_factor}"
|
||||
if torrent.hit_and_run:
|
||||
msg_text = f"{msg_text}\nHit&Run:是"
|
||||
if torrent.description:
|
||||
@@ -59,33 +71,100 @@ class DownloadChain(ChainBase):
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
mtype=NotificationType.Download,
|
||||
title=f"{mediainfo.title_year}"
|
||||
title=f"{mediainfo.title_year} "
|
||||
f"{meta.season_episode} 开始下载",
|
||||
text=msg_text,
|
||||
image=mediainfo.get_message_image(),
|
||||
userid=userid))
|
||||
image=mediainfo.get_message_image()))
|
||||
|
||||
def download_torrent(self, torrent: TorrentInfo,
|
||||
channel: MessageChannel = None,
|
||||
userid: Union[str, int] = None) -> Tuple[Optional[Path], str, list]:
|
||||
userid: Union[str, int] = None
|
||||
) -> Tuple[Optional[Union[Path, str]], str, list]:
|
||||
"""
|
||||
下载种子文件
|
||||
下载种子文件,如果是磁力链,会返回磁力链接本身
|
||||
:return: 种子路径,种子目录名,种子文件清单
|
||||
"""
|
||||
torrent_file, _, download_folder, files, error_msg = self.torrent.download_torrent(
|
||||
url=torrent.enclosure,
|
||||
|
||||
def __get_redict_url(url: str, ua: str = None, cookie: str = None) -> Optional[str]:
|
||||
"""
|
||||
获取下载链接, url格式:[base64]url
|
||||
"""
|
||||
# 获取[]中的内容
|
||||
m = re.search(r"\[(.*)](.*)", url)
|
||||
if m:
|
||||
# 参数
|
||||
base64_str = m.group(1)
|
||||
# URL
|
||||
url = m.group(2)
|
||||
if not base64_str:
|
||||
return url
|
||||
# 解码参数
|
||||
req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8')
|
||||
req_params: Dict[str, dict] = json.loads(req_str)
|
||||
if req_params.get('method') == 'get':
|
||||
# GET请求
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie
|
||||
).get_res(url, params=req_params.get('params'))
|
||||
else:
|
||||
# POST请求
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie
|
||||
).post_res(url, params=req_params.get('params'))
|
||||
if not res:
|
||||
return None
|
||||
if not req_params.get('result'):
|
||||
return res.text
|
||||
else:
|
||||
data = res.json()
|
||||
for key in str(req_params.get('result')).split("."):
|
||||
data = data.get(key)
|
||||
if not data:
|
||||
return None
|
||||
logger.info(f"获取到下载地址:{data}")
|
||||
return data
|
||||
return None
|
||||
|
||||
# 获取下载链接
|
||||
if not torrent.enclosure:
|
||||
return None, "", []
|
||||
if torrent.enclosure.startswith("magnet:"):
|
||||
return torrent.enclosure, "", []
|
||||
|
||||
if torrent.enclosure.startswith("["):
|
||||
# 需要解码获取下载地址
|
||||
torrent_url = __get_redict_url(url=torrent.enclosure,
|
||||
ua=torrent.site_ua,
|
||||
cookie=torrent.site_cookie)
|
||||
else:
|
||||
torrent_url = torrent.enclosure
|
||||
if not torrent_url:
|
||||
logger.error(f"{torrent.title} 无法获取下载地址:{torrent.enclosure}!")
|
||||
return None, "", []
|
||||
# 下载种子文件
|
||||
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
|
||||
url=torrent_url,
|
||||
cookie=torrent.site_cookie,
|
||||
ua=torrent.site_ua,
|
||||
proxy=torrent.site_proxy)
|
||||
|
||||
if isinstance(content, str):
|
||||
# 磁力链
|
||||
return content, "", []
|
||||
|
||||
if not torrent_file:
|
||||
logger.error(f"下载种子文件失败:{torrent.title} - {torrent.enclosure}")
|
||||
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
mtype=NotificationType.Download,
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{torrent.title} 种子下载失败!",
|
||||
text=f"错误信息:{error_msg}\n种子链接:{torrent.enclosure}",
|
||||
text=f"错误信息:{error_msg}\n站点:{torrent.site_name}",
|
||||
userid=userid))
|
||||
return None, "", []
|
||||
|
||||
# 返回 种子文件路径,种子目录名,种子文件清单
|
||||
return torrent_file, download_folder, files
|
||||
|
||||
def download_single(self, context: Context, torrent_file: Path = None,
|
||||
@@ -95,46 +174,87 @@ class DownloadChain(ChainBase):
|
||||
userid: Union[str, int] = None) -> Optional[str]:
|
||||
"""
|
||||
下载及发送通知
|
||||
:param context: 资源上下文
|
||||
:param torrent_file: 种子文件路径
|
||||
:param episodes: 需要下载的集数
|
||||
:param channel: 通知渠道
|
||||
:param save_path: 保存路径
|
||||
:param userid: 用户ID
|
||||
"""
|
||||
_torrent = context.torrent_info
|
||||
_media = context.media_info
|
||||
_meta = context.meta_info
|
||||
_folder_name = ""
|
||||
if not torrent_file:
|
||||
# 下载种子文件
|
||||
torrent_file, _folder_name, _ = self.download_torrent(_torrent, userid=userid)
|
||||
if not torrent_file:
|
||||
# 下载种子文件,得到的可能是文件也可能是磁力链
|
||||
content, _folder_name, _file_list = self.download_torrent(_torrent,
|
||||
channel=channel,
|
||||
userid=userid)
|
||||
if not content:
|
||||
return
|
||||
else:
|
||||
content = torrent_file
|
||||
# 获取种子文件的文件夹名和文件清单
|
||||
_folder_name, _file_list = self.torrent.get_torrent_info(torrent_file)
|
||||
|
||||
# 下载目录
|
||||
if not save_path:
|
||||
if settings.DOWNLOAD_CATEGORY and _media and _media.category:
|
||||
# 开启下载二级目录
|
||||
if _media.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
download_dir = Path(settings.DOWNLOAD_MOVIE_PATH or settings.DOWNLOAD_PATH) / _media.category
|
||||
else:
|
||||
download_dir = Path(settings.DOWNLOAD_TV_PATH or settings.DOWNLOAD_PATH) / _media.category
|
||||
if settings.DOWNLOAD_ANIME_PATH \
|
||||
and _media.genre_ids \
|
||||
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
# 动漫
|
||||
download_dir = Path(settings.DOWNLOAD_ANIME_PATH)
|
||||
else:
|
||||
# 电视剧
|
||||
download_dir = Path(settings.DOWNLOAD_TV_PATH or settings.DOWNLOAD_PATH) / _media.category
|
||||
elif _media:
|
||||
# 未开启下载二级目录
|
||||
if _media.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
download_dir = Path(settings.DOWNLOAD_MOVIE_PATH or settings.DOWNLOAD_PATH)
|
||||
else:
|
||||
download_dir = Path(settings.DOWNLOAD_TV_PATH or settings.DOWNLOAD_PATH)
|
||||
if settings.DOWNLOAD_ANIME_PATH \
|
||||
and _media.genre_ids \
|
||||
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
# 动漫
|
||||
download_dir = Path(settings.DOWNLOAD_ANIME_PATH)
|
||||
else:
|
||||
# 电视剧
|
||||
download_dir = Path(settings.DOWNLOAD_TV_PATH or settings.DOWNLOAD_PATH)
|
||||
else:
|
||||
# 未识别
|
||||
download_dir = Path(settings.DOWNLOAD_PATH)
|
||||
else:
|
||||
# 自定义下载目录
|
||||
download_dir = Path(save_path)
|
||||
|
||||
# 添加下载
|
||||
result: Optional[tuple] = self.download(torrent_path=torrent_file,
|
||||
result: Optional[tuple] = self.download(content=content,
|
||||
cookie=_torrent.site_cookie,
|
||||
episodes=episodes,
|
||||
download_dir=download_dir)
|
||||
download_dir=download_dir,
|
||||
category=_media.category)
|
||||
if result:
|
||||
_hash, error_msg = result
|
||||
else:
|
||||
_hash, error_msg = None, "未知错误"
|
||||
|
||||
if _hash:
|
||||
# 下载文件路径
|
||||
if _folder_name:
|
||||
download_path = download_dir / _folder_name
|
||||
else:
|
||||
download_path = download_dir / _file_list[0] if _file_list else download_dir
|
||||
|
||||
# 登记下载记录
|
||||
self.downloadhis.add(
|
||||
path=_folder_name or _torrent.title,
|
||||
path=str(download_path),
|
||||
type=_media.type.value,
|
||||
title=_media.title,
|
||||
year=_media.year,
|
||||
@@ -148,16 +268,39 @@ class DownloadChain(ChainBase):
|
||||
download_hash=_hash,
|
||||
torrent_name=_torrent.title,
|
||||
torrent_description=_torrent.description,
|
||||
torrent_site=_torrent.site_name
|
||||
torrent_site=_torrent.site_name,
|
||||
userid=userid,
|
||||
channel=channel.value if channel else None,
|
||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
)
|
||||
|
||||
# 登记下载文件
|
||||
files_to_add = []
|
||||
for file in _file_list:
|
||||
if episodes:
|
||||
# 识别文件集
|
||||
file_meta = MetaInfo(Path(file).stem)
|
||||
if not file_meta.begin_episode \
|
||||
or file_meta.begin_episode not in episodes:
|
||||
continue
|
||||
files_to_add.append({
|
||||
"download_hash": _hash,
|
||||
"downloader": settings.DOWNLOADER,
|
||||
"fullpath": str(download_dir / _folder_name / file),
|
||||
"savepath": str(download_dir / _folder_name),
|
||||
"filepath": file,
|
||||
"torrentname": _meta.org_string,
|
||||
})
|
||||
if files_to_add:
|
||||
self.downloadhis.add_files(files_to_add)
|
||||
|
||||
# 发送消息
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, channel=channel)
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, channel=channel, userid=userid)
|
||||
# 下载成功后处理
|
||||
self.download_added(context=context, torrent_path=torrent_file, download_dir=download_dir)
|
||||
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
||||
# 广播事件
|
||||
self.eventmanager.send_event(EventType.DownloadAdded, {
|
||||
"hash": _hash,
|
||||
"torrent_file": torrent_file,
|
||||
"context": context
|
||||
})
|
||||
else:
|
||||
@@ -166,12 +309,11 @@ class DownloadChain(ChainBase):
|
||||
f"{_torrent.title} - {_torrent.enclosure},{error_msg}")
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
mtype=NotificationType.Download,
|
||||
mtype=NotificationType.Manual,
|
||||
title="添加下载任务失败:%s %s"
|
||||
% (_media.title_year, _meta.season_episode),
|
||||
text=f"站点:{_torrent.site_name}\n"
|
||||
f"种子名称:{_meta.org_string}\n"
|
||||
f"种子链接:{_torrent.enclosure}\n"
|
||||
f"错误信息:{error_msg}",
|
||||
image=_media.get_message_image(),
|
||||
userid=userid))
|
||||
@@ -181,12 +323,14 @@ class DownloadChain(ChainBase):
|
||||
contexts: List[Context],
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
save_path: str = None,
|
||||
channel: MessageChannel = None,
|
||||
userid: str = None) -> Tuple[List[Context], Dict[int, Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
根据缺失数据,自动种子列表中组合择优下载
|
||||
:param contexts: 资源上下文列表
|
||||
:param no_exists: 缺失的剧集信息
|
||||
:param save_path: 保存路径
|
||||
:param channel: 通知渠道
|
||||
:param userid: 用户ID
|
||||
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id] = {season: NotExistMediaInfo}
|
||||
"""
|
||||
@@ -239,10 +383,10 @@ class DownloadChain(ChainBase):
|
||||
获取需要的季的集数
|
||||
"""
|
||||
if not no_exists.get(tmdbid):
|
||||
return 0
|
||||
return 9999
|
||||
no_exist = no_exists.get(tmdbid)
|
||||
if not no_exist.get(season):
|
||||
return 0
|
||||
return 9999
|
||||
return no_exist[season].total_episode
|
||||
|
||||
# 分组排序
|
||||
@@ -251,7 +395,8 @@ class DownloadChain(ChainBase):
|
||||
# 如果是电影,直接下载
|
||||
for context in contexts:
|
||||
if context.media_info.type == MediaType.MOVIE:
|
||||
if self.download_single(context, save_path=save_path, userid=userid):
|
||||
if self.download_single(context, save_path=save_path,
|
||||
channel=channel, userid=userid):
|
||||
# 下载成功
|
||||
downloaded_list.append(context)
|
||||
|
||||
@@ -292,25 +437,39 @@ class DownloadChain(ChainBase):
|
||||
if set(torrent_season).issubset(set(need_season)):
|
||||
if len(torrent_season) == 1:
|
||||
# 只有一季的可能是命名错误,需要打开种子鉴别,只有实际集数大于等于总集数才下载
|
||||
torrent_path, _, torrent_files = self.download_torrent(torrent)
|
||||
if not torrent_path:
|
||||
content, _, torrent_files = self.download_torrent(torrent)
|
||||
if not content:
|
||||
continue
|
||||
if isinstance(content, str):
|
||||
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法确定种子文件集数")
|
||||
continue
|
||||
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||
if not torrent_episodes \
|
||||
or len(torrent_episodes) >= __get_season_episodes(need_tmdbid,
|
||||
torrent_season[0]):
|
||||
# 下载
|
||||
download_id = self.download_single(context=context,
|
||||
torrent_file=torrent_path,
|
||||
save_path=save_path,
|
||||
userid=userid)
|
||||
else:
|
||||
logger.info(
|
||||
f"{meta.org_string} 解析文件集数为 {len(torrent_episodes)},未含所需集数")
|
||||
logger.info(f"{meta.org_string} 解析文件集数为 {torrent_episodes}")
|
||||
if not torrent_episodes:
|
||||
continue
|
||||
# 总集数
|
||||
need_total = __get_season_episodes(need_tmdbid, torrent_season[0])
|
||||
if len(torrent_episodes) < need_total:
|
||||
# 更新集数范围
|
||||
begin_ep = min(torrent_episodes)
|
||||
end_ep = max(torrent_episodes)
|
||||
meta.set_episodes(begin=begin_ep, end=end_ep)
|
||||
logger.info(
|
||||
f"{meta.org_string} 解析文件集数发现不是完整合集")
|
||||
continue
|
||||
else:
|
||||
# 下载
|
||||
download_id = self.download_single(
|
||||
context=context,
|
||||
torrent_file=content if isinstance(content, Path) else None,
|
||||
save_path=save_path,
|
||||
channel=channel,
|
||||
userid=userid
|
||||
)
|
||||
else:
|
||||
# 下载
|
||||
download_id = self.download_single(context, save_path=save_path, userid=userid)
|
||||
download_id = self.download_single(context, save_path=save_path,
|
||||
channel=channel, userid=userid)
|
||||
|
||||
if download_id:
|
||||
# 下载成功
|
||||
@@ -368,7 +527,8 @@ class DownloadChain(ChainBase):
|
||||
# 为需要集的子集则下载
|
||||
if torrent_episodes.issubset(set(need_episodes)):
|
||||
# 下载
|
||||
download_id = self.download_single(context, save_path=save_path, userid=userid)
|
||||
download_id = self.download_single(context, save_path=save_path,
|
||||
channel=channel, userid=userid)
|
||||
if download_id:
|
||||
# 下载成功
|
||||
downloaded_list.append(context)
|
||||
@@ -424,22 +584,30 @@ class DownloadChain(ChainBase):
|
||||
and len(meta.season_list) == 1 \
|
||||
and meta.season_list[0] == need_season:
|
||||
# 检查种子看是否有需要的集
|
||||
torrent_path, _, torrent_files = self.download_torrent(torrent, userid=userid)
|
||||
if not torrent_path:
|
||||
content, _, torrent_files = self.download_torrent(torrent)
|
||||
if not content:
|
||||
continue
|
||||
if isinstance(content, str):
|
||||
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法解析种子文件集数")
|
||||
continue
|
||||
# 种子全部集
|
||||
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||
logger.info(f"{torrent.site_name} - {meta.org_string} 解析文件集数:{torrent_episodes}")
|
||||
# 选中的集
|
||||
selected_episodes = set(torrent_episodes).intersection(set(need_episodes))
|
||||
if not selected_episodes:
|
||||
logger.info(f"{torrent.site_name} - {torrent.title} 没有需要的集,跳过...")
|
||||
continue
|
||||
logger.info(f"{torrent.site_name} - {torrent.title} 选中集数:{selected_episodes}")
|
||||
# 添加下载
|
||||
download_id = self.download_single(context=context,
|
||||
torrent_file=torrent_path,
|
||||
episodes=selected_episodes,
|
||||
save_path=save_path,
|
||||
userid=userid)
|
||||
download_id = self.download_single(
|
||||
context=context,
|
||||
torrent_file=content if isinstance(content, Path) else None,
|
||||
episodes=selected_episodes,
|
||||
save_path=save_path,
|
||||
channel=channel,
|
||||
userid=userid
|
||||
)
|
||||
if not download_id:
|
||||
continue
|
||||
# 把识别的集更新到上下文
|
||||
@@ -458,13 +626,15 @@ class DownloadChain(ChainBase):
|
||||
|
||||
def get_no_exists_info(self, meta: MetaBase,
|
||||
mediainfo: MediaInfo,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
totals: Dict[int, int] = None
|
||||
) -> Tuple[bool, Dict[int, Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
检查媒体库,查询是否存在,对于剧集同时返回不存在的季集信息
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 已识别的媒体信息
|
||||
:param no_exists: 在调用该方法前已经存储的不存在的季集信息,有传入时该函数搜索的内容将会叠加后输出
|
||||
:param totals: 电视剧每季的总集数
|
||||
:return: 当前媒体是否缺失,各标题总的季集和缺失的季集
|
||||
"""
|
||||
|
||||
@@ -497,6 +667,10 @@ class DownloadChain(ChainBase):
|
||||
|
||||
if not no_exists:
|
||||
no_exists = {}
|
||||
|
||||
if not totals:
|
||||
totals = {}
|
||||
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
@@ -521,40 +695,54 @@ class DownloadChain(ChainBase):
|
||||
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=mediainfo.season)
|
||||
# 媒体库已存在的剧集
|
||||
exists_tvs: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid)
|
||||
if not exists_tvs:
|
||||
# 所有剧集均缺失
|
||||
# 所有季集均缺失
|
||||
for season, episodes in mediainfo.seasons.items():
|
||||
if not episodes:
|
||||
continue
|
||||
# 全季不存在
|
||||
if meta.begin_season \
|
||||
if meta.season_list \
|
||||
and season not in meta.season_list:
|
||||
continue
|
||||
__append_no_exists(_season=season, _episodes=[], _total=len(episodes), _start=min(episodes))
|
||||
# 总集数
|
||||
total_ep = totals.get(season) or len(episodes)
|
||||
__append_no_exists(_season=season, _episodes=[],
|
||||
_total=total_ep, _start=min(episodes))
|
||||
return False, no_exists
|
||||
else:
|
||||
# 存在一些,检查缺失的季集
|
||||
# 存在一些,检查每季缺失的季集
|
||||
for season, episodes in mediainfo.seasons.items():
|
||||
if meta.begin_season \
|
||||
and season not in meta.season_list:
|
||||
continue
|
||||
if not episodes:
|
||||
continue
|
||||
exist_seasons = exists_tvs.seasons
|
||||
if exist_seasons.get(season):
|
||||
# 取差集
|
||||
lack_episodes = list(set(episodes).difference(set(exist_seasons[season])))
|
||||
# 该季总集数
|
||||
season_total = totals.get(season) or len(episodes)
|
||||
# 该季已存在的集
|
||||
exist_episodes = exists_tvs.seasons.get(season)
|
||||
if exist_episodes:
|
||||
# 已存在取差集
|
||||
if totals.get(season):
|
||||
# 按总集数计算缺失集(开始集为TMDB中的最小集)
|
||||
lack_episodes = list(set(range(min(episodes),
|
||||
season_total + min(episodes))
|
||||
).difference(set(exist_episodes)))
|
||||
else:
|
||||
# 按TMDB集数计算缺失集
|
||||
lack_episodes = list(set(episodes).difference(set(exist_episodes)))
|
||||
if not lack_episodes:
|
||||
# 全部集存在
|
||||
continue
|
||||
# 添加不存在的季集信息
|
||||
__append_no_exists(_season=season, _episodes=lack_episodes,
|
||||
_total=len(episodes), _start=min(episodes))
|
||||
_total=season_total, _start=min(lack_episodes))
|
||||
else:
|
||||
# 全季不存在
|
||||
__append_no_exists(_season=season, _episodes=[],
|
||||
_total=len(episodes), _start=min(episodes))
|
||||
_total=season_total, _start=min(episodes))
|
||||
# 存在不完整的剧集
|
||||
if no_exists:
|
||||
logger.debug(f"媒体库中已存在部分剧集,缺失:{no_exists}")
|
||||
@@ -571,7 +759,8 @@ class DownloadChain(ChainBase):
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
mtype=NotificationType.Download,
|
||||
title="没有正在下载的任务!"))
|
||||
title="没有正在下载的任务!",
|
||||
userid=userid))
|
||||
return
|
||||
# 发送消息
|
||||
title = f"共 {len(torrents)} 个任务正在下载:"
|
||||
@@ -580,7 +769,7 @@ class DownloadChain(ChainBase):
|
||||
for torrent in torrents:
|
||||
messages.append(f"{index}. {torrent.title} "
|
||||
f"{StringUtils.str_filesize(torrent.size)} "
|
||||
f"{round(torrent.progress * 100, 1)}%")
|
||||
f"{round(torrent.progress, 1)}%")
|
||||
index += 1
|
||||
self.post_message(Notification(
|
||||
channel=channel, mtype=NotificationType.Download,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import Context, MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -31,6 +32,25 @@ class MediaChain(ChainBase):
|
||||
# 返回上下文
|
||||
return Context(meta_info=metainfo, media_info=mediainfo)
|
||||
|
||||
def recognize_by_path(self, path: str) -> Optional[Context]:
|
||||
"""
|
||||
根据文件路径识别媒体信息
|
||||
"""
|
||||
logger.info(f'开始识别媒体信息,文件:{path} ...')
|
||||
file_path = Path(path)
|
||||
# 元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
# 识别媒体信息
|
||||
mediainfo = self.recognize_media(meta=file_meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{path} 未识别到媒体信息')
|
||||
return Context(meta_info=file_meta)
|
||||
logger.info(f'{path} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}')
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
# 返回上下文
|
||||
return Context(meta_info=file_meta, media_info=mediainfo)
|
||||
|
||||
def search(self, title: str) -> Tuple[MetaBase, List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
|
||||
@@ -2,12 +2,14 @@ import json
|
||||
import threading
|
||||
from typing import List, Union, Generator
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.db import SessionFactory
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.log import logger
|
||||
from app.schemas import MessageChannel, Notification
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
@@ -17,72 +19,85 @@ class MediaServerChain(ChainBase):
|
||||
媒体服务器处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.mediaserverdb = MediaServerOper()
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
|
||||
def librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
def librarys(self, server: str) -> List[schemas.MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库
|
||||
"""
|
||||
return self.run_module("mediaserver_librarys")
|
||||
return self.run_module("mediaserver_librarys", server=server)
|
||||
|
||||
def items(self, library_id: Union[str, int]) -> Generator:
|
||||
def items(self, server: str, library_id: Union[str, int]) -> List[schemas.MediaServerItem]:
|
||||
"""
|
||||
获取媒体服务器所有项目
|
||||
"""
|
||||
return self.run_module("mediaserver_items", library_id=library_id)
|
||||
return self.run_module("mediaserver_items", server=server, library_id=library_id)
|
||||
|
||||
def episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
||||
def iteminfo(self, server: str, item_id: Union[str, int]) -> schemas.MediaServerItem:
|
||||
"""
|
||||
获取媒体服务器项目信息
|
||||
"""
|
||||
return self.run_module("mediaserver_iteminfo", server=server, item_id=item_id)
|
||||
|
||||
def episodes(self, server: str, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
||||
"""
|
||||
获取媒体服务器剧集信息
|
||||
"""
|
||||
return self.run_module("mediaserver_tv_episodes", item_id=item_id)
|
||||
|
||||
def remote_sync(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
同步豆瓣想看数据,发送消息
|
||||
"""
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="开始媒体服务器 ...", userid=userid))
|
||||
self.sync()
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="同步媒体服务器完成!", userid=userid))
|
||||
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
同步媒体库所有数据到本地数据库
|
||||
"""
|
||||
with lock:
|
||||
logger.info("开始同步媒体库数据 ...")
|
||||
# 媒体服务器同步使用独立的会话
|
||||
_db = SessionFactory()
|
||||
_dbOper = MediaServerOper(_db)
|
||||
# 汇总统计
|
||||
total_count = 0
|
||||
# 清空登记薄
|
||||
self.mediaserverdb.empty(server=settings.MEDIASERVER)
|
||||
for library in self.librarys():
|
||||
logger.info(f"正在同步媒体库 {library.name} ...")
|
||||
library_count = 0
|
||||
for item in self.items(library.id):
|
||||
if not item:
|
||||
_dbOper.empty(server=settings.MEDIASERVER)
|
||||
# 同步黑名单
|
||||
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
|
||||
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
|
||||
# 设置的媒体服务器
|
||||
if not settings.MEDIASERVER:
|
||||
return
|
||||
mediaservers = settings.MEDIASERVER.split(",")
|
||||
# 遍历媒体服务器
|
||||
for mediaserver in mediaservers:
|
||||
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")
|
||||
for library in self.librarys(mediaserver):
|
||||
# 同步黑名单 跳过
|
||||
if library.name in sync_blacklist:
|
||||
continue
|
||||
if not item.item_id:
|
||||
continue
|
||||
# 计数
|
||||
library_count += 1
|
||||
seasoninfo = {}
|
||||
# 类型
|
||||
item_type = "电视剧" if item.item_type in ['Series', 'show'] else "电影"
|
||||
if item_type == "电视剧":
|
||||
# 查询剧集信息
|
||||
espisodes_info = self.episodes(item.item_id) or []
|
||||
for episode in espisodes_info:
|
||||
seasoninfo[episode.season] = episode.episodes
|
||||
# 插入数据
|
||||
item_dict = item.dict()
|
||||
item_dict['seasoninfo'] = json.dumps(seasoninfo)
|
||||
item_dict['item_type'] = item_type
|
||||
self.mediaserverdb.add(**item_dict)
|
||||
logger.info(f"媒体库 {library.name} 同步完成,共同步数量:{library_count}")
|
||||
# 总数累加
|
||||
total_count += library_count
|
||||
logger.info(f"正在同步 {mediaserver} 媒体库 {library.name} ...")
|
||||
library_count = 0
|
||||
for item in self.items(mediaserver, library.id):
|
||||
if not item:
|
||||
continue
|
||||
if not item.item_id:
|
||||
continue
|
||||
# 计数
|
||||
library_count += 1
|
||||
seasoninfo = {}
|
||||
# 类型
|
||||
item_type = "电视剧" if item.item_type in ['Series', 'show'] else "电影"
|
||||
if item_type == "电视剧":
|
||||
# 查询剧集信息
|
||||
espisodes_info = self.episodes(mediaserver, item.item_id) or []
|
||||
for episode in espisodes_info:
|
||||
seasoninfo[episode.season] = episode.episodes
|
||||
# 插入数据
|
||||
item_dict = item.dict()
|
||||
item_dict['seasoninfo'] = json.dumps(seasoninfo)
|
||||
item_dict['item_type'] = item_type
|
||||
_dbOper.add(**item_dict)
|
||||
logger.info(f"{mediaserver} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
|
||||
# 总数累加
|
||||
total_count += library_count
|
||||
# 关闭数据库连接
|
||||
if _db:
|
||||
_db.close()
|
||||
logger.info("【MediaServer】媒体库数据同步完成,同步数量:%s" % total_count)
|
||||
|
||||
@@ -27,12 +27,12 @@ class MessageChain(ChainBase):
|
||||
# 每页数据量
|
||||
_page_size: int = 8
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.downloadchain = DownloadChain()
|
||||
self.subscribechain = SubscribeChain()
|
||||
self.searchchain = SearchChain()
|
||||
self.medtachain = MediaChain()
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
self.downloadchain = DownloadChain(self._db)
|
||||
self.subscribechain = SubscribeChain(self._db)
|
||||
self.searchchain = SearchChain(self._db)
|
||||
self.medtachain = MediaChain(self._db)
|
||||
self.torrent = TorrentHelper()
|
||||
self.eventmanager = EventManager()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
@@ -130,19 +130,29 @@ class MessageChain(ChainBase):
|
||||
return
|
||||
# 搜索结果排序
|
||||
contexts = self.torrenthelper.sort_torrents(contexts)
|
||||
# 更新缓存
|
||||
user_cache[userid] = {
|
||||
"type": "Torrent",
|
||||
"items": contexts
|
||||
}
|
||||
_current_page = 0
|
||||
# 发送种子数据
|
||||
logger.info(f"搜索到 {len(contexts)} 条数据,开始发送选择消息 ...")
|
||||
self.__post_torrents_message(channel=channel,
|
||||
title=mediainfo.title,
|
||||
items=contexts[:self._page_size],
|
||||
userid=userid,
|
||||
total=len(contexts))
|
||||
# 判断是否设置自动下载
|
||||
auto_download_user = settings.AUTO_DOWNLOAD_USER
|
||||
# 匹配到自动下载用户
|
||||
if auto_download_user and any(userid == user for user in auto_download_user.split(",")):
|
||||
logger.info(f"用户 {userid} 在自动下载用户中,开始自动择优下载")
|
||||
# 自动选择下载
|
||||
self.__auto_download(channel=channel,
|
||||
cache_list=contexts,
|
||||
userid=userid,
|
||||
username=username)
|
||||
else:
|
||||
# 更新缓存
|
||||
user_cache[userid] = {
|
||||
"type": "Torrent",
|
||||
"items": contexts
|
||||
}
|
||||
# 发送种子数据
|
||||
logger.info(f"搜索到 {len(contexts)} 条数据,开始发送选择消息 ...")
|
||||
self.__post_torrents_message(channel=channel,
|
||||
title=mediainfo.title,
|
||||
items=contexts[:self._page_size],
|
||||
userid=userid,
|
||||
total=len(contexts))
|
||||
|
||||
elif cache_type == "Subscribe":
|
||||
# 订阅媒体
|
||||
@@ -169,41 +179,15 @@ class MessageChain(ChainBase):
|
||||
elif cache_type == "Torrent":
|
||||
if int(text) == 0:
|
||||
# 自动选择下载
|
||||
# 查询缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
||||
mediainfo=_current_media)
|
||||
if exist_flag:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
title=f"{_current_media.title_year}"
|
||||
f"{_current_meta.sea} 媒体库中已存在",
|
||||
userid=userid))
|
||||
return
|
||||
# 批量下载
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
|
||||
no_exists=no_exists,
|
||||
userid=userid)
|
||||
if downloads and not lefts:
|
||||
# 全部下载完成
|
||||
logger.info(f'{_current_media.title_year} 下载完成')
|
||||
else:
|
||||
# 未完成下载
|
||||
logger.info(f'{_current_media.title_year} 未下载未完整,添加订阅 ...')
|
||||
# 添加订阅,状态为R
|
||||
self.subscribechain.add(title=_current_media.title,
|
||||
year=_current_media.year,
|
||||
mtype=_current_media.type,
|
||||
tmdbid=_current_media.tmdb_id,
|
||||
season=_current_meta.begin_season,
|
||||
channel=channel,
|
||||
userid=userid,
|
||||
username=username,
|
||||
state="R")
|
||||
self.__auto_download(channel=channel,
|
||||
cache_list=cache_list,
|
||||
userid=userid,
|
||||
username=username)
|
||||
else:
|
||||
# 下载种子
|
||||
context: Context = cache_list[int(text) - 1]
|
||||
# 下载
|
||||
self.downloadchain.download_single(context, userid=userid)
|
||||
self.downloadchain.download_single(context, userid=userid, channel=channel)
|
||||
|
||||
elif text.lower() == "p":
|
||||
# 上一页
|
||||
@@ -230,6 +214,11 @@ class MessageChain(ChainBase):
|
||||
start = _current_page * self._page_size
|
||||
end = start + self._page_size
|
||||
if cache_type == "Torrent":
|
||||
# 更新缓存
|
||||
user_cache[userid] = {
|
||||
"type": "Torrent",
|
||||
"items": cache_list[start:end]
|
||||
}
|
||||
# 发送种子数据
|
||||
self.__post_torrents_message(channel=channel,
|
||||
title=_current_media.title,
|
||||
@@ -267,6 +256,11 @@ class MessageChain(ChainBase):
|
||||
# 加一页
|
||||
_current_page += 1
|
||||
if cache_type == "Torrent":
|
||||
# 更新缓存
|
||||
user_cache[userid] = {
|
||||
"type": "Torrent",
|
||||
"items": cache_list
|
||||
}
|
||||
# 发送种子数据
|
||||
self.__post_torrents_message(channel=channel,
|
||||
title=_current_media.title,
|
||||
@@ -337,6 +331,42 @@ class MessageChain(ChainBase):
|
||||
# 保存缓存
|
||||
self.save_cache(user_cache, self._cache_file)
|
||||
|
||||
def __auto_download(self, channel, cache_list, userid, username):
|
||||
"""
|
||||
自动择优下载
|
||||
"""
|
||||
# 查询缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
||||
mediainfo=_current_media)
|
||||
if exist_flag:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
title=f"{_current_media.title_year}"
|
||||
f"{_current_meta.sea} 媒体库中已存在",
|
||||
userid=userid))
|
||||
return
|
||||
# 批量下载
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
|
||||
no_exists=no_exists,
|
||||
channel=channel,
|
||||
userid=userid)
|
||||
if downloads and not lefts:
|
||||
# 全部下载完成
|
||||
logger.info(f'{_current_media.title_year} 下载完成')
|
||||
else:
|
||||
# 未完成下载
|
||||
logger.info(f'{_current_media.title_year} 未下载未完整,添加订阅 ...')
|
||||
# 添加订阅,状态为R
|
||||
self.subscribechain.add(title=_current_media.title,
|
||||
year=_current_media.year,
|
||||
mtype=_current_media.type,
|
||||
tmdbid=_current_media.tmdb_id,
|
||||
season=_current_meta.begin_season,
|
||||
channel=channel,
|
||||
userid=userid,
|
||||
username=username,
|
||||
state="R")
|
||||
|
||||
def __post_medias_message(self, channel: MessageChannel,
|
||||
title: str, items: list, userid: str, total: int):
|
||||
"""
|
||||
|
||||
278
app/chain/rss.py
278
app/chain/rss.py
@@ -1,278 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Tuple, Optional
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.download import DownloadChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context, TorrentInfo, MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.rss_oper import RssOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, NotExistMediaInfo
|
||||
from app.schemas.types import SystemConfigKey, MediaType, NotificationType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class RssChain(ChainBase):
|
||||
"""
|
||||
RSS处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.rssoper = RssOper()
|
||||
self.sites = SitesHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.downloadchain = DownloadChain()
|
||||
self.message = MessageHelper()
|
||||
|
||||
def add(self, title: str, year: str,
|
||||
mtype: MediaType = None,
|
||||
season: int = None,
|
||||
**kwargs) -> Tuple[Optional[int], str]:
|
||||
"""
|
||||
识别媒体信息并添加订阅
|
||||
"""
|
||||
logger.info(f'开始添加自定义订阅,标题:{title} ...')
|
||||
# 识别元数据
|
||||
metainfo = MetaInfo(title)
|
||||
if year:
|
||||
metainfo.year = year
|
||||
if mtype:
|
||||
metainfo.type = mtype
|
||||
if season:
|
||||
metainfo.type = MediaType.TV
|
||||
metainfo.begin_season = season
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=metainfo)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{title} 未识别到媒体信息')
|
||||
return None, "未识别到媒体信息"
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
# 总集数
|
||||
if mediainfo.type == MediaType.TV:
|
||||
if not season:
|
||||
season = 1
|
||||
# 总集数
|
||||
if not kwargs.get('total_episode'):
|
||||
if not mediainfo.seasons:
|
||||
# 补充媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id)
|
||||
if not mediainfo:
|
||||
logger.error(f"媒体信息识别失败!")
|
||||
return None, "媒体信息识别失败"
|
||||
if not mediainfo.seasons:
|
||||
logger.error(f"{title} 媒体信息中没有季集信息")
|
||||
return None, "媒体信息中没有季集信息"
|
||||
total_episode = len(mediainfo.seasons.get(season) or [])
|
||||
if not total_episode:
|
||||
logger.error(f'{title} 未获取到总集数')
|
||||
return None, "未获取到总集数"
|
||||
kwargs.update({
|
||||
'total_episode': total_episode
|
||||
})
|
||||
# 检查是否存在
|
||||
if self.rssoper.exists(tmdbid=mediainfo.tmdb_id, season=season):
|
||||
logger.warn(f'{mediainfo.title} 已存在')
|
||||
return None, f'{mediainfo.title} 自定义订阅已存在'
|
||||
if not kwargs.get("name"):
|
||||
kwargs.update({
|
||||
"name": mediainfo.title
|
||||
})
|
||||
kwargs.update({
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"backdrop": mediainfo.get_backdrop_image(),
|
||||
"vote": mediainfo.vote_average,
|
||||
"description": mediainfo.overview,
|
||||
})
|
||||
# 添加订阅
|
||||
sid = self.rssoper.add(title=title, year=year, season=season, **kwargs)
|
||||
if not sid:
|
||||
logger.error(f'{mediainfo.title_year} 添加自定义订阅失败')
|
||||
return None, "添加自定义订阅失败"
|
||||
else:
|
||||
logger.info(f'{mediainfo.title_year}{metainfo.season} 添加订阅成功')
|
||||
|
||||
# 返回结果
|
||||
return sid, ""
|
||||
|
||||
def refresh(self, rssid: int = None, manual: bool = False):
|
||||
"""
|
||||
刷新RSS订阅数据
|
||||
"""
|
||||
# 所有RSS订阅
|
||||
logger.info("开始刷新RSS订阅数据 ...")
|
||||
rss_tasks = self.rssoper.list(rssid) or []
|
||||
for rss_task in rss_tasks:
|
||||
if not rss_task:
|
||||
continue
|
||||
if not rss_task.url:
|
||||
continue
|
||||
# 下载Rss报文
|
||||
items = RssHelper.parse(rss_task.url, True if rss_task.proxy else False)
|
||||
if not items:
|
||||
logger.error(f"RSS未下载到数据:{rss_task.url}")
|
||||
logger.info(f"{rss_task.name} RSS下载到数据:{len(items)}")
|
||||
# 检查站点
|
||||
domain = StringUtils.get_url_domain(rss_task.url)
|
||||
site_info = self.sites.get_indexer(domain) or {}
|
||||
# 过滤规则
|
||||
if rss_task.best_version:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules2)
|
||||
else:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
|
||||
# 处理RSS条目
|
||||
matched_contexts = []
|
||||
# 处理过的title
|
||||
processed_data = json.loads(rss_task.note) if rss_task.note else {
|
||||
"titles": [],
|
||||
"season_episodes": []
|
||||
}
|
||||
for item in items:
|
||||
if not item.get("title"):
|
||||
continue
|
||||
# 标题是否已处理过
|
||||
if item.get("title") in processed_data.get('titles'):
|
||||
logger.info(f"{item.get('title')} 已处理过")
|
||||
continue
|
||||
# 基本要素匹配
|
||||
if rss_task.include \
|
||||
and not re.search(r"%s" % rss_task.include, item.get("title")):
|
||||
logger.info(f"{item.get('title')} 未包含 {rss_task.include}")
|
||||
continue
|
||||
if rss_task.exclude \
|
||||
and re.search(r"%s" % rss_task.exclude, item.get("title")):
|
||||
logger.info(f"{item.get('title')} 包含 {rss_task.exclude}")
|
||||
continue
|
||||
# 识别媒体信息
|
||||
meta = MetaInfo(title=item.get("title"), subtitle=item.get("description"))
|
||||
if not meta.name:
|
||||
logger.error(f"{item.get('title')} 未识别到有效信息")
|
||||
continue
|
||||
mediainfo = self.recognize_media(meta=meta)
|
||||
if not mediainfo:
|
||||
logger.error(f"{item.get('title')} 未识别到TMDB媒体信息")
|
||||
continue
|
||||
if mediainfo.tmdb_id != rss_task.tmdbid:
|
||||
logger.error(f"{item.get('title')} 不匹配")
|
||||
continue
|
||||
# 季集是否已处理过
|
||||
if meta.season_episode in processed_data.get('season_episodes'):
|
||||
logger.info(f"{meta.season_episode} 已处理过")
|
||||
continue
|
||||
# 种子
|
||||
torrentinfo = TorrentInfo(
|
||||
site=site_info.get("id"),
|
||||
site_name=site_info.get("name"),
|
||||
site_cookie=site_info.get("cookie"),
|
||||
site_ua=site_info.get("cookie") or settings.USER_AGENT,
|
||||
site_proxy=site_info.get("proxy") or rss_task.proxy,
|
||||
site_order=site_info.get("pri"),
|
||||
title=item.get("title"),
|
||||
description=item.get("description"),
|
||||
enclosure=item.get("enclosure"),
|
||||
page_url=item.get("link"),
|
||||
size=item.get("size"),
|
||||
pubdate=time.strftime("%Y-%m-%d %H:%M:%S", item.get("pubdate")) if item.get("pubdate") else None,
|
||||
)
|
||||
# 过滤种子
|
||||
if rss_task.filter:
|
||||
result = self.filter_torrents(
|
||||
rule_string=filter_rule,
|
||||
torrent_list=[torrentinfo]
|
||||
)
|
||||
if not result:
|
||||
logger.info(f"{rss_task.name} 不匹配过滤规则")
|
||||
continue
|
||||
# 更新已处理数据
|
||||
processed_data['titles'].append(item.get("title"))
|
||||
processed_data['season_episodes'].append(meta.season_episode)
|
||||
# 清除多条数据
|
||||
mediainfo.clear()
|
||||
# 匹配到的数据
|
||||
matched_contexts.append(Context(
|
||||
meta_info=meta,
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrentinfo
|
||||
))
|
||||
# 更新已处理过的title
|
||||
self.rssoper.update(rssid=rss_task.id, note=json.dumps(processed_data))
|
||||
if not matched_contexts:
|
||||
logger.info(f"{rss_task.name} 未匹配到数据")
|
||||
continue
|
||||
logger.info(f"{rss_task.name} 匹配到 {len(matched_contexts)} 条数据")
|
||||
# 查询本地存在情况
|
||||
if not rss_task.best_version:
|
||||
# 查询缺失的媒体信息
|
||||
rss_meta = MetaInfo(title=rss_task.title)
|
||||
rss_meta.year = rss_task.year
|
||||
rss_meta.begin_season = rss_task.season
|
||||
rss_meta.type = MediaType(rss_task.type)
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
|
||||
meta=rss_meta,
|
||||
mediainfo=MediaInfo(
|
||||
title=rss_task.title,
|
||||
year=rss_task.year,
|
||||
tmdb_id=rss_task.tmdbid,
|
||||
season=rss_task.season
|
||||
),
|
||||
)
|
||||
if exist_flag:
|
||||
logger.info(f'{rss_task.name} 媒体库中已存在,完成订阅')
|
||||
self.rssoper.delete(rss_task.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'自定义订阅 {rss_task.name} 已完成',
|
||||
image=rss_task.backdrop))
|
||||
continue
|
||||
elif rss_meta.type == MediaType.TV.value:
|
||||
# 打印缺失集信息
|
||||
if no_exists and no_exists.get(rss_task.tmdbid):
|
||||
no_exists_info = no_exists.get(rss_task.tmdbid).get(rss_task.season)
|
||||
if no_exists_info:
|
||||
logger.info(f'订阅 {rss_task.name} 缺失集:{no_exists_info.episodes}')
|
||||
else:
|
||||
if rss_task.type == MediaType.TV.value:
|
||||
no_exists = {
|
||||
rss_task.season: NotExistMediaInfo(
|
||||
season=rss_task.season,
|
||||
episodes=[],
|
||||
total_episode=rss_task.total_episode,
|
||||
start_episode=1)
|
||||
}
|
||||
else:
|
||||
no_exists = {}
|
||||
# 开始下载
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=matched_contexts,
|
||||
no_exists=no_exists,
|
||||
save_path=rss_task.save_path)
|
||||
if downloads and not lefts:
|
||||
if not rss_task.best_version:
|
||||
self.rssoper.delete(rss_task.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'自定义订阅 {rss_task.name} 已完成',
|
||||
image=rss_task.backdrop))
|
||||
# 未完成下载
|
||||
logger.info(f'{rss_task.name} 未下载未完整,继续订阅 ...')
|
||||
if downloads:
|
||||
# 更新最后更新时间和已处理数量
|
||||
self.rssoper.update(rssid=rss_task.id,
|
||||
processed=(rss_task.processed or 0) + len(downloads),
|
||||
last_update=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
logger.info("刷新RSS订阅数据完成")
|
||||
if manual:
|
||||
if len(rss_tasks) == 1:
|
||||
self.message.put(f"{rss_tasks[0].name} 自定义订阅刷新完成")
|
||||
else:
|
||||
self.message.put(f"自定义订阅刷新完成")
|
||||
@@ -1,9 +1,12 @@
|
||||
import pickle
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import Context
|
||||
from app.core.context import MediaInfo, TorrentInfo
|
||||
@@ -23,24 +26,25 @@ class SearchChain(ChainBase):
|
||||
站点资源搜索处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
self.siteshelper = SitesHelper()
|
||||
self.progress = ProgressHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
|
||||
def search_by_tmdbid(self, tmdbid: int, mtype: MediaType = None) -> List[Context]:
|
||||
def search_by_tmdbid(self, tmdbid: int, mtype: MediaType = None, area: str = "title") -> List[Context]:
|
||||
"""
|
||||
根据TMDB ID搜索资源,精确匹配,但不不过滤本地存在的资源
|
||||
:param tmdbid: TMDB ID
|
||||
:param mtype: 媒体,电影 or 电视剧
|
||||
:param area: 搜索范围,title or imdbid
|
||||
"""
|
||||
mediainfo = self.recognize_media(tmdbid=tmdbid, mtype=mtype)
|
||||
if not mediainfo:
|
||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||
return []
|
||||
results = self.process(mediainfo=mediainfo)
|
||||
results = self.process(mediainfo=mediainfo, area=area)
|
||||
# 保存眲结果
|
||||
bytes_results = pickle.dumps(results)
|
||||
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
|
||||
@@ -58,7 +62,7 @@ class SearchChain(ChainBase):
|
||||
else:
|
||||
logger.info(f'开始浏览资源,站点:{site} ...')
|
||||
# 搜索
|
||||
return self.__search_all_sites(keyword=title, sites=[site] if site else None, page=page) or []
|
||||
return self.__search_all_sites(keywords=[title], sites=[site] if site else None, page=page) or []
|
||||
|
||||
def last_search_results(self) -> List[Context]:
|
||||
"""
|
||||
@@ -73,34 +77,22 @@ class SearchChain(ChainBase):
|
||||
print(str(e))
|
||||
return []
|
||||
|
||||
def browse(self, domain: str, keyword: str = None) -> List[TorrentInfo]:
|
||||
"""
|
||||
浏览站点首页内容
|
||||
:param domain: 站点域名
|
||||
:param keyword: 关键词,有值时为搜索
|
||||
"""
|
||||
if not keyword:
|
||||
logger.info(f'开始浏览站点首页内容,站点:{domain} ...')
|
||||
else:
|
||||
logger.info(f'开始搜索资源,关键词:{keyword},站点:{domain} ...')
|
||||
site = self.siteshelper.get_indexer(domain)
|
||||
if not site:
|
||||
logger.error(f'站点 {domain} 不存在!')
|
||||
return []
|
||||
return self.search_torrents(site=site, keyword=keyword)
|
||||
|
||||
def process(self, mediainfo: MediaInfo,
|
||||
keyword: str = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
sites: List[int] = None,
|
||||
filter_rule: str = None) -> List[Context]:
|
||||
priority_rule: str = None,
|
||||
filter_rule: Dict[str, str] = None,
|
||||
area: str = "title") -> List[Context]:
|
||||
"""
|
||||
根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
||||
:param mediainfo: 媒体信息
|
||||
:param keyword: 搜索关键词
|
||||
:param no_exists: 缺失的媒体信息
|
||||
:param sites: 站点ID列表,为空时搜索所有站点
|
||||
:param priority_rule: 优先级规则,为空时使用搜索优先级规则
|
||||
:param filter_rule: 过滤规则,为空是使用默认过滤规则
|
||||
:param area: 搜索范围,title or imdbid
|
||||
"""
|
||||
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||
# 补充媒体信息
|
||||
@@ -125,32 +117,36 @@ class SearchChain(ChainBase):
|
||||
else:
|
||||
keywords = [mediainfo.title]
|
||||
# 执行搜索
|
||||
torrents: List[TorrentInfo] = []
|
||||
for keyword in keywords:
|
||||
torrents = self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
sites=sites
|
||||
)
|
||||
if torrents:
|
||||
break
|
||||
torrents: List[TorrentInfo] = self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keywords=keywords,
|
||||
sites=sites,
|
||||
area=area
|
||||
)
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||
return []
|
||||
# 过滤种子
|
||||
if filter_rule is None:
|
||||
# 取默认过滤规则
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
|
||||
if filter_rule:
|
||||
logger.info(f'开始过滤资源,当前规则:{filter_rule} ...')
|
||||
result: List[TorrentInfo] = self.filter_torrents(rule_string=filter_rule,
|
||||
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=torrents,
|
||||
season_episodes=season_episodes)
|
||||
season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
if result is not None:
|
||||
torrents = result
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤条件的资源')
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
||||
return []
|
||||
# 使用默认过滤规则再次过滤
|
||||
torrents = self.filter_torrents_by_rule(torrents=torrents,
|
||||
filter_rule=filter_rule)
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
||||
return []
|
||||
# 匹配的资源
|
||||
_match_torrents = []
|
||||
# 总数
|
||||
@@ -183,29 +179,42 @@ class SearchChain(ChainBase):
|
||||
# 比对年份
|
||||
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:
|
||||
# 需要电影
|
||||
if torrent_meta.year != mediainfo.year:
|
||||
# 电影年份,上下浮动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}')
|
||||
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
_match_torrents.append(torrent)
|
||||
continue
|
||||
# 在副标题中判断是否存在标题与原语种标题
|
||||
if torrent.description:
|
||||
subtitle = torrent.description.split()
|
||||
if (StringUtils.is_chinese(mediainfo.title)
|
||||
and str(mediainfo.title) in subtitle) \
|
||||
or (StringUtils.is_chinese(mediainfo.original_title)
|
||||
and str(mediainfo.original_title) in subtitle):
|
||||
logger.info(f'{mediainfo.title} 通过副标题匹配到资源:{torrent.site_name} - {torrent.title},'
|
||||
f'副标题:{torrent.description}')
|
||||
_match_torrents.append(torrent)
|
||||
continue
|
||||
# 比对别名和译名
|
||||
for name in mediainfo.names:
|
||||
if StringUtils.clear_upper(name) == meta_name:
|
||||
logger.info(f'{mediainfo.title} 匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
logger.info(f'{mediainfo.title} 通过别名或译名匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
_match_torrents.append(torrent)
|
||||
break
|
||||
else:
|
||||
@@ -228,28 +237,30 @@ class SearchChain(ChainBase):
|
||||
# 返回
|
||||
return contexts
|
||||
|
||||
def __search_all_sites(self, mediainfo: Optional[MediaInfo] = None,
|
||||
keyword: str = None,
|
||||
def __search_all_sites(self, keywords: List[str],
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
sites: List[int] = None,
|
||||
page: int = 0) -> Optional[List[TorrentInfo]]:
|
||||
page: int = 0,
|
||||
area: str = "title") -> Optional[List[TorrentInfo]]:
|
||||
"""
|
||||
多线程搜索多个站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param keyword: 搜索关键词,如有按关键词搜索,否则按媒体信息名称搜索
|
||||
:param keywords: 搜索关键词列表
|
||||
:param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点
|
||||
:param page: 搜索页码
|
||||
:param area: 搜索区域 title or imdbid
|
||||
:reutrn: 资源列表
|
||||
"""
|
||||
# 未开启的站点不搜索
|
||||
indexer_sites = []
|
||||
|
||||
# 配置的索引站点
|
||||
if sites:
|
||||
config_indexers = [str(sid) for sid in sites]
|
||||
else:
|
||||
config_indexers = [str(sid) for sid in self.systemconfig.get(SystemConfigKey.IndexerSites) or []]
|
||||
if not sites:
|
||||
sites = self.systemconfig.get(SystemConfigKey.IndexerSites) or []
|
||||
|
||||
for indexer in self.siteshelper.get_indexers():
|
||||
# 检查站点索引开关
|
||||
if not config_indexers or str(indexer.get("id")) in config_indexers:
|
||||
if not sites or indexer.get("id") in sites:
|
||||
# 站点流控
|
||||
state, msg = self.siteshelper.check(indexer.get("domain"))
|
||||
if state:
|
||||
@@ -259,6 +270,7 @@ class SearchChain(ChainBase):
|
||||
if not indexer_sites:
|
||||
logger.warn('未开启任何有效站点,无法搜索资源')
|
||||
return []
|
||||
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.Search)
|
||||
# 开始计时
|
||||
@@ -275,8 +287,18 @@ class SearchChain(ChainBase):
|
||||
executor = ThreadPoolExecutor(max_workers=len(indexer_sites))
|
||||
all_task = []
|
||||
for site in indexer_sites:
|
||||
task = executor.submit(self.search_torrents, mediainfo=mediainfo,
|
||||
site=site, keyword=keyword, page=page)
|
||||
if area == "imdbid":
|
||||
# 搜索IMDBID
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=[mediainfo.imdb_id] if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
else:
|
||||
# 搜索标题
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=keywords,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
all_task.append(task)
|
||||
# 结果集
|
||||
results = []
|
||||
@@ -287,7 +309,7 @@ class SearchChain(ChainBase):
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
self.progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 计算耗时
|
||||
end_time = datetime.now()
|
||||
@@ -300,3 +322,44 @@ class SearchChain(ChainBase):
|
||||
self.progress.end(ProgressKey.Search)
|
||||
# 返回
|
||||
return results
|
||||
|
||||
def filter_torrents_by_rule(self,
|
||||
torrents: List[TorrentInfo],
|
||||
filter_rule: Dict[str, str] = None
|
||||
) -> List[TorrentInfo]:
|
||||
"""
|
||||
使用过滤规则过滤种子
|
||||
:param torrents: 种子列表
|
||||
:param filter_rule: 过滤规则
|
||||
"""
|
||||
|
||||
# 取默认过滤规则
|
||||
if not filter_rule:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules)
|
||||
if not filter_rule:
|
||||
return torrents
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
|
||||
def __filter_torrent(t: TorrentInfo) -> bool:
|
||||
"""
|
||||
过滤种子
|
||||
"""
|
||||
# 包含
|
||||
if include:
|
||||
if not re.search(r"%s" % include,
|
||||
f"{t.title} {t.description}", re.I):
|
||||
logger.info(f"{t.title} 不匹配包含规则 {include}")
|
||||
return False
|
||||
# 排除
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude,
|
||||
f"{t.title} {t.description}", re.I):
|
||||
logger.info(f"{t.title} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
return True
|
||||
|
||||
# 使用默认过滤规则再次过滤
|
||||
return list(filter(lambda t: __filter_torrent(t), torrents))
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import re
|
||||
from typing import Union, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.db.models.site import Site
|
||||
@@ -20,12 +23,72 @@ class SiteChain(ChainBase):
|
||||
站点管理处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.siteoper = SiteOper()
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
self.siteoper = SiteOper(self._db)
|
||||
self.cookiehelper = CookieHelper()
|
||||
self.message = MessageHelper()
|
||||
|
||||
# 特殊站点登录验证
|
||||
self.special_site_test = {
|
||||
"zhuque.in": self.__zhuque_test,
|
||||
# "m-team.io": self.__mteam_test,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def __zhuque_test(site: Site) -> Tuple[bool, str]:
|
||||
"""
|
||||
判断站点是否已经登陆:zhuique
|
||||
"""
|
||||
# 获取token
|
||||
token = None
|
||||
res = RequestUtils(
|
||||
ua=site.ua,
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
).get_res(url=site.url)
|
||||
if res and res.status_code == 200:
|
||||
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
|
||||
if csrf_token:
|
||||
token = csrf_token.group(1)
|
||||
if not token:
|
||||
return False, "无法获取Token"
|
||||
# 调用查询用户信息接口
|
||||
user_res = RequestUtils(
|
||||
headers={
|
||||
'X-CSRF-TOKEN': token,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"User-Agent": f"{site.ua}"
|
||||
},
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
).get_res(url=f"{site.url}api/user/getInfo")
|
||||
if user_res and user_res.status_code == 200:
|
||||
user_info = user_res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
return True, "连接成功"
|
||||
return False, "Cookie已失效"
|
||||
|
||||
@staticmethod
|
||||
def __mteam_test(site: Site) -> Tuple[bool, str]:
|
||||
"""
|
||||
判断站点是否已经登陆:m-team
|
||||
"""
|
||||
url = f"{site.url}api/member/profile"
|
||||
res = RequestUtils(
|
||||
ua=site.ua,
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
).post_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
user_info = res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
return True, "连接成功"
|
||||
return False, "Cookie已失效"
|
||||
|
||||
def test(self, url: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试站点是否可用
|
||||
@@ -37,6 +100,12 @@ class SiteChain(ChainBase):
|
||||
site_info = self.siteoper.get_by_domain(domain)
|
||||
if not site_info:
|
||||
return False, f"站点【{url}】不存在"
|
||||
|
||||
# 特殊站点测试
|
||||
if self.special_site_test.get(domain):
|
||||
return self.special_site_test[domain](site_info)
|
||||
|
||||
# 通用站点测试
|
||||
site_url = site_info.url
|
||||
site_cookie = site_info.cookie
|
||||
ua = site_info.ua
|
||||
@@ -89,7 +158,8 @@ class SiteChain(ChainBase):
|
||||
if not site_list:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
title="没有维护任何站点信息!"))
|
||||
title="没有维护任何站点信息!",
|
||||
userid=userid))
|
||||
title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \
|
||||
f"\n- 禁用站点:/site_disable [id]" \
|
||||
f"\n- 启用站点:/site_enable [id]" \
|
||||
@@ -219,8 +289,8 @@ class SiteChain(ChainBase):
|
||||
title=f"站点编号 {site_id} 不存在!", userid=userid))
|
||||
return
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
title=f"开始更新【{site_info.name}】Cookie&UA ...", userid=userid))
|
||||
channel=channel,
|
||||
title=f"开始更新【{site_info.name}】Cookie&UA ...", userid=userid))
|
||||
# 用户名
|
||||
username = args[1]
|
||||
# 密码
|
||||
|
||||
@@ -3,21 +3,22 @@ import re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Union, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.core.config import settings
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.context import TorrentInfo, Context, MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.schemas import NotExistMediaInfo, Notification
|
||||
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class SubscribeChain(ChainBase):
|
||||
@@ -25,14 +26,12 @@ class SubscribeChain(ChainBase):
|
||||
订阅管理处理链
|
||||
"""
|
||||
|
||||
_cache_file = "__torrents_cache__"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.downloadchain = DownloadChain()
|
||||
self.searchchain = SearchChain()
|
||||
self.subscribehelper = SubscribeOper()
|
||||
self.siteshelper = SitesHelper()
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
self.downloadchain = DownloadChain(self._db)
|
||||
self.searchchain = SearchChain(self._db)
|
||||
self.subscribeoper = SubscribeOper(self._db)
|
||||
self.torrentschain = TorrentsChain()
|
||||
self.message = MessageHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
@@ -96,21 +95,21 @@ class SubscribeChain(ChainBase):
|
||||
'lack_episode': kwargs.get('total_episode')
|
||||
})
|
||||
# 添加订阅
|
||||
sid, err_msg = self.subscribehelper.add(mediainfo, doubanid=doubanid,
|
||||
season=season, username=username, **kwargs)
|
||||
sid, err_msg = self.subscribeoper.add(mediainfo, doubanid=doubanid,
|
||||
season=season, username=username, **kwargs)
|
||||
if not sid:
|
||||
logger.error(f'{mediainfo.title_year} {err_msg}')
|
||||
if not exist_ok and message:
|
||||
# 发回原用户
|
||||
self.post_message(Notification(channel=channel,
|
||||
mtype=NotificationType.Subscribe,
|
||||
title=f"{mediainfo.title_year}{metainfo.season} "
|
||||
title=f"{mediainfo.title_year} {metainfo.season} "
|
||||
f"添加订阅失败!",
|
||||
text=f"{err_msg}",
|
||||
image=mediainfo.get_message_image(),
|
||||
userid=userid))
|
||||
elif message:
|
||||
logger.info(f'{mediainfo.title_year}{metainfo.season} 添加订阅成功')
|
||||
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
|
||||
if username or userid:
|
||||
text = f"评分:{mediainfo.vote_average},来自用户:{username or userid}"
|
||||
else:
|
||||
@@ -118,50 +117,20 @@ class SubscribeChain(ChainBase):
|
||||
# 广而告之
|
||||
self.post_message(Notification(channel=channel,
|
||||
mtype=NotificationType.Subscribe,
|
||||
title=f"{mediainfo.title_year}{metainfo.season} 已添加订阅",
|
||||
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
||||
text=text,
|
||||
image=mediainfo.get_message_image()))
|
||||
# 返回结果
|
||||
return sid, ""
|
||||
|
||||
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
def exists(self, mediainfo: MediaInfo, meta: MetaBase = None):
|
||||
"""
|
||||
远程刷新订阅,发送消息
|
||||
判断订阅是否已存在
|
||||
"""
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"开始刷新订阅 ...", userid=userid))
|
||||
self.refresh()
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"订阅刷新完成!", userid=userid))
|
||||
|
||||
def remote_search(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
远程搜索订阅,发送消息
|
||||
"""
|
||||
if arg_str and not str(arg_str).isdigit():
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="请输入正确的命令格式:/subscribe_search [id],"
|
||||
"[id]为订阅编号,不输入订阅编号时搜索所有订阅", userid=userid))
|
||||
return
|
||||
if arg_str:
|
||||
sid = int(arg_str)
|
||||
subscribe = self.subscribehelper.get(sid)
|
||||
if not subscribe:
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"订阅编号 {sid} 不存在!", userid=userid))
|
||||
return
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"开始搜索 {subscribe.name} ...", userid=userid))
|
||||
# 搜索订阅
|
||||
self.search(sid=int(arg_str))
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"{subscribe.name} 搜索完成!", userid=userid))
|
||||
else:
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"开始搜索所有订阅 ...", userid=userid))
|
||||
self.search(state='R')
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"订阅搜索完成!", userid=userid))
|
||||
if self.subscribeoper.exists(tmdbid=mediainfo.tmdb_id,
|
||||
season=meta.begin_season if meta else None):
|
||||
return True
|
||||
return False
|
||||
|
||||
def search(self, sid: int = None, state: str = 'N', manual: bool = False):
|
||||
"""
|
||||
@@ -172,15 +141,22 @@ class SubscribeChain(ChainBase):
|
||||
:return: 更新订阅状态为R或删除订阅
|
||||
"""
|
||||
if sid:
|
||||
subscribes = [self.subscribehelper.get(sid)]
|
||||
subscribes = [self.subscribeoper.get(sid)]
|
||||
else:
|
||||
subscribes = self.subscribehelper.list(state)
|
||||
subscribes = self.subscribeoper.list(state)
|
||||
# 遍历订阅
|
||||
for subscribe in subscribes:
|
||||
# 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间
|
||||
if subscribe.date:
|
||||
now = datetime.now()
|
||||
subscribe_time = datetime.strptime(subscribe.date, '%Y-%m-%d %H:%M:%S')
|
||||
if (now - subscribe_time).total_seconds() < 60:
|
||||
logger.debug(f"订阅标题:{subscribe.name} 新增小于1分钟,暂不搜索...")
|
||||
continue
|
||||
logger.info(f'开始搜索订阅,标题:{subscribe.name} ...')
|
||||
# 如果状态为N则更新为R
|
||||
if subscribe.state == 'N':
|
||||
self.subscribehelper.update(subscribe.id, {'state': 'R'})
|
||||
self.subscribeoper.update(subscribe.id, {'state': 'R'})
|
||||
# 生成元数据
|
||||
meta = MetaInfo(subscribe.name)
|
||||
meta.year = subscribe.year
|
||||
@@ -194,14 +170,24 @@ class SubscribeChain(ChainBase):
|
||||
|
||||
# 非洗版状态
|
||||
if not subscribe.best_version:
|
||||
# 每季总集数
|
||||
totals = {}
|
||||
if subscribe.season and subscribe.total_episode:
|
||||
totals = {
|
||||
subscribe.season: subscribe.total_episode
|
||||
}
|
||||
# 查询缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo)
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
totals=totals
|
||||
)
|
||||
if exist_flag:
|
||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在,完成订阅')
|
||||
self.subscribehelper.delete(subscribe.id)
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year}{meta.season} 已完成订阅',
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
|
||||
image=mediainfo.get_message_image()))
|
||||
continue
|
||||
# 电视剧订阅
|
||||
@@ -219,7 +205,7 @@ class SubscribeChain(ChainBase):
|
||||
if no_exists and no_exists.get(subscribe.tmdbid):
|
||||
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
|
||||
if no_exists_info:
|
||||
logger.info(f'订阅 {mediainfo.title_year}{meta.season} 缺失集:{no_exists_info.episodes}')
|
||||
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
||||
else:
|
||||
# 洗版状态
|
||||
if meta.type == MediaType.TV:
|
||||
@@ -237,22 +223,31 @@ class SubscribeChain(ChainBase):
|
||||
sites = json.loads(subscribe.sites)
|
||||
else:
|
||||
sites = None
|
||||
# 过滤规则
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules2)
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
||||
else:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
|
||||
# 默认过滤规则
|
||||
if subscribe.include or subscribe.exclude:
|
||||
filter_rule = {
|
||||
"include": subscribe.include,
|
||||
"exclude": subscribe.exclude
|
||||
}
|
||||
else:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules)
|
||||
# 搜索,同时电视剧会过滤掉不需要的剧集
|
||||
contexts = self.searchchain.process(mediainfo=mediainfo,
|
||||
keyword=subscribe.keyword,
|
||||
no_exists=no_exists,
|
||||
sites=sites,
|
||||
priority_rule=priority_rule,
|
||||
filter_rule=filter_rule)
|
||||
if not contexts:
|
||||
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||
if meta.type == MediaType.TV:
|
||||
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
||||
self.__upate_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
|
||||
continue
|
||||
# 过滤
|
||||
matched_contexts = []
|
||||
@@ -260,22 +255,12 @@ class SubscribeChain(ChainBase):
|
||||
torrent_meta = context.meta_info
|
||||
torrent_info = context.torrent_info
|
||||
torrent_mediainfo = context.media_info
|
||||
# 包含
|
||||
if subscribe.include:
|
||||
if not re.search(r"%s" % subscribe.include,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
continue
|
||||
# 排除
|
||||
if subscribe.exclude:
|
||||
if re.search(r"%s" % subscribe.exclude,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
continue
|
||||
# 非洗版
|
||||
if not subscribe.best_version:
|
||||
# 如果是电视剧过滤掉已经下载的集数
|
||||
if torrent_mediainfo.type == MediaType.TV:
|
||||
if self.__check_subscribe_note(subscribe, torrent_meta.episode_list):
|
||||
logger.info(f'{torrent_info.title} 对应剧集 {torrent_meta.episodes} 已下载过')
|
||||
logger.info(f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 已下载过')
|
||||
continue
|
||||
else:
|
||||
# 洗版时,非整季不要
|
||||
@@ -283,12 +268,17 @@ class SubscribeChain(ChainBase):
|
||||
if torrent_meta.episode_list:
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
# 优先级小于已下载优先级的不要
|
||||
if subscribe.current_priority \
|
||||
and torrent_info.pri_order < subscribe.current_priority:
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于已下载优先级')
|
||||
continue
|
||||
matched_contexts.append(context)
|
||||
if not matched_contexts:
|
||||
logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源')
|
||||
# 非洗版未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
||||
self.__upate_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
|
||||
continue
|
||||
# 自动下载
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=matched_contexts,
|
||||
@@ -305,18 +295,18 @@ class SubscribeChain(ChainBase):
|
||||
mediainfo=mediainfo, downloads=downloads)
|
||||
else:
|
||||
# 未完成下载
|
||||
logger.info(f'{mediainfo.title_year} 未下载未完整,继续订阅 ...')
|
||||
logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...')
|
||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
||||
# 更新订阅剩余集数和时间
|
||||
update_date = True if downloads else False
|
||||
self.__upate_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
# 手动触发时发送系统消息
|
||||
if manual:
|
||||
if sid:
|
||||
self.message.put(f'订阅 {subscribes[0].name} 搜索完成!')
|
||||
else:
|
||||
self.message.put(f'所有订阅搜索完成!')
|
||||
self.message.put('所有订阅搜索完成!')
|
||||
|
||||
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaInfo,
|
||||
mediainfo: MediaInfo, downloads: List[Context]):
|
||||
@@ -326,97 +316,64 @@ class SubscribeChain(ChainBase):
|
||||
if not subscribe.best_version:
|
||||
# 全部下载完成
|
||||
logger.info(f'{mediainfo.title_year} 下载完成,完成订阅')
|
||||
self.subscribehelper.delete(subscribe.id)
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year}{meta.season} 已完成订阅',
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
|
||||
image=mediainfo.get_message_image()))
|
||||
else:
|
||||
# 当前下载资源的优先级
|
||||
priority = max([item.torrent_info.pri_order for item in downloads])
|
||||
if priority == 100:
|
||||
logger.info(f'{mediainfo.title_year} 洗版完成,删除订阅')
|
||||
self.subscribehelper.delete(subscribe.id)
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year}{meta.season} 已洗版完成',
|
||||
title=f'{mediainfo.title_year} {meta.season} 已洗版完成',
|
||||
image=mediainfo.get_message_image()))
|
||||
else:
|
||||
# 正在洗版,更新资源优先级
|
||||
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级')
|
||||
self.subscribehelper.update(subscribe.id, {
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"current_priority": priority
|
||||
})
|
||||
|
||||
def refresh(self):
|
||||
"""
|
||||
刷新站点最新资源
|
||||
订阅刷新
|
||||
"""
|
||||
# 所有订阅
|
||||
subscribes = self.subscribehelper.list('R')
|
||||
if not subscribes:
|
||||
# 没有订阅不运行
|
||||
# 触发刷新站点资源,从缓存中匹配订阅
|
||||
sites = self.get_subscribed_sites()
|
||||
if sites is None:
|
||||
return
|
||||
# 读取缓存
|
||||
torrents_cache: Dict[str, List[Context]] = self.load_cache(self._cache_file) or {}
|
||||
self.match(
|
||||
self.torrentschain.refresh(sites=sites)
|
||||
)
|
||||
|
||||
# 所有站点索引
|
||||
indexers = self.siteshelper.get_indexers()
|
||||
# 配置的索引站点
|
||||
config_indexers = [str(sid) for sid in self.systemconfig.get(SystemConfigKey.IndexerSites) or []]
|
||||
# 遍历站点缓存资源
|
||||
for indexer in indexers:
|
||||
# 未开启的站点不搜索
|
||||
if config_indexers and str(indexer.get("id")) not in config_indexers:
|
||||
continue
|
||||
logger.info(f'开始刷新 {indexer.get("name")} 最新种子 ...')
|
||||
domain = StringUtils.get_url_domain(indexer.get("domain"))
|
||||
torrents: List[TorrentInfo] = self.refresh_torrents(site=indexer)
|
||||
# 按pubdate降序排列
|
||||
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
|
||||
# 取前N条
|
||||
torrents = torrents[:settings.CACHE_CONF.get('refresh')]
|
||||
if torrents:
|
||||
# 过滤出没有处理过的种子
|
||||
torrents = [torrent for torrent in torrents
|
||||
if f'{torrent.title}{torrent.description}'
|
||||
not in [f'{t.torrent_info.title}{t.torrent_info.description}'
|
||||
for t in torrents_cache.get(domain) or []]]
|
||||
if torrents:
|
||||
logger.info(f'{indexer.get("name")} 有 {len(torrents)} 个新种子')
|
||||
else:
|
||||
logger.info(f'{indexer.get("name")} 没有新种子')
|
||||
continue
|
||||
for torrent in torrents:
|
||||
logger.info(f'处理资源:{torrent.title} ...')
|
||||
# 识别
|
||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{torrent.title}')
|
||||
# 存储空的媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
# 清理多余数据
|
||||
mediainfo.clear()
|
||||
# 上下文
|
||||
context = Context(meta_info=meta, media_info=mediainfo, torrent_info=torrent)
|
||||
# 添加到缓存
|
||||
if not torrents_cache.get(domain):
|
||||
torrents_cache[domain] = [context]
|
||||
else:
|
||||
torrents_cache[domain].append(context)
|
||||
# 如果超过了限制条数则移除掉前面的
|
||||
if len(torrents_cache[domain]) > settings.CACHE_CONF.get('torrents'):
|
||||
torrents_cache[domain] = torrents_cache[domain][-settings.CACHE_CONF.get('torrents'):]
|
||||
# 回收资源
|
||||
del torrents
|
||||
else:
|
||||
logger.info(f'{indexer.get("name")} 获取到种子')
|
||||
# 从缓存中匹配订阅
|
||||
self.match(torrents_cache)
|
||||
# 保存缓存到本地
|
||||
self.save_cache(torrents_cache, self._cache_file)
|
||||
def get_subscribed_sites(self) -> Optional[List[int]]:
|
||||
"""
|
||||
获取订阅中涉及的所有站点清单(节约资源)
|
||||
:return: 返回[]代表所有站点命中,返回None代表没有订阅
|
||||
"""
|
||||
# 查询所有订阅
|
||||
subscribes = self.subscribeoper.list('R')
|
||||
if not subscribes:
|
||||
return None
|
||||
ret_sites = []
|
||||
# 刷新订阅选中的Rss站点
|
||||
for subscribe in subscribes:
|
||||
# 如果有一个订阅没有选择站点,则刷新所有订阅站点
|
||||
if not subscribe.sites:
|
||||
return []
|
||||
# 刷新选中的站点
|
||||
sub_sites = json.loads(subscribe.sites)
|
||||
if sub_sites:
|
||||
ret_sites.extend(sub_sites)
|
||||
# 去重
|
||||
if ret_sites:
|
||||
ret_sites = list(set(ret_sites))
|
||||
|
||||
return ret_sites
|
||||
|
||||
def match(self, torrents: Dict[str, List[Context]]):
|
||||
"""
|
||||
@@ -426,7 +383,7 @@ class SubscribeChain(ChainBase):
|
||||
logger.warn('没有缓存资源,无法匹配订阅')
|
||||
return
|
||||
# 所有订阅
|
||||
subscribes = self.subscribehelper.list('R')
|
||||
subscribes = self.subscribeoper.list('R')
|
||||
# 遍历订阅
|
||||
for subscribe in subscribes:
|
||||
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
|
||||
@@ -442,14 +399,24 @@ class SubscribeChain(ChainBase):
|
||||
continue
|
||||
# 非洗版
|
||||
if not subscribe.best_version:
|
||||
# 每季总集数
|
||||
totals = {}
|
||||
if subscribe.season and subscribe.total_episode:
|
||||
totals = {
|
||||
subscribe.season: subscribe.total_episode
|
||||
}
|
||||
# 查询缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo)
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
totals=totals
|
||||
)
|
||||
if exist_flag:
|
||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在,完成订阅')
|
||||
self.subscribehelper.delete(subscribe.id)
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year}{meta.season} 已完成订阅',
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
|
||||
image=mediainfo.get_message_image()))
|
||||
continue
|
||||
# 电视剧订阅
|
||||
@@ -467,7 +434,7 @@ class SubscribeChain(ChainBase):
|
||||
if no_exists and no_exists.get(subscribe.tmdbid):
|
||||
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
|
||||
if no_exists_info:
|
||||
logger.info(f'订阅 {mediainfo.title_year}{meta.season} 缺失集:{no_exists_info.episodes}')
|
||||
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
||||
else:
|
||||
# 洗版
|
||||
if meta.type == MediaType.TV:
|
||||
@@ -480,6 +447,10 @@ class SubscribeChain(ChainBase):
|
||||
}
|
||||
else:
|
||||
no_exists = {}
|
||||
# 默认过滤规则
|
||||
default_filter = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
||||
include = subscribe.include or default_filter.get("include")
|
||||
exclude = subscribe.exclude or default_filter.get("exclude")
|
||||
# 遍历缓存种子
|
||||
_match_context = []
|
||||
for domain, contexts in torrents.items():
|
||||
@@ -492,21 +463,24 @@ class SubscribeChain(ChainBase):
|
||||
if torrent_mediainfo.tmdb_id != mediainfo.tmdb_id \
|
||||
or torrent_mediainfo.type != mediainfo.type:
|
||||
continue
|
||||
# 过滤规则
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules2)
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
||||
else:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
|
||||
result: List[TorrentInfo] = self.filter_torrents(
|
||||
rule_string=filter_rule,
|
||||
torrent_list=[torrent_info])
|
||||
torrent_list=[torrent_info],
|
||||
mediainfo=torrent_mediainfo)
|
||||
if result is not None and not result:
|
||||
# 不符合过滤规则
|
||||
logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
|
||||
continue
|
||||
# 不在订阅站点范围的不处理
|
||||
if subscribe.sites:
|
||||
sub_sites = json.loads(subscribe.sites)
|
||||
if sub_sites and torrent_info.site not in sub_sites:
|
||||
logger.info(f"{torrent_info.title} 不符合 {torrent_mediainfo.title_year} 订阅站点要求")
|
||||
continue
|
||||
# 如果是电视剧
|
||||
if torrent_mediainfo.type == MediaType.TV:
|
||||
@@ -536,11 +510,11 @@ class SubscribeChain(ChainBase):
|
||||
set(torrent_meta.episode_list)
|
||||
):
|
||||
logger.info(
|
||||
f'{torrent_info.title} 对应剧集 {torrent_meta.episodes} 未包含缺失的剧集')
|
||||
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集')
|
||||
continue
|
||||
# 过滤掉已经下载的集数
|
||||
if self.__check_subscribe_note(subscribe, torrent_meta.episode_list):
|
||||
logger.info(f'{torrent_info.title} 对应剧集 {torrent_meta.episodes} 已下载过')
|
||||
logger.info(f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 已下载过')
|
||||
continue
|
||||
else:
|
||||
# 洗版时,非整季不要
|
||||
@@ -549,14 +523,16 @@ class SubscribeChain(ChainBase):
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
# 包含
|
||||
if subscribe.include:
|
||||
if not re.search(r"%s" % subscribe.include,
|
||||
if include:
|
||||
if not re.search(r"%s" % include,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
|
||||
continue
|
||||
# 排除
|
||||
if subscribe.exclude:
|
||||
if re.search(r"%s" % subscribe.exclude,
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
|
||||
continue
|
||||
# 匹配成功
|
||||
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
|
||||
@@ -578,12 +554,59 @@ class SubscribeChain(ChainBase):
|
||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
||||
update_date = True if downloads else False
|
||||
# 未完成下载,计算剩余集数
|
||||
self.__upate_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
else:
|
||||
if meta.type == MediaType.TV:
|
||||
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
||||
self.__upate_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
|
||||
|
||||
def check(self):
|
||||
"""
|
||||
定时检查订阅,更新订阅信息
|
||||
"""
|
||||
# 查询所有订阅
|
||||
subscribes = self.subscribeoper.list()
|
||||
if not subscribes:
|
||||
# 没有订阅不运行
|
||||
return
|
||||
# 遍历订阅
|
||||
for subscribe in subscribes:
|
||||
logger.info(f'开始检查订阅:{subscribe.name} ...')
|
||||
# 生成元数据
|
||||
meta = MetaInfo(subscribe.name)
|
||||
meta.year = subscribe.year
|
||||
meta.begin_season = subscribe.season or None
|
||||
meta.type = MediaType(subscribe.type)
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, tmdbid=subscribe.tmdbid)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid}')
|
||||
continue
|
||||
# 对于电视剧,获取当前季的总集数
|
||||
episodes = mediainfo.seasons.get(subscribe.season) or []
|
||||
if len(episodes) > (subscribe.total_episode or 0):
|
||||
total_episode = len(episodes)
|
||||
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
|
||||
logger.info(
|
||||
f'订阅 {subscribe.name} 总集数变化,更新总集数为{total_episode},缺失集数为{lack_episode} ...')
|
||||
else:
|
||||
total_episode = subscribe.total_episode
|
||||
lack_episode = subscribe.lack_episode
|
||||
# 更新TMDB信息
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"name": mediainfo.title,
|
||||
"year": mediainfo.year,
|
||||
"vote": mediainfo.vote_average,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"backdrop": mediainfo.get_backdrop_image(),
|
||||
"description": mediainfo.overview,
|
||||
"imdbid": mediainfo.imdb_id,
|
||||
"tvdbid": mediainfo.tvdb_id,
|
||||
"total_episode": total_episode,
|
||||
"lack_episode": lack_episode
|
||||
})
|
||||
logger.info(f'订阅 {subscribe.name} 更新完成')
|
||||
|
||||
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
|
||||
"""
|
||||
@@ -608,7 +631,7 @@ class SubscribeChain(ChainBase):
|
||||
# 合并已下载集
|
||||
note = list(set(note).union(set(episodes)))
|
||||
# 更新订阅
|
||||
self.subscribehelper.update(subscribe.id, {
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"note": json.dumps(note)
|
||||
})
|
||||
|
||||
@@ -626,10 +649,10 @@ class SubscribeChain(ChainBase):
|
||||
return True
|
||||
return False
|
||||
|
||||
def __upate_lack_episodes(self, lefts: Dict[int, Dict[int, NotExistMediaInfo]],
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MediaInfo,
|
||||
update_date: bool = False):
|
||||
def __update_lack_episodes(self, lefts: Dict[int, Dict[int, NotExistMediaInfo]],
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MediaInfo,
|
||||
update_date: bool = False):
|
||||
"""
|
||||
更新订阅剩余集数
|
||||
"""
|
||||
@@ -638,23 +661,27 @@ class SubscribeChain(ChainBase):
|
||||
season = season_info.season
|
||||
if season == subscribe.season:
|
||||
left_episodes = season_info.episodes
|
||||
logger.info(f'{mediainfo.title_year} 季 {season} 更新缺失集数为{len(left_episodes)} ...')
|
||||
if not left_episodes:
|
||||
lack_episode = season_info.total_episode
|
||||
else:
|
||||
lack_episode = len(left_episodes)
|
||||
logger.info(f'{mediainfo.title_year} 季 {season} 更新缺失集数为{lack_episode} ...')
|
||||
if update_date:
|
||||
# 同时更新最后时间
|
||||
self.subscribehelper.update(subscribe.id, {
|
||||
"lack_episode": len(left_episodes),
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"lack_episode": lack_episode,
|
||||
"last_update": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
else:
|
||||
self.subscribehelper.update(subscribe.id, {
|
||||
"lack_episode": len(left_episodes)
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"lack_episode": lack_episode
|
||||
})
|
||||
|
||||
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
查询订阅并发送消息
|
||||
"""
|
||||
subscribes = self.subscribehelper.list()
|
||||
subscribes = self.subscribeoper.list()
|
||||
if not subscribes:
|
||||
self.post_message(Notification(channel=channel,
|
||||
title='没有任何订阅!', userid=userid))
|
||||
@@ -693,13 +720,13 @@ class SubscribeChain(ChainBase):
|
||||
if not arg_str.isdigit():
|
||||
continue
|
||||
subscribe_id = int(arg_str)
|
||||
subscribe = self.subscribehelper.get(subscribe_id)
|
||||
subscribe = self.subscribeoper.get(subscribe_id)
|
||||
if not subscribe:
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"订阅编号 {subscribe_id} 不存在!", userid=userid))
|
||||
return
|
||||
# 删除订阅
|
||||
self.subscribehelper.delete(subscribe_id)
|
||||
self.subscribeoper.delete(subscribe_id)
|
||||
# 重新发送消息
|
||||
self.remote_list(channel, userid)
|
||||
|
||||
@@ -710,40 +737,50 @@ class SubscribeChain(ChainBase):
|
||||
total_episode: int,
|
||||
start_episode: int):
|
||||
"""
|
||||
根据订阅开始集数和总结数,结合TMDB信息计算当前订阅的缺失集数
|
||||
根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数
|
||||
:param no_exists: 缺失季集列表
|
||||
:param tmdb_id: TMDB ID
|
||||
:param begin_season: 开始季
|
||||
:param total_episode: 总集数
|
||||
:param start_episode: 开始集数
|
||||
:param total_episode: 订阅设定总集数
|
||||
:param start_episode: 订阅设定开始集数
|
||||
"""
|
||||
# 使用订阅的总集数和开始集数替换no_exists
|
||||
if no_exists \
|
||||
and no_exists.get(tmdb_id) \
|
||||
and (total_episode or start_episode):
|
||||
# 该季原缺失信息
|
||||
no_exist_season = no_exists.get(tmdb_id).get(begin_season)
|
||||
if no_exist_season:
|
||||
# 原季集列表
|
||||
# 原集列表
|
||||
episode_list = no_exist_season.episodes
|
||||
# 原总集数
|
||||
total = no_exist_season.total_episode
|
||||
if total_episode and start_episode:
|
||||
# 有开始集和总集数
|
||||
episodes = list(range(start_episode, total_episode + 1))
|
||||
elif not start_episode:
|
||||
# 有总集数没有开始集
|
||||
episodes = list(range(min(episode_list or [1]), total_episode + 1))
|
||||
start_episode = min(episode_list or [1])
|
||||
elif not total_episode:
|
||||
# 有开始集没有总集数
|
||||
episodes = list(range(start_episode, max(episode_list or [total]) + 1))
|
||||
total_episode = max(episode_list or [total])
|
||||
# 原开始集数
|
||||
start = no_exist_season.start_episode
|
||||
|
||||
# 更新剧集列表、开始集数、总集数
|
||||
if not episode_list:
|
||||
# 整季缺失
|
||||
episodes = []
|
||||
start_episode = start_episode or start
|
||||
total_episode = total_episode or total
|
||||
else:
|
||||
return no_exists
|
||||
# 与原有集取交集
|
||||
if episode_list:
|
||||
episodes = list(set(episodes).intersection(set(episode_list)))
|
||||
# 处理集合
|
||||
# 部分缺失
|
||||
if not start_episode \
|
||||
and not total_episode:
|
||||
# 无需调整
|
||||
return no_exists
|
||||
if not start_episode:
|
||||
# 没有自定义开始集
|
||||
start_episode = start
|
||||
if not total_episode:
|
||||
# 没有自定义总集数
|
||||
total_episode = total
|
||||
# 新的集列表
|
||||
new_episodes = list(range(max(start_episode, start), total_episode + 1))
|
||||
# 与原集列表取交集
|
||||
episodes = list(set(episode_list).intersection(set(new_episodes)))
|
||||
# 更新集合
|
||||
no_exists[tmdb_id][begin_season] = NotExistMediaInfo(
|
||||
season=begin_season,
|
||||
episodes=episodes,
|
||||
|
||||
17
app/chain/system.py
Normal file
17
app/chain/system.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from typing import Union
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.schemas import Notification, MessageChannel
|
||||
|
||||
|
||||
class SystemChain(ChainBase):
|
||||
"""
|
||||
系统级处理链
|
||||
"""
|
||||
def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
清理系统缓存
|
||||
"""
|
||||
self.clear_cache()
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"缓存清理完成!", userid=userid))
|
||||
@@ -1,11 +1,16 @@
|
||||
import random
|
||||
from typing import Optional, List
|
||||
|
||||
from cachetools import cached, TTLCache
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class TmdbChain(ChainBase):
|
||||
class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
TheMovieDB处理链
|
||||
"""
|
||||
@@ -106,3 +111,17 @@ class TmdbChain(ChainBase):
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("person_credits", person_id=person_id, page=page)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
||||
def get_random_wallpager(self):
|
||||
"""
|
||||
获取随机壁纸,缓存1个小时
|
||||
"""
|
||||
infos = self.tmdb_trending()
|
||||
if infos:
|
||||
# 随机一个电影
|
||||
while True:
|
||||
info = random.choice(infos)
|
||||
if info and info.get("backdrop_path"):
|
||||
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.get('backdrop_path')}"
|
||||
return None
|
||||
|
||||
234
app/chain/torrents.py
Normal file
234
app/chain/torrents.py
Normal file
@@ -0,0 +1,234 @@
|
||||
import re
|
||||
from typing import Dict, List, Union
|
||||
|
||||
from cachetools import cached, TTLCache
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.context import TorrentInfo, Context, MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db import SessionFactory
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
站点首页或RSS种子处理链,服务于订阅、刷流等
|
||||
"""
|
||||
|
||||
_spider_file = "__torrents_cache__"
|
||||
_rss_file = "__rss_cache__"
|
||||
|
||||
def __init__(self):
|
||||
self._db = SessionFactory()
|
||||
super().__init__(self._db)
|
||||
self.siteshelper = SitesHelper()
|
||||
self.siteoper = SiteOper(self._db)
|
||||
self.rsshelper = RssHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
远程刷新订阅,发送消息
|
||||
"""
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"开始刷新种子 ...", userid=userid))
|
||||
self.refresh()
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"种子刷新完成!", userid=userid))
|
||||
|
||||
def get_torrents(self, stype: str = None) -> Dict[str, List[Context]]:
|
||||
"""
|
||||
获取当前缓存的种子
|
||||
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
|
||||
"""
|
||||
|
||||
if not stype:
|
||||
stype = settings.SUBSCRIBE_MODE
|
||||
|
||||
# 读取缓存
|
||||
if stype == 'spider':
|
||||
return self.load_cache(self._spider_file) or {}
|
||||
else:
|
||||
return self.load_cache(self._rss_file) or {}
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128 if settings.BIG_MEMORY_MODE else 1, ttl=600))
|
||||
def browse(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
浏览站点首页内容,返回种子清单,TTL缓存10分钟
|
||||
:param domain: 站点域名
|
||||
"""
|
||||
logger.info(f'开始获取站点 {domain} 最新种子 ...')
|
||||
site = self.siteshelper.get_indexer(domain)
|
||||
if not site:
|
||||
logger.error(f'站点 {domain} 不存在!')
|
||||
return []
|
||||
return self.refresh_torrents(site=site)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128 if settings.BIG_MEMORY_MODE else 1, ttl=300))
|
||||
def rss(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
获取站点RSS内容,返回种子清单,TTL缓存5分钟
|
||||
:param domain: 站点域名
|
||||
"""
|
||||
logger.info(f'开始获取站点 {domain} RSS ...')
|
||||
site = self.siteshelper.get_indexer(domain)
|
||||
if not site:
|
||||
logger.error(f'站点 {domain} 不存在!')
|
||||
return []
|
||||
if not site.get("rss"):
|
||||
logger.error(f'站点 {domain} 未配置RSS地址!')
|
||||
return []
|
||||
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False)
|
||||
if rss_items is None:
|
||||
# rss过期,尝试保留原配置生成新的rss
|
||||
self.__renew_rss_url(domain=domain, site=site)
|
||||
return []
|
||||
if not rss_items:
|
||||
logger.error(f'站点 {domain} 未获取到RSS数据!')
|
||||
return []
|
||||
# 组装种子
|
||||
ret_torrents: List[TorrentInfo] = []
|
||||
for item in rss_items:
|
||||
if not item.get("title"):
|
||||
continue
|
||||
torrentinfo = TorrentInfo(
|
||||
site=site.get("id"),
|
||||
site_name=site.get("name"),
|
||||
site_cookie=site.get("cookie"),
|
||||
site_ua=site.get("ua") or settings.USER_AGENT,
|
||||
site_proxy=site.get("proxy"),
|
||||
site_order=site.get("pri"),
|
||||
title=item.get("title"),
|
||||
enclosure=item.get("enclosure"),
|
||||
page_url=item.get("link"),
|
||||
size=item.get("size"),
|
||||
pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None,
|
||||
)
|
||||
ret_torrents.append(torrentinfo)
|
||||
|
||||
return ret_torrents
|
||||
|
||||
def refresh(self, stype: str = None, sites: List[int] = None) -> Dict[str, List[Context]]:
|
||||
"""
|
||||
刷新站点最新资源,识别并缓存起来
|
||||
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
|
||||
:param sites: 强制指定站点ID列表,为空则读取设置的订阅站点
|
||||
"""
|
||||
# 刷新类型
|
||||
if not stype:
|
||||
stype = settings.SUBSCRIBE_MODE
|
||||
|
||||
# 刷新站点
|
||||
if not sites:
|
||||
sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
|
||||
|
||||
# 读取缓存
|
||||
torrents_cache = self.get_torrents()
|
||||
|
||||
# 所有站点索引
|
||||
indexers = self.siteshelper.get_indexers()
|
||||
# 遍历站点缓存资源
|
||||
for indexer in indexers:
|
||||
# 未开启的站点不刷新
|
||||
if sites and indexer.get("id") not in sites:
|
||||
continue
|
||||
domain = StringUtils.get_url_domain(indexer.get("domain"))
|
||||
if stype == "spider":
|
||||
# 刷新首页种子
|
||||
torrents: List[TorrentInfo] = self.browse(domain=domain)
|
||||
else:
|
||||
# 刷新RSS种子
|
||||
torrents: List[TorrentInfo] = self.rss(domain=domain)
|
||||
# 按pubdate降序排列
|
||||
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
|
||||
# 取前N条
|
||||
torrents = torrents[:settings.CACHE_CONF.get('refresh')]
|
||||
if torrents:
|
||||
# 过滤出没有处理过的种子
|
||||
torrents = [torrent for torrent in torrents
|
||||
if f'{torrent.title}{torrent.description}'
|
||||
not in [f'{t.torrent_info.title}{t.torrent_info.description}'
|
||||
for t in torrents_cache.get(domain) or []]]
|
||||
if torrents:
|
||||
logger.info(f'{indexer.get("name")} 有 {len(torrents)} 个新种子')
|
||||
else:
|
||||
logger.info(f'{indexer.get("name")} 没有新种子')
|
||||
continue
|
||||
for torrent in torrents:
|
||||
logger.info(f'处理资源:{torrent.title} ...')
|
||||
# 识别
|
||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{torrent.title}')
|
||||
# 存储空的媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
# 清理多余数据
|
||||
mediainfo.clear()
|
||||
# 上下文
|
||||
context = Context(meta_info=meta, media_info=mediainfo, torrent_info=torrent)
|
||||
# 添加到缓存
|
||||
if not torrents_cache.get(domain):
|
||||
torrents_cache[domain] = [context]
|
||||
else:
|
||||
torrents_cache[domain].append(context)
|
||||
# 如果超过了限制条数则移除掉前面的
|
||||
if len(torrents_cache[domain]) > settings.CACHE_CONF.get('torrents'):
|
||||
torrents_cache[domain] = torrents_cache[domain][-settings.CACHE_CONF.get('torrents'):]
|
||||
# 回收资源
|
||||
del torrents
|
||||
else:
|
||||
logger.info(f'{indexer.get("name")} 没有获取到种子')
|
||||
|
||||
# 保存缓存到本地
|
||||
if stype == "spider":
|
||||
self.save_cache(torrents_cache, self._spider_file)
|
||||
else:
|
||||
self.save_cache(torrents_cache, self._rss_file)
|
||||
|
||||
# 返回
|
||||
return torrents_cache
|
||||
|
||||
def __renew_rss_url(self, domain: str, site: dict):
|
||||
"""
|
||||
保留原配置生成新的rss地址
|
||||
"""
|
||||
try:
|
||||
# RSS链接过期
|
||||
logger.error(f"站点 {domain} RSS链接已过期,正在尝试自动获取!")
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(
|
||||
url=site.get("url"),
|
||||
cookie=site.get("cookie"),
|
||||
ua=site.get("ua") or settings.USER_AGENT,
|
||||
proxy=True if site.get("proxy") else False
|
||||
)
|
||||
if rss_url:
|
||||
# 获取新的日期的passkey
|
||||
match = re.search(r'passkey=([a-zA-Z0-9]+)', rss_url)
|
||||
if match:
|
||||
new_passkey = match.group(1)
|
||||
# 获取过期rss除去passkey部分
|
||||
new_rss = re.sub(r'&passkey=([a-zA-Z0-9]+)', f'&passkey={new_passkey}', site.get("rss"))
|
||||
logger.info(f"更新站点 {domain} RSS地址 ...")
|
||||
self.siteoper.update_rss(domain=domain, rss=new_rss)
|
||||
else:
|
||||
# 发送消息
|
||||
self.post_message(
|
||||
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
||||
else:
|
||||
self.post_message(
|
||||
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
||||
@@ -1,22 +1,30 @@
|
||||
import json
|
||||
import glob
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
from typing import List, Optional, Tuple, Union, Dict
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.helper.format import FormatParser
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, Notification
|
||||
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, MessageChannel, NotificationType
|
||||
from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat
|
||||
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \
|
||||
SystemConfigKey
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
@@ -28,212 +36,565 @@ class TransferChain(ChainBase):
|
||||
文件转移处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.transferhis = TransferHistoryOper()
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
self.downloadhis = DownloadHistoryOper(self._db)
|
||||
self.transferhis = TransferHistoryOper(self._db)
|
||||
self.progress = ProgressHelper()
|
||||
self.mediachain = MediaChain(self._db)
|
||||
self.tmdbchain = TmdbChain(self._db)
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
def process(self, arg_str: str = None, channel: MessageChannel = None, userid: Union[str, int] = None) -> bool:
|
||||
def process(self) -> bool:
|
||||
"""
|
||||
获取下载器中的种子列表,并执行转移
|
||||
:param arg_str: 传入的参数 (种子hash和TMDBID|类型)
|
||||
:param channel: 消息通道
|
||||
:param userid: 用户ID
|
||||
"""
|
||||
|
||||
def extract_hash_and_number(string: str):
|
||||
"""
|
||||
从字符串中提取种子hash和编号
|
||||
"""
|
||||
pattern = r'([a-fA-F0-9]+) (\d+)'
|
||||
match = re.search(pattern, string)
|
||||
if match:
|
||||
hash_value = match.group(1)
|
||||
number = match.group(2)
|
||||
return hash_value, int(number)
|
||||
else:
|
||||
return None, None
|
||||
|
||||
# 全局锁,避免重复处理
|
||||
with lock:
|
||||
if arg_str:
|
||||
logger.info(f"开始转移下载器文件,参数:{arg_str}")
|
||||
# 解析中附带的类型
|
||||
args = arg_str.split('|')
|
||||
if len(args) > 1:
|
||||
mtype = MediaType(args[-1])
|
||||
arg_str = args[0]
|
||||
else:
|
||||
mtype = None
|
||||
# 解析中种子hash,TMDB ID
|
||||
torrent_hash, tmdbid = extract_hash_and_number(arg_str)
|
||||
if not hash or not tmdbid:
|
||||
logger.error(f"参数错误,参数:{arg_str}")
|
||||
return False
|
||||
# 获取种子
|
||||
torrents: Optional[List[TransferTorrent]] = self.list_torrents(hashs=torrent_hash)
|
||||
if not torrents:
|
||||
logger.error(f"没有获取到种子,参数:{torrent_hash}")
|
||||
return False
|
||||
# 查询媒体信息
|
||||
arg_mediainfo = self.recognize_media(mtype=mtype, tmdbid=tmdbid)
|
||||
else:
|
||||
arg_mediainfo = None
|
||||
logger.info("开始执行下载器文件转移 ...")
|
||||
# 从下载器获取种子列表
|
||||
torrents: Optional[List[TransferTorrent]] = self.list_torrents(status=TorrentStatus.TRANSFER)
|
||||
if not torrents:
|
||||
logger.info("没有获取到已完成的下载任务")
|
||||
return False
|
||||
logger.info("开始执行下载器文件转移 ...")
|
||||
# 从下载器获取种子列表
|
||||
torrents: Optional[List[TransferTorrent]] = self.list_torrents(status=TorrentStatus.TRANSFER)
|
||||
if not torrents:
|
||||
logger.info("没有获取到已完成的下载任务")
|
||||
return False
|
||||
|
||||
logger.info(f"获取到 {len(torrents)} 个已完成的下载任务")
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.FileTransfer)
|
||||
# 总数
|
||||
total_num = len(torrents)
|
||||
# 已处理数量
|
||||
processed_num = 0
|
||||
self.progress.update(value=0,
|
||||
text=f"开始转移下载任务文件,共 {total_num} 个任务 ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
for torrent in torrents:
|
||||
# 更新进度
|
||||
self.progress.update(value=processed_num / total_num * 100,
|
||||
text=f"正在转移 {torrent.title} ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
# 识别元数据
|
||||
meta: MetaBase = MetaInfo(title=torrent.title)
|
||||
if not meta.name:
|
||||
logger.error(f'未识别到元数据,标题:{torrent.title}')
|
||||
continue
|
||||
if not arg_mediainfo:
|
||||
# 查询下载记录识别情况
|
||||
downloadhis: DownloadHistory = self.downloadhis.get_by_hash(torrent.hash)
|
||||
if downloadhis:
|
||||
# 类型
|
||||
mtype = MediaType(downloadhis.type)
|
||||
# 补充剧集信息
|
||||
if mtype == MediaType.TV \
|
||||
and ((not meta.season_list and downloadhis.seasons)
|
||||
or (not meta.episode_list and downloadhis.episodes)):
|
||||
meta = MetaInfo(f"{torrent.title} {downloadhis.seasons} {downloadhis.episodes}")
|
||||
# 按TMDBID识别
|
||||
mediainfo = self.recognize_media(mtype=mtype,
|
||||
tmdbid=downloadhis.tmdbid)
|
||||
else:
|
||||
# 使用标题识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{torrent.title}')
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{torrent.title} 未识别到媒体信息,无法入库!\n"
|
||||
f"回复:```\n/transfer {torrent.hash} [tmdbid]|[类型]\n``` 手动识别转移。",
|
||||
userid=userid))
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add(
|
||||
src=str(torrent.path),
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
download_hash=torrent.hash,
|
||||
status=0,
|
||||
errmsg="未识别到媒体信息"
|
||||
)
|
||||
continue
|
||||
# 查询下载记录识别情况
|
||||
downloadhis: DownloadHistory = self.downloadhis.get_by_hash(torrent.hash)
|
||||
if downloadhis:
|
||||
# 类型
|
||||
mtype = MediaType(downloadhis.type)
|
||||
# 按TMDBID识别
|
||||
mediainfo = self.recognize_media(mtype=mtype,
|
||||
tmdbid=downloadhis.tmdbid)
|
||||
else:
|
||||
mediainfo = arg_mediainfo
|
||||
logger.info(f"{torrent.title} 识别为:{mediainfo.type.value} {mediainfo.title_year}")
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
# 转移
|
||||
transferinfo: TransferInfo = self.transfer(mediainfo=mediainfo,
|
||||
path=torrent.path,
|
||||
transfer_type=settings.TRANSFER_TYPE)
|
||||
if not transferinfo or not transferinfo.target_path:
|
||||
# 转移失败
|
||||
logger.warn(f"{torrent.title} 入库失败")
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
title=f"{mediainfo.title_year}{meta.season_episode} 入库失败!",
|
||||
text=f"原因:{transferinfo.message if transferinfo else '未知'}",
|
||||
image=mediainfo.get_message_image(),
|
||||
userid=userid
|
||||
))
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add(
|
||||
src=str(torrent.path),
|
||||
dest=str(transferinfo.target_path) if transferinfo else None,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
type=mediainfo.type.value,
|
||||
category=mediainfo.category,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
tvdbid=mediainfo.tvdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
image=mediainfo.get_poster_image(),
|
||||
download_hash=torrent.hash,
|
||||
status=0,
|
||||
errmsg=transferinfo.message if transferinfo else '未知错误',
|
||||
files=json.dumps(transferinfo.file_list) if transferinfo else None
|
||||
)
|
||||
continue
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add(
|
||||
src=str(torrent.path),
|
||||
dest=str(transferinfo.target_path) if transferinfo else None,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
type=mediainfo.type.value,
|
||||
category=mediainfo.category,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
tvdbid=mediainfo.tvdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
image=mediainfo.get_poster_image(),
|
||||
download_hash=torrent.hash,
|
||||
status=1,
|
||||
files=json.dumps(transferinfo.file_list)
|
||||
)
|
||||
# 转移完成
|
||||
self.transfer_completed(hashs=torrent.hash, transinfo=transferinfo)
|
||||
# 刮削元数据
|
||||
self.scrape_metadata(path=transferinfo.target_path, mediainfo=mediainfo)
|
||||
# 刷新媒体库
|
||||
self.refresh_mediaserver(mediainfo=mediainfo, file_path=transferinfo.target_path)
|
||||
# 发送通知
|
||||
self.send_transfer_message(meta=meta, mediainfo=mediainfo, transferinfo=transferinfo)
|
||||
# 广播事件
|
||||
self.eventmanager.send_event(EventType.TransferComplete, {
|
||||
'meta': meta,
|
||||
'mediainfo': mediainfo,
|
||||
'transferinfo': transferinfo
|
||||
})
|
||||
# 计数
|
||||
processed_num += 1
|
||||
# 更新进度
|
||||
self.progress.update(value=processed_num / total_num * 100,
|
||||
text=f"{torrent.title} 转移完成",
|
||||
key=ProgressKey.FileTransfer)
|
||||
# 结束进度
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
# 非MoviePilot下载的任务,按文件识别
|
||||
mediainfo = None
|
||||
|
||||
# 执行转移
|
||||
self.do_transfer(path=torrent.path, mediainfo=mediainfo,
|
||||
download_hash=torrent.hash)
|
||||
|
||||
# 设置下载任务状态
|
||||
self.transfer_completed(hashs=torrent.hash, path=torrent.path)
|
||||
# 结束
|
||||
logger.info("下载器文件转移执行完成")
|
||||
return True
|
||||
|
||||
def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo, transferinfo: TransferInfo):
|
||||
def do_transfer(self, path: Path, meta: MetaBase = None,
|
||||
mediainfo: MediaInfo = None, download_hash: str = None,
|
||||
target: Path = None, transfer_type: str = None,
|
||||
season: int = None, epformat: EpisodeFormat = None,
|
||||
min_filesize: int = 0, force: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
执行一个复杂目录的转移操作
|
||||
:param path: 待转移目录或文件
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param download_hash: 下载记录hash
|
||||
:param target: 目标路径
|
||||
:param transfer_type: 转移类型
|
||||
:param season: 季
|
||||
:param epformat: 剧集格式
|
||||
:param min_filesize: 最小文件大小(MB)
|
||||
:param force: 是否强制转移
|
||||
返回:成功标识,错误信息
|
||||
"""
|
||||
if not transfer_type:
|
||||
transfer_type = settings.TRANSFER_TYPE
|
||||
|
||||
# 获取待转移路径清单
|
||||
trans_paths = self.__get_trans_paths(path)
|
||||
if not trans_paths:
|
||||
logger.warn(f"{path.name} 没有找到可转移的媒体文件")
|
||||
return False, f"{path.name} 没有找到可转移的媒体文件"
|
||||
|
||||
# 有集自定义格式
|
||||
formaterHandler = FormatParser(eformat=epformat.format,
|
||||
details=epformat.detail,
|
||||
part=epformat.part,
|
||||
offset=epformat.offset) if epformat else None
|
||||
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.FileTransfer)
|
||||
# 目录所有文件清单
|
||||
transfer_files = SystemUtils.list_files(directory=path,
|
||||
extensions=settings.RMT_MEDIAEXT,
|
||||
min_filesize=min_filesize)
|
||||
if formaterHandler:
|
||||
# 有集自定义格式,过滤文件
|
||||
transfer_files = [f for f in transfer_files if formaterHandler.match(f.name)]
|
||||
|
||||
# 汇总错误信息
|
||||
err_msgs: List[str] = []
|
||||
# 总文件数
|
||||
total_num = len(transfer_files)
|
||||
# 已处理数量
|
||||
processed_num = 0
|
||||
# 失败数量
|
||||
fail_num = 0
|
||||
# 跳过数量
|
||||
skip_num = 0
|
||||
self.progress.update(value=0,
|
||||
text=f"开始转移 {path},共 {total_num} 个文件 ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
# 整理屏蔽词
|
||||
transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
|
||||
|
||||
# 处理所有待转移目录或文件,默认一个转移路径或文件只有一个媒体信息
|
||||
for trans_path in trans_paths:
|
||||
# 汇总季集清单
|
||||
season_episodes: Dict[Tuple, List[int]] = {}
|
||||
# 汇总元数据
|
||||
metas: Dict[Tuple, MetaBase] = {}
|
||||
# 汇总媒体信息
|
||||
medias: Dict[Tuple, MediaInfo] = {}
|
||||
# 汇总转移信息
|
||||
transfers: Dict[Tuple, TransferInfo] = {}
|
||||
|
||||
# 如果是目录且不是⼀蓝光原盘,获取所有文件并转移
|
||||
if (not trans_path.is_file()
|
||||
and not SystemUtils.is_bluray_dir(trans_path)):
|
||||
# 遍历获取下载目录所有文件
|
||||
file_paths = SystemUtils.list_files(directory=trans_path,
|
||||
extensions=settings.RMT_MEDIAEXT,
|
||||
min_filesize=min_filesize)
|
||||
else:
|
||||
file_paths = [trans_path]
|
||||
|
||||
if formaterHandler:
|
||||
# 有集自定义格式,过滤文件
|
||||
file_paths = [f for f in file_paths if formaterHandler.match(f.name)]
|
||||
|
||||
# 转移所有文件
|
||||
for file_path in file_paths:
|
||||
# 回收站及隐藏的文件不处理
|
||||
file_path_str = str(file_path)
|
||||
if file_path_str.find('/@Recycle/') != -1 \
|
||||
or file_path_str.find('/#recycle/') != -1 \
|
||||
or file_path_str.find('/.') != -1 \
|
||||
or file_path_str.find('/@eaDir') != -1:
|
||||
logger.debug(f"{file_path_str} 是回收站或隐藏的文件")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 整理屏蔽词不处理
|
||||
is_blocked = False
|
||||
if transfer_exclude_words:
|
||||
for keyword in transfer_exclude_words:
|
||||
if not keyword:
|
||||
continue
|
||||
if keyword and re.search(r"%s" % keyword, file_path_str, re.IGNORECASE):
|
||||
logger.info(f"{file_path} 命中整理屏蔽词 {keyword},不处理")
|
||||
is_blocked = True
|
||||
break
|
||||
if is_blocked:
|
||||
err_msgs.append(f"{file_path.name} 命中整理屏蔽词")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 转移成功的不再处理
|
||||
if not force:
|
||||
transferd = self.transferhis.get_by_src(file_path_str)
|
||||
if transferd and transferd.status:
|
||||
logger.info(f"{file_path} 已成功转移过,如需重新处理,请删除历史记录。")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 更新进度
|
||||
self.progress.update(value=processed_num / total_num * 100,
|
||||
text=f"正在转移 ({processed_num + 1}/{total_num}){file_path.name} ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
if not meta:
|
||||
# 文件元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
else:
|
||||
file_meta = meta
|
||||
|
||||
# 合并季
|
||||
if season is not None:
|
||||
file_meta.begin_season = season
|
||||
|
||||
if not file_meta:
|
||||
logger.error(f"{file_path} 无法识别有效信息")
|
||||
err_msgs.append(f"{file_path} 无法识别有效信息")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 自定义识别
|
||||
if formaterHandler:
|
||||
# 开始集、结束集、PART
|
||||
begin_ep, end_ep, part = formaterHandler.split_episode(file_path.stem)
|
||||
if begin_ep is not None:
|
||||
file_meta.begin_episode = begin_ep
|
||||
file_meta.part = part
|
||||
if end_ep is not None:
|
||||
file_meta.end_episode = end_ep
|
||||
|
||||
if not mediainfo:
|
||||
# 识别媒体信息
|
||||
file_mediainfo = self.recognize_media(meta=file_meta)
|
||||
else:
|
||||
file_mediainfo = mediainfo
|
||||
|
||||
if not file_mediainfo:
|
||||
logger.warn(f'{file_path} 未识别到媒体信息')
|
||||
# 新增转移失败历史记录
|
||||
his = self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=transfer_type,
|
||||
meta=file_meta,
|
||||
download_hash=download_hash
|
||||
)
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
|
||||
f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
|
||||
))
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||
if not settings.SCRAP_FOLLOW_TMDB:
|
||||
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=file_mediainfo.tmdb_id,
|
||||
mtype=file_mediainfo.type.value)
|
||||
if transfer_history:
|
||||
file_mediainfo.title = transfer_history.title
|
||||
|
||||
logger.info(f"{file_path.name} 识别为:{file_mediainfo.type.value} {file_mediainfo.title_year}")
|
||||
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=file_mediainfo)
|
||||
|
||||
# 获取集数据
|
||||
if file_mediainfo.type == MediaType.TV:
|
||||
episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=file_mediainfo.tmdb_id,
|
||||
season=file_meta.begin_season or 1)
|
||||
else:
|
||||
episodes_info = None
|
||||
|
||||
# 获取下载hash
|
||||
if not download_hash:
|
||||
download_file = self.downloadhis.get_file_by_fullpath(file_path_str)
|
||||
if download_file:
|
||||
download_hash = download_file.download_hash
|
||||
|
||||
# 执行转移
|
||||
transferinfo: TransferInfo = self.transfer(meta=file_meta,
|
||||
mediainfo=file_mediainfo,
|
||||
path=file_path,
|
||||
transfer_type=transfer_type,
|
||||
target=target,
|
||||
episodes_info=episodes_info)
|
||||
if not transferinfo:
|
||||
logger.error("文件转移模块运行失败")
|
||||
return False, "文件转移模块运行失败"
|
||||
if not transferinfo.success:
|
||||
# 转移失败
|
||||
logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}")
|
||||
err_msgs.append(f"{file_path.name} {transferinfo.message}")
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo,
|
||||
transferinfo=transferinfo
|
||||
)
|
||||
# 发送消息
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{file_mediainfo.title_year} {file_meta.season_episode} 入库失败!",
|
||||
text=f"原因:{transferinfo.message or '未知'}",
|
||||
image=file_mediainfo.get_message_image()
|
||||
))
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 汇总信息
|
||||
mkey = (file_mediainfo.tmdb_id, file_meta.begin_season)
|
||||
if mkey not in medias:
|
||||
# 新增信息
|
||||
metas[mkey] = file_meta
|
||||
medias[mkey] = file_mediainfo
|
||||
season_episodes[mkey] = file_meta.episode_list
|
||||
transfers[mkey] = transferinfo
|
||||
else:
|
||||
# 合并季集清单
|
||||
season_episodes[mkey] = list(set(season_episodes[mkey] + file_meta.episode_list))
|
||||
# 合并转移数据
|
||||
transfers[mkey].file_count += transferinfo.file_count
|
||||
transfers[mkey].total_size += transferinfo.total_size
|
||||
transfers[mkey].file_list.extend(transferinfo.file_list)
|
||||
transfers[mkey].file_list_new.extend(transferinfo.file_list_new)
|
||||
transfers[mkey].fail_list.extend(transferinfo.fail_list)
|
||||
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_success(
|
||||
src_path=file_path,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo,
|
||||
transferinfo=transferinfo
|
||||
)
|
||||
# 刮削单个文件
|
||||
if settings.SCRAP_METADATA:
|
||||
self.scrape_metadata(path=transferinfo.target_path, mediainfo=file_mediainfo)
|
||||
# 更新进度
|
||||
processed_num += 1
|
||||
self.progress.update(value=processed_num / total_num * 100,
|
||||
text=f"{file_path.name} 转移完成",
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
# 目录或文件转移完成
|
||||
self.progress.update(text=f"{trans_path} 转移完成,正在执行后续处理 ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
# 执行后续处理
|
||||
for mkey, media in medias.items():
|
||||
transfer_meta = metas[mkey]
|
||||
transfer_info = transfers[mkey]
|
||||
# 媒体目录
|
||||
if transfer_info.target_path.is_file():
|
||||
transfer_info.target_path = transfer_info.target_path.parent
|
||||
# 刷新媒体库,根目录或季目录
|
||||
if settings.REFRESH_MEDIASERVER:
|
||||
self.refresh_mediaserver(mediainfo=media, file_path=transfer_info.target_path)
|
||||
# 发送通知
|
||||
se_str = None
|
||||
if media.type == MediaType.TV:
|
||||
se_str = f"{transfer_meta.season} {StringUtils.format_ep(season_episodes[mkey])}"
|
||||
self.send_transfer_message(meta=transfer_meta,
|
||||
mediainfo=media,
|
||||
transferinfo=transfer_info,
|
||||
season_episode=se_str)
|
||||
# 广播事件
|
||||
self.eventmanager.send_event(EventType.TransferComplete, {
|
||||
'meta': transfer_meta,
|
||||
'mediainfo': media,
|
||||
'transferinfo': transfer_info
|
||||
})
|
||||
|
||||
# 结束进度
|
||||
logger.info(f"{path} 转移完成,共 {total_num} 个文件,"
|
||||
f"失败 {fail_num} 个,跳过 {skip_num} 个")
|
||||
|
||||
self.progress.update(value=100,
|
||||
text=f"{path} 转移完成,共 {total_num} 个文件,"
|
||||
f"失败 {fail_num} 个,跳过 {skip_num} 个",
|
||||
key=ProgressKey.FileTransfer)
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
|
||||
return True, "\n".join(err_msgs)
|
||||
|
||||
@staticmethod
|
||||
def __get_trans_paths(directory: Path):
|
||||
"""
|
||||
获取转移目录列表
|
||||
"""
|
||||
|
||||
if not directory.exists():
|
||||
logger.warn(f"目录不存在:{directory}")
|
||||
return []
|
||||
|
||||
# 单文件
|
||||
if directory.is_file():
|
||||
return [directory]
|
||||
|
||||
# 蓝光原盘
|
||||
if SystemUtils.is_bluray_dir(directory):
|
||||
return [directory]
|
||||
|
||||
# 需要转移的路径列表
|
||||
trans_paths = []
|
||||
|
||||
# 先检查当前目录的下级目录,以支持合集的情况
|
||||
for sub_dir in SystemUtils.list_sub_directory(directory):
|
||||
# 如果是蓝光原盘
|
||||
if SystemUtils.is_bluray_dir(sub_dir):
|
||||
trans_paths.append(sub_dir)
|
||||
# 没有媒体文件的目录跳过
|
||||
elif SystemUtils.list_files(sub_dir, extensions=settings.RMT_MEDIAEXT):
|
||||
trans_paths.append(sub_dir)
|
||||
|
||||
if not trans_paths:
|
||||
# 没有有效子目录,直接转移当前目录
|
||||
trans_paths.append(directory)
|
||||
else:
|
||||
# 有子目录时,把当前目录的文件添加到转移任务中
|
||||
trans_paths.extend(
|
||||
SystemUtils.list_sub_files(directory, extensions=settings.RMT_MEDIAEXT)
|
||||
)
|
||||
return trans_paths
|
||||
|
||||
def remote_transfer(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
远程重新转移,参数 历史记录ID TMDBID|类型
|
||||
"""
|
||||
|
||||
def args_error():
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="请输入正确的命令格式:/redo [id] [tmdbid]|[类型],"
|
||||
"[id]历史记录编号", userid=userid))
|
||||
|
||||
if not arg_str:
|
||||
args_error()
|
||||
return
|
||||
arg_strs = str(arg_str).split()
|
||||
if len(arg_strs) != 2:
|
||||
args_error()
|
||||
return
|
||||
# 历史记录ID
|
||||
logid = arg_strs[0]
|
||||
if not logid.isdigit():
|
||||
args_error()
|
||||
return
|
||||
# TMDB ID
|
||||
tmdb_strs = arg_strs[1].split('|')
|
||||
tmdbid = tmdb_strs[0]
|
||||
if not logid.isdigit():
|
||||
args_error()
|
||||
return
|
||||
# 类型
|
||||
type_str = tmdb_strs[1] if len(tmdb_strs) > 1 else None
|
||||
if not type_str or type_str not in [MediaType.MOVIE.value, MediaType.TV.value]:
|
||||
args_error()
|
||||
return
|
||||
state, errmsg = self.re_transfer(logid=int(logid),
|
||||
mtype=MediaType(type_str), tmdbid=int(tmdbid))
|
||||
if not state:
|
||||
self.post_message(Notification(channel=channel, title="手动整理失败",
|
||||
text=errmsg, userid=userid))
|
||||
return
|
||||
|
||||
def re_transfer(self, logid: int,
|
||||
mtype: MediaType = None, tmdbid: int = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
根据历史记录,重新识别转移,只处理对应的src目录
|
||||
:param logid: 历史记录ID
|
||||
:param mtype: 媒体类型
|
||||
:param tmdbid: TMDB ID
|
||||
"""
|
||||
# 查询历史记录
|
||||
history: TransferHistory = self.transferhis.get(logid)
|
||||
if not history:
|
||||
logger.error(f"历史记录不存在,ID:{logid}")
|
||||
return False, "历史记录不存在"
|
||||
# 没有下载记录,按源目录路径重新转移
|
||||
src_path = Path(history.src)
|
||||
if not src_path.exists():
|
||||
return False, f"源目录不存在:{src_path}"
|
||||
dest_path = Path(history.dest) if history.dest else None
|
||||
# 查询媒体信息
|
||||
if mtype and tmdbid:
|
||||
mediainfo = self.recognize_media(mtype=mtype, tmdbid=tmdbid)
|
||||
else:
|
||||
meta = MetaInfoPath(src_path)
|
||||
mediainfo = self.recognize_media(meta=meta)
|
||||
if not mediainfo:
|
||||
return False, f"未识别到媒体信息,类型:{mtype.value},tmdbid:{tmdbid}"
|
||||
# 重新执行转移
|
||||
logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}")
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
|
||||
# 删除旧的已整理文件
|
||||
if history.dest:
|
||||
self.delete_files(Path(history.dest))
|
||||
|
||||
# 强制转移
|
||||
state, errmsg = self.do_transfer(path=src_path,
|
||||
mediainfo=mediainfo,
|
||||
download_hash=history.download_hash,
|
||||
target=dest_path,
|
||||
force=True)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
|
||||
return True, ""
|
||||
|
||||
def manual_transfer(self, in_path: Path,
|
||||
target: Path = None,
|
||||
tmdbid: int = None,
|
||||
mtype: MediaType = None,
|
||||
season: int = None,
|
||||
transfer_type: str = None,
|
||||
epformat: EpisodeFormat = None,
|
||||
min_filesize: int = 0) -> Tuple[bool, Union[str, list]]:
|
||||
"""
|
||||
手动转移
|
||||
:param in_path: 源文件路径
|
||||
:param target: 目标路径
|
||||
:param tmdbid: TMDB ID
|
||||
:param mtype: 媒体类型
|
||||
:param season: 季度
|
||||
:param transfer_type: 转移类型
|
||||
:param epformat: 剧集格式
|
||||
:param min_filesize: 最小文件大小(MB)
|
||||
"""
|
||||
logger.info(f"手动转移:{in_path} ...")
|
||||
|
||||
if tmdbid:
|
||||
# 有输入TMDBID时单个识别
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.mediachain.recognize_media(tmdbid=tmdbid, mtype=mtype)
|
||||
if not mediainfo:
|
||||
return False, f"媒体信息识别失败,tmdbid: {tmdbid}, type: {mtype.value}"
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.FileTransfer)
|
||||
self.progress.update(value=0,
|
||||
text=f"开始转移 {in_path} ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
# 开始转移
|
||||
state, errmsg = self.do_transfer(
|
||||
path=in_path,
|
||||
mediainfo=mediainfo,
|
||||
target=target,
|
||||
season=season,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize
|
||||
)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
logger.info(f"{in_path} 转移完成")
|
||||
return True, ""
|
||||
else:
|
||||
# 没有输入TMDBID时,按文件识别
|
||||
state, errmsg = self.do_transfer(path=in_path,
|
||||
target=target,
|
||||
transfer_type=transfer_type,
|
||||
season=season,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize)
|
||||
return state, errmsg
|
||||
|
||||
def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transferinfo: TransferInfo, season_episode: str = None):
|
||||
"""
|
||||
发送入库成功的消息
|
||||
"""
|
||||
msg_title = f"{mediainfo.title_year}{meta.season_episode} 已入库"
|
||||
msg_title = f"{mediainfo.title_year} {meta.season_episode if not season_episode else season_episode} 已入库"
|
||||
if mediainfo.vote_average:
|
||||
msg_str = f"评分:{mediainfo.vote_average},类型:{mediainfo.type.value}"
|
||||
else:
|
||||
@@ -255,25 +616,47 @@ class TransferChain(ChainBase):
|
||||
def delete_files(path: Path):
|
||||
"""
|
||||
删除转移后的文件以及空目录
|
||||
:param path: 文件路径
|
||||
"""
|
||||
logger.info(f"开始删除文件以及空目录:{path} ...")
|
||||
if not path.exists():
|
||||
logger.error(f"{path} 不存在")
|
||||
return
|
||||
elif path.is_file():
|
||||
# 删除文件
|
||||
path.unlink()
|
||||
if path.is_file():
|
||||
# 删除文件、nfo、jpg等同名文件
|
||||
pattern = path.stem.replace('[', '?').replace(']', '?')
|
||||
files = path.parent.glob(f"{pattern}.*")
|
||||
for file in files:
|
||||
Path(file).unlink()
|
||||
logger.warn(f"文件 {path} 已删除")
|
||||
# 判断目录是否为空, 为空则删除
|
||||
if str(path.parent.parent) != str(path.root):
|
||||
# 父父目录非根目录,才删除父目录
|
||||
files = SystemUtils.list_files_with_extensions(path.parent, settings.RMT_MEDIAEXT)
|
||||
if not files:
|
||||
shutil.rmtree(path.parent)
|
||||
logger.warn(f"目录 {path.parent} 已删除")
|
||||
# 需要删除父目录
|
||||
elif str(path.parent) == str(path.root):
|
||||
# 根目录,不删除
|
||||
logger.warn(f"根目录 {path} 不能删除!")
|
||||
return
|
||||
else:
|
||||
if str(path.parent) != str(path.root):
|
||||
# 父目录非根目录,才删除目录
|
||||
shutil.rmtree(path)
|
||||
# 删除目录
|
||||
logger.warn(f"目录 {path} 已删除")
|
||||
# 非根目录,才删除目录
|
||||
shutil.rmtree(path)
|
||||
# 删除目录
|
||||
logger.warn(f"目录 {path} 已删除")
|
||||
# 需要删除父目录
|
||||
|
||||
# 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级
|
||||
if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT):
|
||||
# 媒体库二级分类根路径
|
||||
library_root_names = [
|
||||
settings.LIBRARY_MOVIE_NAME or '电影',
|
||||
settings.LIBRARY_TV_NAME or '电视剧',
|
||||
settings.LIBRARY_ANIME_NAME or '动漫',
|
||||
]
|
||||
|
||||
# 判断父目录是否为空, 为空则删除
|
||||
for parent_path in path.parents:
|
||||
# 遍历父目录到媒体库二级分类根路径
|
||||
if str(parent_path.name) in library_root_names:
|
||||
break
|
||||
if str(parent_path.parent) != str(path.root):
|
||||
# 父目录非根目录,才删除父目录
|
||||
if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT):
|
||||
# 当前路径下没有媒体文件则删除
|
||||
shutil.rmtree(parent_path)
|
||||
logger.warn(f"目录 {parent_path} 已删除")
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
from app.chain import ChainBase
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import EventType, MediaImageType, MediaType, NotificationType
|
||||
from app.utils.http import WebUtils
|
||||
from app.utils.web import WebUtils
|
||||
|
||||
|
||||
class WebhookChain(ChainBase):
|
||||
@@ -76,8 +76,9 @@ class WebhookChain(ChainBase):
|
||||
# 消息图片
|
||||
image_url = event_info.image_url
|
||||
# 查询剧集图片
|
||||
if event_info.tmdb_id \
|
||||
and event_info.season_id:
|
||||
if (event_info.tmdb_id
|
||||
and event_info.season_id
|
||||
and event_info.episode_id):
|
||||
specific_image = self.obtain_specific_image(
|
||||
mediaid=event_info.tmdb_id,
|
||||
mtype=MediaType.TV,
|
||||
|
||||
164
app/command.py
164
app/command.py
@@ -1,21 +1,24 @@
|
||||
import traceback
|
||||
from threading import Thread, Event
|
||||
from typing import Any, Union
|
||||
from typing import Any, Union, Dict
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.cookiecloud import CookieCloudChain
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.system import SystemChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.event import Event as ManagerEvent
|
||||
from app.core.event import eventmanager, EventManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db import SessionFactory
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class CommandChian(ChainBase):
|
||||
@@ -38,70 +41,106 @@ class Command(metaclass=Singleton):
|
||||
_event = Event()
|
||||
|
||||
def __init__(self):
|
||||
# 数据库连接
|
||||
self._db = SessionFactory()
|
||||
# 事件管理器
|
||||
self.eventmanager = EventManager()
|
||||
# 插件管理器
|
||||
self.pluginmanager = PluginManager()
|
||||
# 处理链
|
||||
self.chain = CommandChian(self._db)
|
||||
# 定时服务管理
|
||||
self.scheduler = Scheduler()
|
||||
# 内置命令
|
||||
self._commands = {
|
||||
"/cookiecloud": {
|
||||
"func": CookieCloudChain().remote_sync,
|
||||
"id": "cookiecloud",
|
||||
"type": "scheduler",
|
||||
"description": "同步站点",
|
||||
"data": {}
|
||||
"category": "站点"
|
||||
},
|
||||
"/sites": {
|
||||
"func": SiteChain().remote_list,
|
||||
"func": SiteChain(self._db).remote_list,
|
||||
"description": "查询站点",
|
||||
"category": "站点",
|
||||
"data": {}
|
||||
},
|
||||
"/site_cookie": {
|
||||
"func": SiteChain().remote_cookie,
|
||||
"func": SiteChain(self._db).remote_cookie,
|
||||
"description": "更新站点Cookie",
|
||||
"data": {}
|
||||
},
|
||||
"/site_enable": {
|
||||
"func": SiteChain().remote_enable,
|
||||
"func": SiteChain(self._db).remote_enable,
|
||||
"description": "启用站点",
|
||||
"data": {}
|
||||
},
|
||||
"/site_disable": {
|
||||
"func": SiteChain().remote_disable,
|
||||
"func": SiteChain(self._db).remote_disable,
|
||||
"description": "禁用站点",
|
||||
"data": {}
|
||||
},
|
||||
"/mediaserver_sync": {
|
||||
"func": MediaServerChain().remote_sync,
|
||||
"id": "mediaserver_sync",
|
||||
"type": "scheduler",
|
||||
"description": "同步媒体服务器",
|
||||
"data": {}
|
||||
"category": "管理"
|
||||
},
|
||||
"/subscribes": {
|
||||
"func": SubscribeChain().remote_list,
|
||||
"func": SubscribeChain(self._db).remote_list,
|
||||
"description": "查询订阅",
|
||||
"category": "订阅",
|
||||
"data": {}
|
||||
},
|
||||
"/subscribe_refresh": {
|
||||
"func": SubscribeChain().remote_refresh,
|
||||
"id": "subscribe_refresh",
|
||||
"type": "scheduler",
|
||||
"description": "刷新订阅",
|
||||
"data": {}
|
||||
"category": "订阅"
|
||||
},
|
||||
"/subscribe_search": {
|
||||
"func": SubscribeChain().remote_search,
|
||||
"id": "subscribe_search",
|
||||
"type": "scheduler",
|
||||
"description": "搜索订阅",
|
||||
"data": {}
|
||||
"category": "订阅"
|
||||
},
|
||||
"/subscribe_delete": {
|
||||
"func": SubscribeChain().remote_delete,
|
||||
"func": SubscribeChain(self._db).remote_delete,
|
||||
"description": "删除订阅",
|
||||
"data": {}
|
||||
},
|
||||
"/subscribe_tmdb": {
|
||||
"id": "subscribe_tmdb",
|
||||
"type": "scheduler",
|
||||
"description": "订阅元数据更新"
|
||||
},
|
||||
"/downloading": {
|
||||
"func": DownloadChain().remote_downloading,
|
||||
"func": DownloadChain(self._db).remote_downloading,
|
||||
"description": "正在下载",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
},
|
||||
"/transfer": {
|
||||
"func": TransferChain().process,
|
||||
"id": "transfer",
|
||||
"type": "scheduler",
|
||||
"description": "下载文件整理",
|
||||
"category": "管理"
|
||||
},
|
||||
"/redo": {
|
||||
"func": TransferChain(self._db).remote_transfer,
|
||||
"description": "手动整理",
|
||||
"data": {}
|
||||
},
|
||||
"/clear_cache": {
|
||||
"func": SystemChain(self._db).remote_clear_cache,
|
||||
"description": "清理缓存",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
},
|
||||
"/restart": {
|
||||
"func": SystemUtils.restart,
|
||||
"description": "重启系统",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
@@ -112,13 +151,12 @@ class Command(metaclass=Singleton):
|
||||
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 = CommandChian()
|
||||
# 广播注册命令菜单
|
||||
self.chain.register_commands(commands=self.get_commands())
|
||||
# 消息处理线程
|
||||
@@ -144,12 +182,64 @@ class Command(metaclass=Singleton):
|
||||
except Exception as e:
|
||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
def __run_command(self, command: Dict[str, any],
|
||||
data_str: str = "",
|
||||
channel: MessageChannel = None, userid: Union[str, int] = None):
|
||||
"""
|
||||
运行定时服务
|
||||
"""
|
||||
if command.get("type") == "scheduler":
|
||||
# 定时服务
|
||||
if userid:
|
||||
self.chain.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
title=f"开始执行 {command.get('description')} ...",
|
||||
userid=userid
|
||||
)
|
||||
)
|
||||
|
||||
# 执行定时任务
|
||||
self.scheduler.start(job_id=command.get("id"))
|
||||
|
||||
if userid:
|
||||
self.chain.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
title=f"{command.get('description')} 执行完成",
|
||||
userid=userid
|
||||
)
|
||||
)
|
||||
else:
|
||||
# 命令
|
||||
cmd_data = command['data'] if command.get('data') else {}
|
||||
args_num = ObjectUtils.arguments(command['func'])
|
||||
if args_num > 0:
|
||||
if cmd_data:
|
||||
# 有内置参数直接使用内置参数
|
||||
data = cmd_data.get("data") or {}
|
||||
data['channel'] = channel
|
||||
data['user'] = userid
|
||||
cmd_data['data'] = data
|
||||
command['func'](**cmd_data)
|
||||
elif args_num == 2:
|
||||
# 没有输入参数,只输入渠道和用户ID
|
||||
command['func'](channel, userid)
|
||||
elif args_num > 2:
|
||||
# 多个输入参数:用户输入、用户ID
|
||||
command['func'](data_str, channel, userid)
|
||||
else:
|
||||
# 没有参数
|
||||
command['func']()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
停止事件处理线程
|
||||
"""
|
||||
self._event.set()
|
||||
self._thread.join()
|
||||
if self._db:
|
||||
self._db.close()
|
||||
|
||||
def get_commands(self):
|
||||
"""
|
||||
@@ -157,13 +247,15 @@ class Command(metaclass=Singleton):
|
||||
"""
|
||||
return self._commands
|
||||
|
||||
def register(self, cmd: str, func: Any, data: dict = None, desc: str = None) -> None:
|
||||
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 {}
|
||||
}
|
||||
|
||||
@@ -181,23 +273,19 @@ class Command(metaclass=Singleton):
|
||||
command = self.get(cmd)
|
||||
if command:
|
||||
try:
|
||||
logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...")
|
||||
cmd_data = command['data'] if command.get('data') else {}
|
||||
args_num = ObjectUtils.arguments(command['func'])
|
||||
if args_num > 0:
|
||||
if cmd_data:
|
||||
# 有内置参数直接使用内置参数
|
||||
command['func'](**cmd_data)
|
||||
elif args_num == 2:
|
||||
# 没有输入参数,只输入渠道和用户ID
|
||||
command['func'](channel, userid)
|
||||
elif args_num > 2:
|
||||
# 多个输入参数:用户输入、用户ID
|
||||
command['func'](data_str, channel, userid)
|
||||
if userid:
|
||||
logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...")
|
||||
else:
|
||||
# 没有参数
|
||||
command['func']()
|
||||
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
|
||||
logger.info(f"开始执行:{command.get('description')} ...")
|
||||
|
||||
# 执行命令
|
||||
self.__run_command(command, data_str=data_str,
|
||||
channel=channel, userid=userid)
|
||||
|
||||
if userid:
|
||||
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
|
||||
else:
|
||||
logger.info(f"{command.get('description')} 执行完成")
|
||||
except Exception as err:
|
||||
logger.error(f"执行命令 {cmd} 出错:{str(err)}")
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseSettings
|
||||
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# 项目名称
|
||||
@@ -39,6 +44,8 @@ class Settings(BaseSettings):
|
||||
SEARCH_SOURCE: str = "themoviedb"
|
||||
# 刮削入库的媒体文件
|
||||
SCRAP_METADATA: bool = True
|
||||
# 新增已入库媒体是否跟随TMDB信息变化
|
||||
SCRAP_FOLLOW_TMDB: bool = True
|
||||
# 刮削来源
|
||||
SCRAP_SOURCE: str = "themoviedb"
|
||||
# TMDB图片地址
|
||||
@@ -63,9 +70,17 @@ class Settings(BaseSettings):
|
||||
RMT_AUDIO_TRACK_EXT: list = ['.mka']
|
||||
# 索引器
|
||||
INDEXER: str = "builtin"
|
||||
# 用户认证站点 hhclub/audiences/hddolby/zmpt/freefarm/hdfans/wintersakura/leaves/1ptba/icc2022/iyuu
|
||||
# 订阅模式
|
||||
SUBSCRIBE_MODE: str = "spider"
|
||||
# RSS订阅模式刷新时间间隔(分钟)
|
||||
SUBSCRIBE_RSS_INTERVAL: int = 30
|
||||
# 订阅搜索开关
|
||||
SUBSCRIBE_SEARCH: bool = False
|
||||
# 用户认证站点
|
||||
AUTH_SITE: str = ""
|
||||
# 消息通知渠道 telegram/wechat/slack
|
||||
# 交互搜索自动下载用户ID,使用,分割
|
||||
AUTO_DOWNLOAD_USER: str = None
|
||||
# 消息通知渠道 telegram/wechat/slack,多个通知渠道用,分隔
|
||||
MESSAGER: str = "telegram"
|
||||
# WeChat企业ID
|
||||
WECHAT_CORPID: str = None
|
||||
@@ -95,14 +110,22 @@ class Settings(BaseSettings):
|
||||
SLACK_APP_TOKEN: str = ""
|
||||
# Slack 频道名称
|
||||
SLACK_CHANNEL: str = ""
|
||||
# SynologyChat Webhook
|
||||
SYNOLOGYCHAT_WEBHOOK: str = ""
|
||||
# SynologyChat Token
|
||||
SYNOLOGYCHAT_TOKEN: str = ""
|
||||
# 下载器 qbittorrent/transmission
|
||||
DOWNLOADER: str = "qbittorrent"
|
||||
# 下载器监控开关
|
||||
DOWNLOADER_MONITOR: bool = True
|
||||
# Qbittorrent地址,IP:PORT
|
||||
QB_HOST: str = None
|
||||
# Qbittorrent用户名
|
||||
QB_USER: str = None
|
||||
# Qbittorrent密码
|
||||
QB_PASSWORD: str = None
|
||||
# Qbittorrent分类自动管理
|
||||
QB_CATEGORY: bool = False
|
||||
# Transmission地址,IP:PORT
|
||||
TR_HOST: str = None
|
||||
# Transmission用户名
|
||||
@@ -117,16 +140,20 @@ class Settings(BaseSettings):
|
||||
DOWNLOAD_MOVIE_PATH: str = None
|
||||
# 电视剧下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_TV_PATH: str = None
|
||||
# 动漫下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_ANIME_PATH: str = None
|
||||
# 下载目录二级分类
|
||||
DOWNLOAD_CATEGORY: bool = False
|
||||
# 下载站点字幕
|
||||
DOWNLOAD_SUBTITLE: bool = True
|
||||
# 媒体服务器 emby/jellyfin/plex
|
||||
# 媒体服务器 emby/jellyfin/plex,多个媒体服务器,分割
|
||||
MEDIASERVER: str = "emby"
|
||||
# 入库刷新媒体库
|
||||
REFRESH_MEDIASERVER: bool = True
|
||||
# 媒体服务器同步间隔(小时)
|
||||
MEDIASERVER_SYNC_INTERVAL: int = 6
|
||||
# 媒体服务器同步黑名单,多个媒体库名称,分割
|
||||
MEDIASERVER_SYNC_BLACKLIST: str = None
|
||||
# EMBY服务器地址,IP:PORT
|
||||
EMBY_HOST: str = None
|
||||
# EMBY Api Key
|
||||
@@ -142,23 +169,29 @@ class Settings(BaseSettings):
|
||||
# 转移方式 link/copy/move/softlink
|
||||
TRANSFER_TYPE: str = "copy"
|
||||
# CookieCloud服务器地址
|
||||
COOKIECLOUD_HOST: str = "https://nastool.org/cookiecloud"
|
||||
COOKIECLOUD_HOST: str = "https://movie-pilot.org/cookiecloud"
|
||||
# CookieCloud用户KEY
|
||||
COOKIECLOUD_KEY: str = None
|
||||
# CookieCloud端对端加密密码
|
||||
COOKIECLOUD_PASSWORD: str = None
|
||||
# CookieCloud同步间隔(分钟)
|
||||
COOKIECLOUD_INTERVAL: int = 60 * 24
|
||||
# OCR服务器地址
|
||||
OCR_HOST: str = "https://movie-pilot.org"
|
||||
# CookieCloud对应的浏览器UA
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
||||
# 媒体库目录
|
||||
# 媒体库目录,多个目录使用,分隔
|
||||
LIBRARY_PATH: str = None
|
||||
# 电影媒体库目录名,默认"电影"
|
||||
LIBRARY_MOVIE_NAME: str = None
|
||||
# 电视剧媒体库目录名,默认"电视剧"
|
||||
LIBRARY_TV_NAME: str = None
|
||||
# 动漫媒体库目录名,默认"电视剧/动漫"
|
||||
LIBRARY_ANIME_NAME: str = None
|
||||
# 二级分类
|
||||
LIBRARY_CATEGORY: bool = True
|
||||
# 电视剧动漫的分类genre_ids
|
||||
ANIME_GENREIDS = [16]
|
||||
# 电影重命名格式
|
||||
MOVIE_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \
|
||||
"/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}" \
|
||||
@@ -179,7 +212,11 @@ class Settings(BaseSettings):
|
||||
def CONFIG_PATH(self):
|
||||
if self.CONFIG_DIR:
|
||||
return Path(self.CONFIG_DIR)
|
||||
return self.INNER_CONFIG_PATH
|
||||
elif SystemUtils.is_docker():
|
||||
return Path("/config")
|
||||
elif SystemUtils.is_frozen():
|
||||
return Path(sys.executable).parent / "config"
|
||||
return self.ROOT_PATH / "config"
|
||||
|
||||
@property
|
||||
def TEMP_PATH(self):
|
||||
@@ -233,11 +270,20 @@ class Settings(BaseSettings):
|
||||
"server": self.PROXY_HOST
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@property
|
||||
def LIBRARY_PATHS(self) -> List[Path]:
|
||||
if self.LIBRARY_PATH:
|
||||
return [Path(path) for path in self.LIBRARY_PATH.split(",")]
|
||||
return []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
with self.CONFIG_PATH as p:
|
||||
if not p.exists():
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
if SystemUtils.is_frozen():
|
||||
if not (p / "app.env").exists():
|
||||
SystemUtils.copy(self.INNER_CONFIG_PATH / "app.env", p / "app.env")
|
||||
with self.TEMP_PATH as p:
|
||||
if not p.exists():
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
@@ -249,4 +295,7 @@ class Settings(BaseSettings):
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
settings = Settings(
|
||||
_env_file=Settings().CONFIG_PATH / "app.env",
|
||||
_env_file_encoding="utf-8"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
@@ -148,6 +148,8 @@ class MediaInfo:
|
||||
vote_average: int = 0
|
||||
# 描述
|
||||
overview: str = None
|
||||
# 风格ID
|
||||
genre_ids: list = field(default_factory=list)
|
||||
# 所有别名和译名
|
||||
names: list = field(default_factory=list)
|
||||
# 各季的剧集清单信息
|
||||
@@ -250,6 +252,15 @@ class MediaInfo:
|
||||
"""
|
||||
setattr(self, f"{name}_path", image)
|
||||
|
||||
def get_image(self, name: str):
|
||||
"""
|
||||
获取图片地址
|
||||
"""
|
||||
try:
|
||||
return getattr(self, f"{name}_path")
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
def set_category(self, cat: str):
|
||||
"""
|
||||
设置二级分类
|
||||
@@ -261,7 +272,7 @@ class MediaInfo:
|
||||
初始化媒信息
|
||||
"""
|
||||
|
||||
def __directors_actors(tmdbinfo: dict):
|
||||
def __directors_actors(tmdbinfo: dict) -> Tuple[List[dict], List[dict]]:
|
||||
"""
|
||||
查询导演和演员
|
||||
:param tmdbinfo: TMDB元数据
|
||||
@@ -338,6 +349,8 @@ class MediaInfo:
|
||||
self.vote_average = round(float(info.get('vote_average')), 1) if info.get('vote_average') else 0
|
||||
# 描述
|
||||
self.overview = info.get('overview')
|
||||
# 风格
|
||||
self.genre_ids = info.get('genre_ids') or []
|
||||
# 原语种
|
||||
self.original_language = info.get('original_language')
|
||||
if self.type == MediaType.MOVIE:
|
||||
@@ -442,6 +455,8 @@ class MediaInfo:
|
||||
self.poster_path = info.get("pic", {}).get("large")
|
||||
if not self.poster_path and info.get("cover_url"):
|
||||
self.poster_path = info.get("cover_url")
|
||||
if not self.poster_path and info.get("cover"):
|
||||
self.poster_path = info.get("cover").get("url")
|
||||
# 简介
|
||||
if not self.overview:
|
||||
self.overview = info.get("intro") or info.get("card_subtitle") or ""
|
||||
@@ -549,7 +564,6 @@ class MediaInfo:
|
||||
dicts["type"] = self.type.value if self.type else None
|
||||
dicts["detail_link"] = self.detail_link
|
||||
dicts["title_year"] = self.title_year
|
||||
dicts["tmdb_info"]["media_type"] = self.type.value if self.type else None
|
||||
return dicts
|
||||
|
||||
def clear(self):
|
||||
|
||||
47
app/core/meta/customization.py
Normal file
47
app/core/meta/customization.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import regex as re
|
||||
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class CustomizationMatcher(metaclass=Singleton):
|
||||
"""
|
||||
识别自定义占位符
|
||||
"""
|
||||
customization = None
|
||||
custom_separator = None
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.customization = None
|
||||
self.custom_separator = None
|
||||
|
||||
def match(self, title=None):
|
||||
"""
|
||||
:param title: 资源标题或文件名
|
||||
:return: 匹配结果
|
||||
"""
|
||||
if not title:
|
||||
return ""
|
||||
if not self.customization:
|
||||
# 自定义占位符
|
||||
customization = self.systemconfig.get(SystemConfigKey.Customization)
|
||||
if not customization:
|
||||
return ""
|
||||
if isinstance(customization, str):
|
||||
customization = customization.replace("\n", ";").replace("|", ";").strip(";").split(";")
|
||||
self.customization = "|".join([f"({item})" for item in customization])
|
||||
|
||||
customization_re = re.compile(r"%s" % self.customization)
|
||||
# 处理重复多次的情况,保留先后顺序(按添加自定义占位符的顺序)
|
||||
unique_customization = {}
|
||||
for item in re.findall(customization_re, title):
|
||||
if not isinstance(item, tuple):
|
||||
item = (item,)
|
||||
for i in range(len(item)):
|
||||
if item[i] and unique_customization.get(item[i]) is None:
|
||||
unique_customization[item[i]] = i
|
||||
unique_customization = list(dict(sorted(unique_customization.items(), key=lambda x: x[1])).keys())
|
||||
separator = self.custom_separator or "@"
|
||||
return separator.join(unique_customization)
|
||||
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
import zhconv
|
||||
import anitopy
|
||||
from app.core.meta.customization import CustomizationMatcher
|
||||
from app.core.meta.metabase import MetaBase
|
||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.utils.string import StringUtils
|
||||
@@ -88,9 +89,9 @@ class MetaAnime(MetaBase):
|
||||
self.begin_season = int(begin_season)
|
||||
if end_season and int(end_season) != self.begin_season:
|
||||
self.end_season = int(end_season)
|
||||
self.total_seasons = (self.end_season - self.begin_season) + 1
|
||||
self.total_season = (self.end_season - self.begin_season) + 1
|
||||
else:
|
||||
self.total_seasons = 1
|
||||
self.total_season = 1
|
||||
self.type = MediaType.TV
|
||||
# 集号
|
||||
episode_number = anitopy_info.get("episode_number")
|
||||
@@ -144,6 +145,8 @@ class MetaAnime(MetaBase):
|
||||
self.resource_team = \
|
||||
ReleaseGroupsMatcher().match(title=original_title) or \
|
||||
anitopy_info_origin.get("release_group") or None
|
||||
# 自定义占位符
|
||||
self.customization = CustomizationMatcher().match(title=original_title) or None
|
||||
# 视频编码
|
||||
self.video_encode = anitopy_info.get("video_term")
|
||||
if isinstance(self.video_encode, list):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Union, Optional, List
|
||||
from typing import Union, Optional, List, Self
|
||||
|
||||
import cn2an
|
||||
import regex as re
|
||||
@@ -15,9 +15,9 @@ class MetaBase(object):
|
||||
"""
|
||||
# 是否处理的文件
|
||||
isfile: bool = False
|
||||
# 原标题字符串
|
||||
# 原标题字符串(未经过识别词处理)
|
||||
title: str = ""
|
||||
# 识别用字符串
|
||||
# 识别用字符串(经过识别词处理后)
|
||||
org_string: Optional[str] = None
|
||||
# 副标题
|
||||
subtitle: Optional[str] = None
|
||||
@@ -30,7 +30,7 @@ class MetaBase(object):
|
||||
# 年份
|
||||
year: Optional[str] = None
|
||||
# 总季数
|
||||
total_seasons: int = 0
|
||||
total_season: int = 0
|
||||
# 识别的开始季 数字
|
||||
begin_season: Optional[int] = None
|
||||
# 识别的结束季 数字
|
||||
@@ -51,6 +51,8 @@ class MetaBase(object):
|
||||
resource_pix: Optional[str] = None
|
||||
# 识别的制作组/字幕组
|
||||
resource_team: Optional[str] = None
|
||||
# 识别的自定义占位符
|
||||
customization: Optional[str] = None
|
||||
# 视频编码
|
||||
video_encode: Optional[str] = None
|
||||
# 音频编码
|
||||
@@ -115,13 +117,13 @@ class MetaBase(object):
|
||||
return
|
||||
if self.begin_season is None and isinstance(begin_season, int):
|
||||
self.begin_season = begin_season
|
||||
self.total_seasons = 1
|
||||
self.total_season = 1
|
||||
if self.begin_season is not None \
|
||||
and self.end_season is None \
|
||||
and isinstance(end_season, int) \
|
||||
and end_season != self.begin_season:
|
||||
self.end_season = end_season
|
||||
self.total_seasons = (self.end_season - self.begin_season) + 1
|
||||
self.total_season = (self.end_season - self.begin_season) + 1
|
||||
self.type = MediaType.TV
|
||||
self._subtitle_flag = True
|
||||
# 第x集
|
||||
@@ -179,12 +181,12 @@ class MetaBase(object):
|
||||
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_seasons = int(cn2an.cn2an(season_all.strip(), mode='smart'))
|
||||
self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart'))
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return
|
||||
self.begin_season = 1
|
||||
self.end_season = self.total_seasons
|
||||
self.end_season = self.total_season
|
||||
self.type = MediaType.TV
|
||||
self._subtitle_flag = True
|
||||
|
||||
@@ -243,7 +245,7 @@ class MetaBase(object):
|
||||
else:
|
||||
return [self.begin_season]
|
||||
|
||||
@ property
|
||||
@property
|
||||
def episode(self) -> str:
|
||||
"""
|
||||
返回开始集、结束集字符串
|
||||
@@ -440,10 +442,74 @@ class MetaBase(object):
|
||||
elif len(ep) > 1 and str(ep[0]).isdigit() and str(ep[-1]).isdigit():
|
||||
self.begin_episode = int(ep[0])
|
||||
self.end_episode = int(ep[-1])
|
||||
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
||||
elif str(ep).isdigit():
|
||||
self.begin_episode = int(ep)
|
||||
self.end_episode = None
|
||||
|
||||
def set_episodes(self, begin: int, end: int):
|
||||
"""
|
||||
设置开始集结束集
|
||||
"""
|
||||
if begin:
|
||||
self.begin_episode = begin
|
||||
if end:
|
||||
self.end_episode = end
|
||||
if self.begin_episode and self.end_episode:
|
||||
self.total_episode = (self.end_episode - self.begin_episode) + 1
|
||||
|
||||
def merge(self, meta: Self):
|
||||
"""
|
||||
全并Meta信息
|
||||
"""
|
||||
# 类型
|
||||
if self.type == MediaType.UNKNOWN \
|
||||
and meta.type != MediaType.UNKNOWN:
|
||||
self.type = meta.type
|
||||
# 名称
|
||||
if not self.name:
|
||||
self.cn_name = meta.cn_name
|
||||
self.en_name = meta.en_name
|
||||
# 年份
|
||||
if not self.year:
|
||||
self.year = meta.year
|
||||
# 季
|
||||
if (self.type == MediaType.TV
|
||||
and not self.season):
|
||||
self.begin_season = meta.begin_season
|
||||
self.end_season = meta.end_season
|
||||
self.total_season = meta.total_season
|
||||
# 开始集
|
||||
if (self.type == MediaType.TV
|
||||
and not self.episode):
|
||||
self.begin_episode = meta.begin_episode
|
||||
self.end_episode = meta.end_episode
|
||||
self.total_episode = meta.total_episode
|
||||
# 版本
|
||||
if not self.resource_type:
|
||||
self.resource_type = meta.resource_type
|
||||
# 分辨率
|
||||
if not self.resource_pix:
|
||||
self.resource_pix = meta.resource_pix
|
||||
# 制作组/字幕组
|
||||
if not self.resource_team:
|
||||
self.resource_team = meta.resource_team
|
||||
# 自定义占位符
|
||||
if not self.customization:
|
||||
self.customization = meta.customization
|
||||
# 特效
|
||||
if not self.resource_effect:
|
||||
self.resource_effect = meta.resource_effect
|
||||
# 视频编码
|
||||
if not self.video_encode:
|
||||
self.video_encode = meta.video_encode
|
||||
# 音频编码
|
||||
if not self.audio_encode:
|
||||
self.audio_encode = meta.audio_encode
|
||||
# Part
|
||||
if not self.part:
|
||||
self.part = meta.part
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
转为字典
|
||||
|
||||
@@ -2,6 +2,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta.customization import CustomizationMatcher
|
||||
from app.core.meta.metabase import MetaBase
|
||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.utils.string import StringUtils
|
||||
@@ -130,6 +131,8 @@ class MetaVideo(MetaBase):
|
||||
self.part = None
|
||||
# 制作组/字幕组
|
||||
self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None
|
||||
# 自定义占位符
|
||||
self.customization = CustomizationMatcher().match(title=original_title) or None
|
||||
|
||||
def __fix_name(self, name: str):
|
||||
if not name:
|
||||
@@ -347,14 +350,14 @@ class MetaVideo(MetaBase):
|
||||
se = int(se)
|
||||
if self.begin_season is None:
|
||||
self.begin_season = se
|
||||
self.total_seasons = 1
|
||||
self.total_season = 1
|
||||
else:
|
||||
if se > self.begin_season:
|
||||
self.end_season = se
|
||||
self.total_seasons = (self.end_season - self.begin_season) + 1
|
||||
if self.isfile and self.total_seasons > 1:
|
||||
self.total_season = (self.end_season - self.begin_season) + 1
|
||||
if self.isfile and self.total_season > 1:
|
||||
self.end_season = None
|
||||
self.total_seasons = 1
|
||||
self.total_season = 1
|
||||
elif token.isdigit():
|
||||
try:
|
||||
int(token)
|
||||
@@ -364,13 +367,15 @@ class MetaVideo(MetaBase):
|
||||
and self.begin_season is None \
|
||||
and len(token) < 3:
|
||||
self.begin_season = int(token)
|
||||
self.total_seasons = 1
|
||||
self.total_season = 1
|
||||
self._last_token_type = "season"
|
||||
self._stop_name_flag = True
|
||||
self._continue_flag = False
|
||||
self.type = MediaType.TV
|
||||
elif token.upper() == "SEASON" and self.begin_season is None:
|
||||
self._last_token_type = "SEASON"
|
||||
elif self.type == MediaType.TV and self.begin_season is None:
|
||||
self.begin_season = 1
|
||||
|
||||
def __init_episode(self, token: str):
|
||||
re_res = re.findall(r"%s" % self._episode_re, token, re.IGNORECASE)
|
||||
|
||||
@@ -28,7 +28,23 @@ class WordsMatcher(metaclass=Singleton):
|
||||
if not word:
|
||||
continue
|
||||
try:
|
||||
if word.count(" => "):
|
||||
if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "):
|
||||
# 替换词
|
||||
thc = str(re.findall(r'(.*?)\s*=>', word)[0]).strip()
|
||||
# 被替换词
|
||||
bthc = str(re.findall(r'=>\s*(.*?)\s*&&', word)[0]).strip()
|
||||
# 集偏移前字段
|
||||
pyq = str(re.findall(r'&&\s*(.*?)\s*<>', word)[0]).strip()
|
||||
# 集偏移后字段
|
||||
pyh = str(re.findall(r'<>(.*?)\s*>>', word)[0]).strip()
|
||||
# 集偏移
|
||||
offsets = str(re.findall(r'>>\s*(.*?)$', word)[0]).strip()
|
||||
# 替换词
|
||||
title, message, state = self.__replace_regex(title, thc, bthc)
|
||||
if state:
|
||||
# 替换词成功再进行集偏移
|
||||
title, message, state = self.__episode_offset(title, pyq, pyh, offsets)
|
||||
elif word.count(" => "):
|
||||
# 替换词
|
||||
strings = word.split(" => ")
|
||||
title, message, state = self.__replace_regex(title, strings[0], strings[1])
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.core.meta.words import WordsMatcher
|
||||
|
||||
def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
||||
"""
|
||||
媒体整理入口,根据名称和副标题,判断是哪种类型的识别,返回对应对象
|
||||
根据标题和副标题识别元数据
|
||||
:param title: 标题、种子名、文件名
|
||||
:param subtitle: 副标题、描述
|
||||
:return: MetaAnime、MetaVideo
|
||||
@@ -33,6 +33,20 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
||||
return meta
|
||||
|
||||
|
||||
def MetaInfoPath(path: Path) -> MetaBase:
|
||||
"""
|
||||
根据路径识别元数据
|
||||
:param path: 路径
|
||||
"""
|
||||
# 上级目录元数据
|
||||
dir_meta = MetaInfo(title=path.parent.name)
|
||||
# 文件元数据,不包含后缀
|
||||
file_meta = MetaInfo(title=path.stem)
|
||||
# 合并元数据
|
||||
file_meta.merge(dir_meta)
|
||||
return file_meta
|
||||
|
||||
|
||||
def is_anime(name: str) -> bool:
|
||||
"""
|
||||
判断是否为动漫
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import List, Any, Dict, Tuple
|
||||
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.module import ModuleHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.object import ObjectUtils
|
||||
@@ -23,6 +24,7 @@ class PluginManager(metaclass=Singleton):
|
||||
_config_key: str = "plugin.%s"
|
||||
|
||||
def __init__(self):
|
||||
self.siteshelper = SitesHelper()
|
||||
self.init_config()
|
||||
|
||||
def init_config(self):
|
||||
@@ -37,6 +39,7 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
启动加载插件
|
||||
"""
|
||||
|
||||
# 扫描插件目录
|
||||
plugins = ModuleHelper.load(
|
||||
"app.plugins",
|
||||
@@ -80,8 +83,15 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
# 停止所有插件
|
||||
for plugin in self._running_plugins.values():
|
||||
if hasattr(plugin, "stop"):
|
||||
plugin.stop()
|
||||
# 关闭数据库
|
||||
if hasattr(plugin, "close"):
|
||||
plugin.close()
|
||||
# 关闭插件
|
||||
if hasattr(plugin, "stop_service"):
|
||||
plugin.stop_service()
|
||||
# 清空对像
|
||||
self._plugins = {}
|
||||
self._running_plugins = {}
|
||||
|
||||
def get_plugin_config(self, pid: str) -> dict:
|
||||
"""
|
||||
@@ -176,6 +186,8 @@ class PluginManager(metaclass=Singleton):
|
||||
# 已安装插件
|
||||
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
for pid, plugin in self._plugins.items():
|
||||
# 运行状插件
|
||||
plugin_obj = self._running_plugins.get(pid)
|
||||
# 基本属性
|
||||
conf = {}
|
||||
# ID
|
||||
@@ -186,11 +198,20 @@ class PluginManager(metaclass=Singleton):
|
||||
else:
|
||||
conf.update({"installed": False})
|
||||
# 运行状态
|
||||
if pid in self._running_plugins.keys() and hasattr(plugin, "get_state"):
|
||||
plugin_obj = self._running_plugins.get(pid)
|
||||
if plugin_obj and hasattr(plugin, "get_state"):
|
||||
conf.update({"state": plugin_obj.get_state()})
|
||||
else:
|
||||
conf.update({"state": False})
|
||||
# 是否有详情页面
|
||||
if hasattr(plugin, "get_page"):
|
||||
if ObjectUtils.check_method(plugin.get_page):
|
||||
conf.update({"has_page": True})
|
||||
else:
|
||||
conf.update({"has_page": False})
|
||||
# 权限
|
||||
if hasattr(plugin, "auth_level"):
|
||||
if self.siteshelper.auth_level < plugin.auth_level:
|
||||
continue
|
||||
# 名称
|
||||
if hasattr(plugin, "plugin_name"):
|
||||
conf.update({"plugin_name": plugin.plugin_name})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from sqlalchemy import create_engine, QueuePool
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.orm import sessionmaker, Session, scoped_session
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
@@ -8,11 +8,16 @@ Engine = create_engine(f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
poolclass=QueuePool,
|
||||
pool_size=1000,
|
||||
pool_recycle=60 * 10,
|
||||
max_overflow=0)
|
||||
# 数据库会话
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=Engine)
|
||||
pool_size=1024,
|
||||
pool_recycle=600,
|
||||
pool_timeout=180,
|
||||
max_overflow=0,
|
||||
connect_args={"timeout": 60})
|
||||
# 会话工厂
|
||||
SessionFactory = sessionmaker(autocommit=False, autoflush=False, bind=Engine)
|
||||
|
||||
# 多线程全局使用的数据库会话
|
||||
ScopedSession = scoped_session(SessionFactory)
|
||||
|
||||
|
||||
def get_db():
|
||||
@@ -22,7 +27,7 @@ def get_db():
|
||||
"""
|
||||
db = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
db = SessionFactory()
|
||||
yield db
|
||||
finally:
|
||||
if db:
|
||||
@@ -30,12 +35,10 @@ def get_db():
|
||||
|
||||
|
||||
class DbOper:
|
||||
|
||||
_db: Session = None
|
||||
|
||||
def __init__(self, _db=SessionLocal()):
|
||||
self._db = _db
|
||||
|
||||
def __del__(self):
|
||||
if self._db:
|
||||
self._db.close()
|
||||
def __init__(self, db: Session = None):
|
||||
if db:
|
||||
self._db = db
|
||||
else:
|
||||
self._db = ScopedSession()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
|
||||
|
||||
|
||||
class DownloadHistoryOper(DbOper):
|
||||
@@ -10,28 +10,81 @@ class DownloadHistoryOper(DbOper):
|
||||
下载历史管理
|
||||
"""
|
||||
|
||||
def get_by_path(self, path: Path) -> Any:
|
||||
def get_by_path(self, path: Path) -> DownloadHistory:
|
||||
"""
|
||||
按路径查询下载记录
|
||||
:param path: 数据key
|
||||
"""
|
||||
return DownloadHistory.get_by_path(self._db, path)
|
||||
return DownloadHistory.get_by_path(self._db, str(path))
|
||||
|
||||
def get_by_hash(self, download_hash: str) -> Any:
|
||||
def get_by_hash(self, download_hash: str) -> DownloadHistory:
|
||||
"""
|
||||
按Hash查询下载记录
|
||||
:param download_hash: 数据key
|
||||
"""
|
||||
return DownloadHistory.get_by_hash(self._db, download_hash)
|
||||
|
||||
def add(self, **kwargs):
|
||||
def add(self, **kwargs) -> DownloadHistory:
|
||||
"""
|
||||
新增下载历史
|
||||
"""
|
||||
downloadhistory = DownloadHistory(**kwargs)
|
||||
return downloadhistory.create(self._db)
|
||||
|
||||
def list_by_page(self, page: int = 1, count: int = 30):
|
||||
def add_files(self, file_items: List[dict]):
|
||||
"""
|
||||
新增下载历史文件
|
||||
"""
|
||||
for file_item in file_items:
|
||||
downloadfile = DownloadFiles(**file_item)
|
||||
downloadfile.create(self._db)
|
||||
|
||||
def truncate_files(self):
|
||||
"""
|
||||
清空下载历史文件记录
|
||||
"""
|
||||
DownloadFiles.truncate(self._db)
|
||||
|
||||
def get_files_by_hash(self, download_hash: str, state: int = None) -> List[DownloadFiles]:
|
||||
"""
|
||||
按Hash查询下载文件记录
|
||||
:param download_hash: 数据key
|
||||
:param state: 删除状态
|
||||
"""
|
||||
return DownloadFiles.get_by_hash(self._db, download_hash, state)
|
||||
|
||||
def get_file_by_fullpath(self, fullpath: str) -> DownloadFiles:
|
||||
"""
|
||||
按fullpath查询下载文件记录
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
return DownloadFiles.get_by_fullpath(self._db, fullpath)
|
||||
|
||||
def get_files_by_savepath(self, fullpath: str) -> List[DownloadFiles]:
|
||||
"""
|
||||
按savepath查询下载文件记录
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
return DownloadFiles.get_by_savepath(self._db, fullpath)
|
||||
|
||||
def delete_file_by_fullpath(self, fullpath: str):
|
||||
"""
|
||||
按fullpath删除下载文件记录
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
DownloadFiles.delete_by_fullpath(self._db, fullpath)
|
||||
|
||||
def get_hash_by_fullpath(self, fullpath: str) -> str:
|
||||
"""
|
||||
按fullpath查询下载文件记录hash
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath)
|
||||
if fileinfo:
|
||||
return fileinfo.download_hash
|
||||
return ""
|
||||
|
||||
def list_by_page(self, page: int = 1, count: int = 30) -> List[DownloadHistory]:
|
||||
"""
|
||||
分页查询下载历史
|
||||
"""
|
||||
@@ -42,3 +95,24 @@ class DownloadHistoryOper(DbOper):
|
||||
清空下载记录
|
||||
"""
|
||||
DownloadHistory.truncate(self._db)
|
||||
|
||||
def get_last_by(self, mtype=None, title: str = None, year: str = None,
|
||||
season: str = None, episode: str = None, tmdbid=None) -> List[DownloadHistory]:
|
||||
"""
|
||||
按类型、标题、年份、季集查询下载记录
|
||||
"""
|
||||
return DownloadHistory.get_last_by(db=self._db,
|
||||
mtype=mtype,
|
||||
title=title,
|
||||
year=year,
|
||||
season=season,
|
||||
episode=episode,
|
||||
tmdbid=tmdbid)
|
||||
|
||||
def list_by_user_date(self, date: str, userid: str = None) -> List[DownloadHistory]:
|
||||
"""
|
||||
查询某用户某时间之后的下载历史
|
||||
"""
|
||||
return DownloadHistory.list_by_user_date(db=self._db,
|
||||
date=date,
|
||||
userid=userid)
|
||||
|
||||
@@ -6,7 +6,7 @@ from alembic.config import Config
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.db import Engine, SessionLocal
|
||||
from app.db import Engine, SessionFactory
|
||||
from app.db.models import Base
|
||||
from app.db.models.user import User
|
||||
from app.log import logger
|
||||
@@ -22,15 +22,16 @@ def init_db():
|
||||
# 全量建表
|
||||
Base.metadata.create_all(bind=Engine)
|
||||
# 初始化超级管理员
|
||||
_db = SessionLocal()
|
||||
user = User.get_by_name(db=_db, name=settings.SUPERUSER)
|
||||
db = SessionFactory()
|
||||
user = User.get_by_name(db=db, name=settings.SUPERUSER)
|
||||
if not user:
|
||||
user = User(
|
||||
name=settings.SUPERUSER,
|
||||
hashed_password=get_password_hash(settings.SUPERUSER_PASSWORD),
|
||||
is_superuser=True,
|
||||
)
|
||||
user.create(_db)
|
||||
user.create(db)
|
||||
db.close()
|
||||
|
||||
|
||||
def update_db():
|
||||
@@ -38,7 +39,7 @@ def update_db():
|
||||
更新数据库
|
||||
"""
|
||||
db_location = settings.CONFIG_PATH / 'user.db'
|
||||
script_location = settings.ROOT_PATH / 'alembic'
|
||||
script_location = settings.ROOT_PATH / 'database'
|
||||
try:
|
||||
alembic_cfg = Config()
|
||||
alembic_cfg.set_main_option('script_location', str(script_location))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.db import DbOper, SessionLocal
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models.mediaserver import MediaServerItem
|
||||
|
||||
|
||||
@@ -10,7 +12,7 @@ class MediaServerOper(DbOper):
|
||||
媒体服务器数据管理
|
||||
"""
|
||||
|
||||
def __init__(self, db=SessionLocal()):
|
||||
def __init__(self, db: Session = None):
|
||||
super().__init__(db)
|
||||
|
||||
def add(self, **kwargs) -> bool:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any
|
||||
from typing import Any, Self, List
|
||||
|
||||
from sqlalchemy.orm import as_declarative, declared_attr
|
||||
from sqlalchemy.orm import as_declarative, declared_attr, Session
|
||||
|
||||
|
||||
@as_declarative()
|
||||
@@ -8,33 +8,41 @@ class Base:
|
||||
id: Any
|
||||
__name__: str
|
||||
|
||||
def create(self, db):
|
||||
@staticmethod
|
||||
def commit(db: Session):
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as err:
|
||||
db.rollback()
|
||||
raise err
|
||||
|
||||
def create(self, db: Session) -> Self:
|
||||
db.add(self)
|
||||
db.commit()
|
||||
self.commit(db)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def get(cls, db, rid: int):
|
||||
def get(cls, db: Session, rid: int) -> Self:
|
||||
return db.query(cls).filter(cls.id == rid).first()
|
||||
|
||||
def update(self, db, payload: dict):
|
||||
def update(self, db: Session, payload: dict):
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
for key, value in payload.items():
|
||||
setattr(self, key, value)
|
||||
db.commit()
|
||||
Base.commit(db)
|
||||
|
||||
@classmethod
|
||||
def delete(cls, db, rid):
|
||||
def delete(cls, db: Session, rid):
|
||||
db.query(cls).filter(cls.id == rid).delete()
|
||||
db.commit()
|
||||
Base.commit(db)
|
||||
|
||||
@classmethod
|
||||
def truncate(cls, db):
|
||||
def truncate(cls, db: Session):
|
||||
db.query(cls).delete()
|
||||
db.commit()
|
||||
Base.commit(db)
|
||||
|
||||
@classmethod
|
||||
def list(cls, db):
|
||||
def list(cls, db: Session) -> List[Self]:
|
||||
return db.query(cls).all()
|
||||
|
||||
def to_dict(self):
|
||||
|
||||
@@ -35,6 +35,12 @@ class DownloadHistory(Base):
|
||||
torrent_description = Column(String)
|
||||
# 种子站点
|
||||
torrent_site = Column(String)
|
||||
# 下载用户
|
||||
userid = Column(String)
|
||||
# 下载渠道
|
||||
channel = Column(String)
|
||||
# 创建时间
|
||||
date = Column(String)
|
||||
# 附加信息
|
||||
note = Column(String)
|
||||
|
||||
@@ -49,3 +55,104 @@ class DownloadHistory(Base):
|
||||
@staticmethod
|
||||
def get_by_path(db: Session, path: str):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.path == path).first()
|
||||
|
||||
@staticmethod
|
||||
def get_last_by(db: Session, mtype: str = None, title: str = None, year: int = None, season: str = None,
|
||||
episode: str = None, tmdbid: int = None):
|
||||
"""
|
||||
据tmdbid、season、season_episode查询转移记录
|
||||
"""
|
||||
if tmdbid and not season and not episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
if tmdbid and season and not episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
if tmdbid and season and episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧所有季集|电影
|
||||
if not season and not episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季
|
||||
if season and not episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
def list_by_user_date(db: Session, date: str, userid: str = None):
|
||||
"""
|
||||
查询某用户某时间之后的下载历史
|
||||
"""
|
||||
if userid:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date < date,
|
||||
DownloadHistory.userid == userid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
|
||||
class DownloadFiles(Base):
|
||||
"""
|
||||
下载文件记录
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
# 下载任务Hash
|
||||
download_hash = Column(String, index=True)
|
||||
# 下载器
|
||||
downloader = Column(String)
|
||||
# 完整路径
|
||||
fullpath = Column(String, index=True)
|
||||
# 保存路径
|
||||
savepath = Column(String, index=True)
|
||||
# 文件相对路径/名称
|
||||
filepath = Column(String)
|
||||
# 种子名称
|
||||
torrentname = Column(String)
|
||||
# 状态 0-已删除 1-正常
|
||||
state = Column(Integer, nullable=False, default=1)
|
||||
|
||||
@staticmethod
|
||||
def get_by_hash(db: Session, download_hash: str, state: int = None):
|
||||
if state:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash,
|
||||
DownloadFiles.state == state).all()
|
||||
else:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash).all()
|
||||
|
||||
@staticmethod
|
||||
def get_by_fullpath(db: Session, fullpath: str):
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
||||
DownloadFiles.id.desc()).first()
|
||||
|
||||
@staticmethod
|
||||
def get_by_savepath(db: Session, savepath: str):
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
|
||||
|
||||
@staticmethod
|
||||
def delete_by_fullpath(db: Session, fullpath: str):
|
||||
db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath,
|
||||
DownloadFiles.state == 1).update(
|
||||
{
|
||||
"state": 0
|
||||
}
|
||||
)
|
||||
Base.commit(db)
|
||||
|
||||
@@ -47,7 +47,7 @@ class MediaServerItem(Base):
|
||||
@staticmethod
|
||||
def empty(db: Session, server: str):
|
||||
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
|
||||
db.commit()
|
||||
Base.commit(db)
|
||||
|
||||
@staticmethod
|
||||
def exist_by_tmdbid(db: Session, tmdbid: int, mtype: str):
|
||||
|
||||
@@ -23,4 +23,9 @@ class PluginData(Base):
|
||||
|
||||
@staticmethod
|
||||
def del_plugin_data_by_key(db: Session, plugin_id: str, key: str):
|
||||
return 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()
|
||||
Base.commit(db)
|
||||
|
||||
@staticmethod
|
||||
def get_plugin_data_by_plugin_id(db: Session, plugin_id: str):
|
||||
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Base
|
||||
|
||||
|
||||
class Rss(Base):
|
||||
"""
|
||||
RSS订阅
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
# 名称
|
||||
name = Column(String, nullable=False)
|
||||
# RSS地址
|
||||
url = Column(String, nullable=False)
|
||||
# 类型
|
||||
type = Column(String)
|
||||
# 标题
|
||||
title = Column(String)
|
||||
# 年份
|
||||
year = Column(String)
|
||||
# TMDBID
|
||||
tmdbid = Column(Integer, index=True)
|
||||
# 季号
|
||||
season = Column(Integer)
|
||||
# 海报
|
||||
poster = Column(String)
|
||||
# 背景图
|
||||
backdrop = Column(String)
|
||||
# 评分
|
||||
vote = Column(Integer)
|
||||
# 简介
|
||||
description = Column(String)
|
||||
# 总集数
|
||||
total_episode = Column(Integer)
|
||||
# 包含
|
||||
include = Column(String)
|
||||
# 排除
|
||||
exclude = Column(String)
|
||||
# 洗版
|
||||
best_version = Column(Integer)
|
||||
# 是否使用代理服务器
|
||||
proxy = Column(Integer)
|
||||
# 是否使用过滤规则
|
||||
filter = Column(Integer)
|
||||
# 保存路径
|
||||
save_path = Column(String)
|
||||
# 已处理数量
|
||||
processed = Column(Integer)
|
||||
# 附加信息,已处理数据
|
||||
note = Column(String)
|
||||
# 最后更新时间
|
||||
last_update = Column(String)
|
||||
# 状态 0-停用,1-启用
|
||||
state = Column(Integer, default=1)
|
||||
|
||||
@staticmethod
|
||||
def get_by_tmdbid(db: Session, tmdbid: int, season: int = None):
|
||||
if season:
|
||||
return db.query(Rss).filter(Rss.tmdbid == tmdbid,
|
||||
Rss.season == season).all()
|
||||
return db.query(Rss).filter(Rss.tmdbid == tmdbid).all()
|
||||
|
||||
@staticmethod
|
||||
def get_by_title(db: Session, title: str):
|
||||
return db.query(Rss).filter(Rss.title == title).first()
|
||||
@@ -61,4 +61,4 @@ class Site(Base):
|
||||
@staticmethod
|
||||
def reset(db: Session):
|
||||
db.query(Site).delete()
|
||||
db.commit()
|
||||
Base.commit(db)
|
||||
|
||||
@@ -49,6 +49,8 @@ class Subscribe(Base):
|
||||
state = Column(String, nullable=False, index=True, default='N')
|
||||
# 最后更新时间
|
||||
last_update = Column(String)
|
||||
# 创建时间
|
||||
date = Column(String)
|
||||
# 订阅用户
|
||||
username = Column(String)
|
||||
# 订阅站点
|
||||
|
||||
@@ -25,7 +25,7 @@ class TransferHistory(Base):
|
||||
title = Column(String, index=True)
|
||||
# 年份
|
||||
year = Column(String)
|
||||
tmdbid = Column(Integer)
|
||||
tmdbid = Column(Integer, index=True)
|
||||
imdbid = Column(String)
|
||||
tvdbid = Column(Integer)
|
||||
doubanid = Column(String)
|
||||
@@ -65,6 +65,10 @@ class TransferHistory(Base):
|
||||
def get_by_src(db: Session, src: str):
|
||||
return db.query(TransferHistory).filter(TransferHistory.src == src).first()
|
||||
|
||||
@staticmethod
|
||||
def list_by_hash(db: Session, download_hash: str):
|
||||
return db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).all()
|
||||
|
||||
@staticmethod
|
||||
def statistic(db: Session, days: int = 7):
|
||||
"""
|
||||
@@ -85,35 +89,75 @@ class TransferHistory(Base):
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%')).first()[0]
|
||||
|
||||
@staticmethod
|
||||
def list_by(db: Session, mtype: str = None, title: str = None, year: int = None, season: str = None,
|
||||
episode: str = None, tmdbid: str = None):
|
||||
def list_by(db: Session, mtype: str = None, title: str = None, year: str = None, season: str = None,
|
||||
episode: str = None, tmdbid: int = None, dest: str = None):
|
||||
"""
|
||||
据tmdbid、season、season_episode查询转移记录
|
||||
tmdbid + mtype 或 title + year 必输
|
||||
"""
|
||||
if tmdbid and not season and not episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid).all()
|
||||
if tmdbid and season and not episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.seasons == season).all()
|
||||
if tmdbid and season and episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode).all()
|
||||
# 电视剧所有季集|电影
|
||||
if not season and not episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.type == mtype,
|
||||
TransferHistory.title == title,
|
||||
TransferHistory.year == year).all()
|
||||
# 电视剧某季
|
||||
if season and not episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.type == mtype,
|
||||
TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season).all()
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.type == mtype,
|
||||
TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode).all()
|
||||
# TMDBID + 类型
|
||||
if tmdbid and mtype:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season).all()
|
||||
else:
|
||||
if dest:
|
||||
# 电影
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.dest == dest).all()
|
||||
else:
|
||||
# 电视剧所有季集
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype).all()
|
||||
# 标题 + 年份
|
||||
elif title and year:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season).all()
|
||||
else:
|
||||
if dest:
|
||||
# 电影
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.dest == dest).all()
|
||||
else:
|
||||
# 电视剧所有季集
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year).all()
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_by_type_tmdbid(db: Session, mtype: str = None, tmdbid: int = None):
|
||||
"""
|
||||
据tmdbid、type查询转移记录
|
||||
"""
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype).first()
|
||||
|
||||
@staticmethod
|
||||
def update_download_hash(db: Session, historyid: int = None, download_hash: str = None):
|
||||
db.query(TransferHistory).filter(TransferHistory.id == historyid).update(
|
||||
{
|
||||
"download_hash": download_hash
|
||||
}
|
||||
)
|
||||
Base.commit(db)
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
from typing import Any
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models import Base
|
||||
from app.db.models.plugin import PluginData
|
||||
from app.utils.object import ObjectUtils
|
||||
|
||||
@@ -12,7 +11,7 @@ class PluginDataOper(DbOper):
|
||||
插件数据管理
|
||||
"""
|
||||
|
||||
def save(self, plugin_id: str, key: str, value: Any) -> Base:
|
||||
def save(self, plugin_id: str, key: str, value: Any) -> PluginData:
|
||||
"""
|
||||
保存插件数据
|
||||
:param plugin_id: 插件id
|
||||
@@ -57,3 +56,10 @@ class PluginDataOper(DbOper):
|
||||
清空插件数据
|
||||
"""
|
||||
PluginData.truncate(self._db)
|
||||
|
||||
def get_data_all(self, plugin_id: str) -> Any:
|
||||
"""
|
||||
获取插件所有数据
|
||||
:param plugin_id: 插件id
|
||||
"""
|
||||
return PluginData.get_plugin_data_by_plugin_id(self._db, plugin_id)
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
from typing import List
|
||||
|
||||
from app.db import DbOper, SessionLocal
|
||||
from app.db.models.rss import Rss
|
||||
|
||||
|
||||
class RssOper(DbOper):
|
||||
"""
|
||||
RSS订阅数据管理
|
||||
"""
|
||||
|
||||
def __init__(self, db=SessionLocal()):
|
||||
super().__init__(db)
|
||||
|
||||
def add(self, **kwargs) -> bool:
|
||||
"""
|
||||
新增RSS订阅
|
||||
"""
|
||||
item = Rss(**kwargs)
|
||||
item.create(self._db)
|
||||
return True
|
||||
|
||||
def exists(self, tmdbid: int, season: int = None):
|
||||
"""
|
||||
判断是否存在
|
||||
"""
|
||||
return Rss.get_by_tmdbid(self._db, tmdbid, season)
|
||||
|
||||
def list(self, rssid: int = None) -> List[Rss]:
|
||||
"""
|
||||
查询所有RSS订阅
|
||||
"""
|
||||
if rssid:
|
||||
return [Rss.get(self._db, rssid)]
|
||||
return Rss.list(self._db)
|
||||
|
||||
def delete(self, rssid: int) -> bool:
|
||||
"""
|
||||
删除RSS订阅
|
||||
"""
|
||||
item = Rss.get(self._db, rssid)
|
||||
if item:
|
||||
item.delete(self._db)
|
||||
return True
|
||||
return False
|
||||
|
||||
def update(self, rssid: int, **kwargs) -> bool:
|
||||
"""
|
||||
更新RSS订阅
|
||||
"""
|
||||
item = Rss.get(self._db, rssid)
|
||||
if item:
|
||||
item.update(self._db, kwargs)
|
||||
return True
|
||||
return False
|
||||
@@ -19,7 +19,7 @@ class SiteOper(DbOper):
|
||||
return True, "新增站点成功"
|
||||
return False, "站点已存在"
|
||||
|
||||
def get(self, sid: int):
|
||||
def get(self, sid: int) -> Site:
|
||||
"""
|
||||
查询单个站点
|
||||
"""
|
||||
@@ -31,7 +31,7 @@ class SiteOper(DbOper):
|
||||
"""
|
||||
return Site.list(self._db)
|
||||
|
||||
def list_active(self):
|
||||
def list_active(self) -> List[Site]:
|
||||
"""
|
||||
按状态获取站点列表
|
||||
"""
|
||||
@@ -41,9 +41,9 @@ class SiteOper(DbOper):
|
||||
"""
|
||||
删除站点
|
||||
"""
|
||||
return Site.delete(self._db, sid)
|
||||
Site.delete(self._db, sid)
|
||||
|
||||
def update(self, sid: int, payload: dict):
|
||||
def update(self, sid: int, payload: dict) -> Site:
|
||||
"""
|
||||
更新站点
|
||||
"""
|
||||
@@ -74,3 +74,15 @@ class SiteOper(DbOper):
|
||||
"cookie": cookies
|
||||
})
|
||||
return True, "更新站点Cookie成功"
|
||||
|
||||
def update_rss(self, domain: str, rss: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
更新站点rss
|
||||
"""
|
||||
site = Site.get_by_domain(self._db, domain)
|
||||
if not site:
|
||||
return False, "站点不存在"
|
||||
site.update(self._db, {
|
||||
"rss": rss
|
||||
})
|
||||
return True, "更新站点RSS地址成功"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import time
|
||||
from typing import Tuple, List
|
||||
|
||||
from app.core.context import MediaInfo
|
||||
@@ -26,12 +27,22 @@ class SubscribeOper(DbOper):
|
||||
backdrop=mediainfo.get_backdrop_image(),
|
||||
vote=mediainfo.vote_average,
|
||||
description=mediainfo.overview,
|
||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
**kwargs)
|
||||
subscribe.create(self._db)
|
||||
return subscribe.id, "新增订阅成功"
|
||||
else:
|
||||
return subscribe.id, "订阅已存在"
|
||||
|
||||
def exists(self, tmdbid: int, season: int) -> bool:
|
||||
"""
|
||||
判断是否存在
|
||||
"""
|
||||
if season:
|
||||
return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False
|
||||
else:
|
||||
return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False
|
||||
|
||||
def get(self, sid: int) -> Subscribe:
|
||||
"""
|
||||
获取订阅
|
||||
@@ -52,7 +63,7 @@ class SubscribeOper(DbOper):
|
||||
"""
|
||||
Subscribe.delete(self._db, rid=sid)
|
||||
|
||||
def update(self, sid: int, payload: dict):
|
||||
def update(self, sid: int, payload: dict) -> Subscribe:
|
||||
"""
|
||||
更新订阅
|
||||
"""
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import json
|
||||
from typing import Any, Union
|
||||
|
||||
from app.db import DbOper, SessionLocal
|
||||
from app.db import DbOper, SessionFactory
|
||||
from app.db.models.systemconfig import SystemConfig
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
# 配置对象
|
||||
__SYSTEMCONF: dict = {}
|
||||
|
||||
def __init__(self, _db=SessionLocal()):
|
||||
def __init__(self):
|
||||
"""
|
||||
加载配置到内存
|
||||
"""
|
||||
super().__init__(_db)
|
||||
self._db = SessionFactory()
|
||||
super().__init__(self._db)
|
||||
for item in SystemConfig.list(self._db):
|
||||
if ObjectUtils.is_obj(item.value):
|
||||
self.__SYSTEMCONF[item.key] = json.loads(item.value)
|
||||
@@ -33,18 +34,20 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
self.__SYSTEMCONF[key] = value
|
||||
# 写入数据库
|
||||
if ObjectUtils.is_obj(value):
|
||||
if value is not None:
|
||||
value = json.dumps(value)
|
||||
else:
|
||||
value = ''
|
||||
value = json.dumps(value)
|
||||
elif value is None:
|
||||
value = ''
|
||||
conf = SystemConfig.get_by_key(self._db, key)
|
||||
if conf:
|
||||
conf.update(self._db, {"value": value})
|
||||
if value:
|
||||
conf.update(self._db, {"value": value})
|
||||
else:
|
||||
conf.delete(self._db, conf.id)
|
||||
else:
|
||||
conf = SystemConfig(key=key, value=value)
|
||||
conf.create(self._db)
|
||||
|
||||
def get(self, key: Union[str, SystemConfigKey] = None):
|
||||
def get(self, key: Union[str, SystemConfigKey] = None) -> Any:
|
||||
"""
|
||||
获取系统设置
|
||||
"""
|
||||
@@ -53,3 +56,7 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
if not key:
|
||||
return self.__SYSTEMCONF
|
||||
return self.__SYSTEMCONF.get(key)
|
||||
|
||||
def __del__(self):
|
||||
if self._db:
|
||||
self._db.close()
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.db import DbOper
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas import TransferInfo
|
||||
|
||||
|
||||
class TransferHistoryOper(DbOper):
|
||||
@@ -10,52 +15,71 @@ class TransferHistoryOper(DbOper):
|
||||
转移历史管理
|
||||
"""
|
||||
|
||||
def get_by_title(self, title: str) -> Any:
|
||||
def get(self, historyid: int) -> TransferHistory:
|
||||
"""
|
||||
获取转移历史
|
||||
:param historyid: 转移历史id
|
||||
"""
|
||||
return TransferHistory.get(self._db, historyid)
|
||||
|
||||
def get_by_title(self, title: str) -> List[TransferHistory]:
|
||||
"""
|
||||
按标题查询转移记录
|
||||
:param title: 数据key
|
||||
"""
|
||||
return TransferHistory.list_by_title(self._db, title)
|
||||
|
||||
def get_by_src(self, src: str) -> Any:
|
||||
def get_by_src(self, src: str) -> TransferHistory:
|
||||
"""
|
||||
按源查询转移记录
|
||||
:param src: 数据key
|
||||
"""
|
||||
return TransferHistory.get_by_src(self._db, src)
|
||||
|
||||
def add(self, **kwargs):
|
||||
def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
|
||||
"""
|
||||
按种子hash查询转移记录
|
||||
:param download_hash: 种子hash
|
||||
"""
|
||||
return TransferHistory.list_by_hash(self._db, download_hash)
|
||||
|
||||
def add(self, **kwargs) -> TransferHistory:
|
||||
"""
|
||||
新增转移历史
|
||||
"""
|
||||
if kwargs.get("download_hash"):
|
||||
transferhistory = TransferHistory.get_by_hash(self._db, kwargs.get("download_hash"))
|
||||
if transferhistory:
|
||||
transferhistory.delete(self._db, transferhistory.id)
|
||||
kwargs.update({
|
||||
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
})
|
||||
return TransferHistory(**kwargs).create(self._db)
|
||||
|
||||
def statistic(self, days: int = 7):
|
||||
def statistic(self, days: int = 7) -> List[Any]:
|
||||
"""
|
||||
统计最近days天的下载历史数量
|
||||
"""
|
||||
return TransferHistory.statistic(self._db, days)
|
||||
|
||||
def get_by(self, mtype: str = None, title: str = None, year: int = None,
|
||||
season: str = None, episode: str = None, tmdbid: str = None) -> Any:
|
||||
def get_by(self, title: str = None, year: str = None, mtype: str = None,
|
||||
season: str = None, episode: str = None, tmdbid: int = None, dest: str = None) -> List[TransferHistory]:
|
||||
"""
|
||||
按类型、标题、年份、季集查询转移记录
|
||||
"""
|
||||
return TransferHistory.list_by(db=self._db,
|
||||
mtype=mtype,
|
||||
title=title,
|
||||
dest=dest,
|
||||
year=year,
|
||||
season=season,
|
||||
episode=episode,
|
||||
tmdbid=tmdbid)
|
||||
|
||||
def get_by_type_tmdbid(self, mtype: str = None, tmdbid: int = None) -> TransferHistory:
|
||||
"""
|
||||
按类型、tmdb查询转移记录
|
||||
"""
|
||||
return TransferHistory.get_by_type_tmdbid(db=self._db,
|
||||
mtype=mtype,
|
||||
tmdbid=tmdbid)
|
||||
|
||||
def delete(self, historyid):
|
||||
"""
|
||||
删除转移记录
|
||||
@@ -67,3 +91,88 @@ class TransferHistoryOper(DbOper):
|
||||
清空转移记录
|
||||
"""
|
||||
TransferHistory.truncate(self._db)
|
||||
|
||||
def add_force(self, **kwargs) -> TransferHistory:
|
||||
"""
|
||||
新增转移历史,相同源目录的记录会被删除
|
||||
"""
|
||||
if kwargs.get("src"):
|
||||
transferhistory = TransferHistory.get_by_src(self._db, kwargs.get("src"))
|
||||
if transferhistory:
|
||||
transferhistory.delete(self._db, transferhistory.id)
|
||||
kwargs.update({
|
||||
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
})
|
||||
return TransferHistory(**kwargs).create(self._db)
|
||||
|
||||
def update_download_hash(self, historyid, download_hash):
|
||||
"""
|
||||
补充转移记录download_hash
|
||||
"""
|
||||
TransferHistory.update_download_hash(self._db, historyid, download_hash)
|
||||
|
||||
def add_success(self, src_path: Path, mode: str, meta: MetaBase,
|
||||
mediainfo: MediaInfo, transferinfo: TransferInfo,
|
||||
download_hash: str = None):
|
||||
"""
|
||||
新增转移成功历史记录
|
||||
"""
|
||||
self.add_force(
|
||||
src=str(src_path),
|
||||
dest=str(transferinfo.target_path),
|
||||
mode=mode,
|
||||
type=mediainfo.type.value,
|
||||
category=mediainfo.category,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
tvdbid=mediainfo.tvdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
image=mediainfo.get_poster_image(),
|
||||
download_hash=download_hash,
|
||||
status=1,
|
||||
files=json.dumps(transferinfo.file_list)
|
||||
)
|
||||
|
||||
def add_fail(self, src_path: Path, mode: str, meta: MetaBase, mediainfo: MediaInfo = None,
|
||||
transferinfo: TransferInfo = None, download_hash: str = None):
|
||||
"""
|
||||
新增转移失败历史记录
|
||||
"""
|
||||
if mediainfo and transferinfo:
|
||||
his = self.add_force(
|
||||
src=str(src_path),
|
||||
dest=str(transferinfo.target_path),
|
||||
mode=mode,
|
||||
type=mediainfo.type.value,
|
||||
category=mediainfo.category,
|
||||
title=mediainfo.title or meta.name,
|
||||
year=mediainfo.year or meta.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
tvdbid=mediainfo.tvdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
image=mediainfo.get_poster_image(),
|
||||
download_hash=download_hash,
|
||||
status=0,
|
||||
errmsg=transferinfo.message or '未知错误',
|
||||
files=json.dumps(transferinfo.file_list)
|
||||
)
|
||||
else:
|
||||
his = self.add_force(
|
||||
title=meta.name,
|
||||
year=meta.year,
|
||||
src=str(src_path),
|
||||
mode=mode,
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
download_hash=download_hash,
|
||||
status=0,
|
||||
errmsg="未识别到媒体信息"
|
||||
)
|
||||
return his
|
||||
|
||||
@@ -23,14 +23,17 @@ class CookieHelper:
|
||||
"password": [
|
||||
'//input[@name="password"]',
|
||||
'//input[@id="form_item_password"]',
|
||||
'//input[@id="password"]'
|
||||
'//input[@id="password"]',
|
||||
'//input[@type="password"]'
|
||||
],
|
||||
"captcha": [
|
||||
'//input[@name="imagestring"]',
|
||||
'//input[@name="captcha"]',
|
||||
'//input[@id="form_item_captcha"]'
|
||||
'//input[@id="form_item_captcha"]',
|
||||
'//input[@placeholder="驗證碼"]'
|
||||
],
|
||||
"captcha_img": [
|
||||
'//img[@alt="captcha"]/@src',
|
||||
'//img[@alt="CAPTCHA"]/@src',
|
||||
'//img[@alt="SECURITY CODE"]/@src',
|
||||
'//img[@id="LAY-user-get-vercode"]/@src',
|
||||
|
||||
@@ -2,12 +2,15 @@ from pyvirtualdisplay import Display
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class DisplayHelper(metaclass=Singleton):
|
||||
_display: Display = None
|
||||
|
||||
def __init__(self):
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
try:
|
||||
self._display = Display(visible=False, size=(1024, 768))
|
||||
self._display.start()
|
||||
|
||||
108
app/helper/format.py
Normal file
108
app/helper/format.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import re
|
||||
from typing import Tuple, Optional
|
||||
|
||||
import parse
|
||||
|
||||
|
||||
class FormatParser(object):
|
||||
_key = ""
|
||||
_split_chars = r"\.|\s+|\(|\)|\[|]|-|\+|【|】|/|~|;|&|\||#|_|「|」|~"
|
||||
|
||||
def __init__(self, eformat: str, details: str = None, part: str = None,
|
||||
offset: int = None, key: str = "ep"):
|
||||
"""
|
||||
:params eformat: 格式化字符串
|
||||
:params details: 格式化详情
|
||||
:params part: 分集
|
||||
:params offset: 偏移量
|
||||
:prams key: EP关键字
|
||||
"""
|
||||
self._format = eformat
|
||||
self._start_ep = None
|
||||
self._end_ep = None
|
||||
self._part = None
|
||||
if part:
|
||||
self._part = part
|
||||
if details:
|
||||
if re.compile("\\d{1,4}-\\d{1,4}").match(details):
|
||||
self._start_ep = details
|
||||
self._end_ep = details
|
||||
else:
|
||||
tmp = details.split(",")
|
||||
if len(tmp) > 1:
|
||||
self._start_ep = int(tmp[0])
|
||||
self._end_ep = int(tmp[0]) if int(tmp[0]) > int(tmp[1]) else int(tmp[1])
|
||||
else:
|
||||
self._start_ep = self._end_ep = int(tmp[0])
|
||||
self.__offset = int(offset) if offset else 0
|
||||
self._key = key
|
||||
|
||||
@property
|
||||
def format(self):
|
||||
return self._format
|
||||
|
||||
@property
|
||||
def start_ep(self):
|
||||
return self._start_ep
|
||||
|
||||
@property
|
||||
def end_ep(self):
|
||||
return self._end_ep
|
||||
|
||||
@property
|
||||
def part(self):
|
||||
return self._part
|
||||
|
||||
@property
|
||||
def offset(self):
|
||||
return self.__offset
|
||||
|
||||
def match(self, file: str) -> bool:
|
||||
if not self._format:
|
||||
return True
|
||||
s, e = self.__handle_single(file)
|
||||
if not s:
|
||||
return False
|
||||
if self._start_ep is None:
|
||||
return True
|
||||
if self._start_ep <= s <= self._end_ep:
|
||||
return True
|
||||
return False
|
||||
|
||||
def split_episode(self, file_name: str) -> Tuple[Optional[int], Optional[int], Optional[str]]:
|
||||
"""
|
||||
拆分集数,返回开始集数,结束集数,Part信息
|
||||
"""
|
||||
# 指定的具体集数,直接返回
|
||||
if self._start_ep is not None and self._start_ep == self._end_ep:
|
||||
if isinstance(self._start_ep, str):
|
||||
s, e = self._start_ep.split("-")
|
||||
if int(s) == int(e):
|
||||
return int(s) + self.__offset, None, self.part
|
||||
return int(s) + self.__offset, int(e) + self.__offset, self.part
|
||||
return self._start_ep + self.__offset, None, self.part
|
||||
if not self._format:
|
||||
return None, None, None
|
||||
s, e = self.__handle_single(file_name)
|
||||
return s + self.__offset if s is not None else None, \
|
||||
e + self.__offset if e is not None else None, self.part
|
||||
|
||||
def __handle_single(self, file: str) -> Tuple[Optional[int], Optional[int]]:
|
||||
"""
|
||||
处理单集,返回单集的开始和结束集数
|
||||
"""
|
||||
if not self._format:
|
||||
return None, None
|
||||
ret = parse.parse(self._format, file)
|
||||
if not ret or not ret.__contains__(self._key):
|
||||
return None, None
|
||||
episodes = ret.__getitem__(self._key)
|
||||
if not re.compile(r"^(EP)?(\d{1,4})(-(EP)?(\d{1,4}))?$", re.IGNORECASE).match(episodes):
|
||||
return None, None
|
||||
episode_splits = list(filter(lambda x: re.compile(r'[a-zA-Z]*\d{1,4}', re.IGNORECASE).match(x),
|
||||
re.split(r'%s' % self._split_chars, episodes)))
|
||||
if len(episode_splits) == 1:
|
||||
return int(re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub("", episode_splits[0])), None
|
||||
else:
|
||||
return int(re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub("", episode_splits[0])), int(
|
||||
re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub("", episode_splits[1]))
|
||||
@@ -1,5 +1,6 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class NfoReader:
|
||||
@@ -8,6 +9,9 @@ class NfoReader:
|
||||
self.tree = ET.parse(xml_file_path)
|
||||
self.root = self.tree.getroot()
|
||||
|
||||
def get_element_value(self, element_path):
|
||||
def get_element_value(self, element_path) -> Optional[str]:
|
||||
element = self.root.find(element_path)
|
||||
return element.text if element is not None else None
|
||||
|
||||
def get_elements(self, element_path) -> List[ET.Element]:
|
||||
return self.root.findall(element_path)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import base64
|
||||
|
||||
from app.core.config import settings
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class OcrHelper:
|
||||
|
||||
_ocr_b64_url = "https://nastool.org/captcha/base64"
|
||||
_ocr_b64_url = f"{settings.OCR_HOST}/captcha/base64"
|
||||
|
||||
def get_captcha_text(self, image_url=None, image_b64=None, cookie=None, ua=None):
|
||||
"""
|
||||
|
||||
@@ -1,16 +1,227 @@
|
||||
import xml.dom.minidom
|
||||
from typing import List
|
||||
from typing import List, Tuple, Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.browser import PlaywrightHelper
|
||||
from app.utils.dom import DomUtils
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class RssHelper:
|
||||
|
||||
"""
|
||||
RSS帮助类,解析RSS报文、获取RSS地址等
|
||||
"""
|
||||
# 各站点RSS链接获取配置
|
||||
rss_link_conf = {
|
||||
"default": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
}
|
||||
},
|
||||
"hares.top": {
|
||||
"xpath": "//*[@id='layui-layer100001']/div[2]/div/p[4]/a/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
}
|
||||
},
|
||||
"et8.org": {
|
||||
"xpath": "//*[@id='outer']/table/tbody/tr/td/table/tbody/tr/td/a[2]/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
}
|
||||
},
|
||||
"pttime.org": {
|
||||
"xpath": "//*[@id='outer']/table/tbody/tr/td/table/tbody/tr/td/text()[5]",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"showrows": 10,
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1
|
||||
}
|
||||
},
|
||||
"ourbits.club": {
|
||||
"xpath": "//a[@class='gen_rsslink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
}
|
||||
},
|
||||
"totheglory.im": {
|
||||
"xpath": "//textarea/text()",
|
||||
"url": "rsstools.php?c51=51&c52=52&c53=53&c54=54&c108=108&c109=109&c62=62&c63=63&c67=67&c69=69&c70=70&c73=73&c76=76&c75=75&c74=74&c87=87&c88=88&c99=99&c90=90&c58=58&c103=103&c101=101&c60=60",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
}
|
||||
},
|
||||
"monikadesign.uk": {
|
||||
"xpath": "//a/@href",
|
||||
"url": "rss",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
}
|
||||
},
|
||||
"zhuque.in": {
|
||||
"xpath": "//a/@href",
|
||||
"url": "user/rss",
|
||||
"render": True,
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
}
|
||||
},
|
||||
"hdchina.org": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
"rsscart": 0
|
||||
}
|
||||
},
|
||||
"audiences.me": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
"torrent_type": 1,
|
||||
"exp": 180
|
||||
}
|
||||
},
|
||||
"shadowflow.org": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"paid": 0,
|
||||
"search_mode": 0,
|
||||
"showrows": 30
|
||||
}
|
||||
},
|
||||
"hddolby.com": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
"exp": 180
|
||||
}
|
||||
},
|
||||
"hdhome.org": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
"exp": 180
|
||||
}
|
||||
},
|
||||
"pthome.net": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
"exp": 180
|
||||
}
|
||||
},
|
||||
"ptsbao.club": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
"size": 0
|
||||
}
|
||||
},
|
||||
"leaves.red": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 0,
|
||||
"paid": 2
|
||||
}
|
||||
},
|
||||
"hdtime.org": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 0,
|
||||
}
|
||||
},
|
||||
"m-team.io": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"showrows": 50,
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"https": 1
|
||||
}
|
||||
},
|
||||
"u2.dmhy.org": {
|
||||
"xpath": "//a[@class='faqlink']/@href",
|
||||
"url": "getrss.php",
|
||||
"params": {
|
||||
"inclbookmarked": 0,
|
||||
"itemsmalldescr": 1,
|
||||
"showrows": 50,
|
||||
"search_mode": 1,
|
||||
"inclautochecked": 1,
|
||||
"trackerssl": 1
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse(url, proxy: bool = False) -> List[dict]:
|
||||
def parse(url, proxy: bool = False) -> Union[List[dict], None]:
|
||||
"""
|
||||
解析RSS订阅URL,获取RSS中的种子信息
|
||||
:param url: RSS地址
|
||||
@@ -77,4 +288,61 @@ class RssHelper:
|
||||
continue
|
||||
except Exception as e2:
|
||||
print(str(e2))
|
||||
# RSS过期 观众RSS 链接已过期,您需要获得一个新的! pthome RSS Link has expired, You need to get a new one!
|
||||
_rss_expired_msg = [
|
||||
"RSS 链接已过期, 您需要获得一个新的!",
|
||||
"RSS Link has expired, You need to get a new one!",
|
||||
"RSS Link has expired, You need to get new!"
|
||||
]
|
||||
if ret_xml in _rss_expired_msg:
|
||||
return None
|
||||
return ret_array
|
||||
|
||||
def get_rss_link(self, url: str, cookie: str, ua: str, proxy: bool = False) -> Tuple[str, str]:
|
||||
"""
|
||||
获取站点rss地址
|
||||
:param url: 站点地址
|
||||
:param cookie: 站点cookie
|
||||
:param ua: 站点ua
|
||||
:param proxy: 是否使用代理
|
||||
:return: rss地址、错误信息
|
||||
"""
|
||||
try:
|
||||
# 获取站点域名
|
||||
domain = StringUtils.get_url_domain(url)
|
||||
# 获取配置
|
||||
site_conf = self.rss_link_conf.get(domain) or self.rss_link_conf.get("default")
|
||||
# RSS地址
|
||||
rss_url = urljoin(url, site_conf.get("url"))
|
||||
# RSS请求参数
|
||||
rss_params = site_conf.get("params")
|
||||
# 请求RSS页面
|
||||
if site_conf.get("render"):
|
||||
html_text = PlaywrightHelper().get_page_source(
|
||||
url=rss_url,
|
||||
cookies=cookie,
|
||||
ua=ua,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
)
|
||||
else:
|
||||
res = RequestUtils(
|
||||
cookies=cookie,
|
||||
timeout=60,
|
||||
ua=ua,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
).post_res(url=rss_url, data=rss_params)
|
||||
if res:
|
||||
html_text = res.text
|
||||
elif res is not None:
|
||||
return "", f"获取 {url} RSS链接失败,错误码:{res.status_code},错误原因:{res.reason}"
|
||||
else:
|
||||
return "", f"获取RSS链接失败:无法连接 {url} "
|
||||
# 解析HTML
|
||||
html = etree.HTML(html_text)
|
||||
if html:
|
||||
rss_link = html.xpath(site_conf.get("xpath"))
|
||||
if rss_link:
|
||||
return str(rss_link[-1]), ""
|
||||
return "", f"获取RSS链接失败:{url}"
|
||||
except Exception as e:
|
||||
return "", f"获取 {url} RSS链接失败:{str(e)}"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -130,21 +130,34 @@ class TorrentHelper:
|
||||
"""
|
||||
获取种子文件的文件夹名和文件清单
|
||||
:param torrent_path: 种子文件路径
|
||||
:return: 文件夹名、文件清单
|
||||
:return: 文件夹名、文件清单,单文件种子返回空文件夹名
|
||||
"""
|
||||
if not torrent_path or not torrent_path.exists():
|
||||
return "", []
|
||||
try:
|
||||
torrentinfo = Torrent.from_file(torrent_path)
|
||||
# 获取目录名
|
||||
folder_name = torrentinfo.name
|
||||
# 获取文件清单
|
||||
if len(torrentinfo.files) <= 1:
|
||||
if (not torrentinfo.files
|
||||
or (len(torrentinfo.files) == 1
|
||||
and torrentinfo.files[0].name == torrentinfo.name)):
|
||||
# 单文件种子目录名返回空
|
||||
folder_name = ""
|
||||
# 单文件种子
|
||||
file_list = [torrentinfo.name]
|
||||
else:
|
||||
file_list = [fileinfo.name for fileinfo in torrentinfo.files]
|
||||
logger.debug(f"{torrent_path.stem} -> 目录:{folder_name},文件清单:{file_list}")
|
||||
# 目录名
|
||||
folder_name = torrentinfo.name
|
||||
# 文件清单,如果一级目录与种子名相同则去掉
|
||||
file_list = []
|
||||
for fileinfo in torrentinfo.files:
|
||||
file_path = Path(fileinfo.name)
|
||||
# 根路径
|
||||
root_path = file_path.parts[0]
|
||||
if root_path == folder_name:
|
||||
file_list.append(str(file_path.relative_to(root_path)))
|
||||
else:
|
||||
file_list.append(fileinfo.name)
|
||||
logger.info(f"解析种子:{torrent_path.name} => 目录:{folder_name},文件清单:{file_list}")
|
||||
return folder_name, file_list
|
||||
except Exception as err:
|
||||
logger.error(f"种子文件解析失败:{err}")
|
||||
@@ -188,7 +201,12 @@ class TorrentHelper:
|
||||
# 季数
|
||||
_season_len = str(len(_meta.season_list)).rjust(2, '0')
|
||||
# 集数
|
||||
_episode_len = str(9999 - len(_meta.episode_list)).rjust(4, '0')
|
||||
if not _meta.episode_list:
|
||||
# 无集数的排最前面
|
||||
_episode_len = "9999"
|
||||
else:
|
||||
# 集数越多的排越前面
|
||||
_episode_len = str(len(_meta.episode_list)).rjust(4, '0')
|
||||
# 优先规则
|
||||
priority = self.system_config.get(SystemConfigKey.TorrentsPriority)
|
||||
if priority != "site":
|
||||
@@ -249,9 +267,11 @@ class TorrentHelper:
|
||||
for file in files:
|
||||
if not file:
|
||||
continue
|
||||
if Path(file).suffix not in settings.RMT_MEDIAEXT:
|
||||
file_path = Path(file)
|
||||
if file_path.suffix not in settings.RMT_MEDIAEXT:
|
||||
continue
|
||||
meta = MetaInfo(file)
|
||||
# 只使用文件名识别
|
||||
meta = MetaInfo(file_path.stem)
|
||||
if not meta.begin_episode:
|
||||
continue
|
||||
episodes = list(set(episodes).union(set(meta.episode_list)))
|
||||
|
||||
29
app/log.py
29
app/log.py
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
import click
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# logger
|
||||
@@ -21,12 +23,31 @@ file_handler = RotatingFileHandler(filename=settings.LOG_PATH / 'moviepilot.log'
|
||||
backupCount=3,
|
||||
encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
level_name_colors = {
|
||||
logging.DEBUG: lambda level_name: click.style(str(level_name), fg="cyan"),
|
||||
logging.INFO: lambda level_name: click.style(str(level_name), fg="green"),
|
||||
logging.WARNING: lambda level_name: click.style(str(level_name), fg="yellow"),
|
||||
logging.ERROR: lambda level_name: click.style(str(level_name), fg="red"),
|
||||
logging.CRITICAL: lambda level_name: click.style(
|
||||
str(level_name), fg="bright_red"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# 定义日志输出格式
|
||||
formatter = logging.Formatter("%(asctime)s - %(filename)s -【%(levelname)s】%(message)s")
|
||||
console_handler.setFormatter(formatter)
|
||||
file_handler.setFormatter(formatter)
|
||||
class CustomFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
seperator = " " * (8 - len(record.levelname))
|
||||
record.leveltext = level_name_colors[record.levelno](record.levelname + ":") + seperator
|
||||
return super().format(record)
|
||||
|
||||
# 将Handler添加到Logger
|
||||
|
||||
# 终端日志
|
||||
console_formatter = CustomFormatter("%(leveltext)s%(filename)s - %(message)s")
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 文件日志
|
||||
file_formater = CustomFormatter("【%(levelname)s】%(asctime)s - %(filename)s - %(message)s")
|
||||
file_handler.setFormatter(file_formater)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
@@ -68,6 +68,8 @@ def start_module():
|
||||
"""
|
||||
# 虚伪显示
|
||||
DisplayHelper()
|
||||
# 站点管理
|
||||
SitesHelper()
|
||||
# 加载模块
|
||||
ModuleManager()
|
||||
# 加载插件
|
||||
@@ -76,8 +78,6 @@ def start_module():
|
||||
Scheduler()
|
||||
# 启动事件消费
|
||||
Command()
|
||||
# 站点管理
|
||||
SitesHelper()
|
||||
# 初始化路由
|
||||
init_routers()
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ def checkMessage(channel_type: MessageChannel):
|
||||
return None
|
||||
if channel_type == MessageChannel.Slack and not switch.get("slack"):
|
||||
return None
|
||||
if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"):
|
||||
return None
|
||||
return func(self, message, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
@@ -10,11 +11,11 @@ from app.modules import _ModuleBase
|
||||
from app.modules.douban.apiv2 import DoubanApi
|
||||
from app.modules.douban.scraper import DoubanScraper
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.common import retry
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class DoubanModule(_ModuleBase):
|
||||
|
||||
doubanapi: DoubanApi = None
|
||||
scraper: DoubanScraper = None
|
||||
|
||||
@@ -34,6 +35,271 @@ class DoubanModule(_ModuleBase):
|
||||
:param doubanid: 豆瓣ID
|
||||
:return: 豆瓣信息
|
||||
"""
|
||||
"""
|
||||
{
|
||||
"rating": {
|
||||
"count": 287365,
|
||||
"max": 10,
|
||||
"star_count": 3.5,
|
||||
"value": 6.6
|
||||
},
|
||||
"lineticket_url": "",
|
||||
"controversy_reason": "",
|
||||
"pubdate": [
|
||||
"2021-10-29(中国大陆)"
|
||||
],
|
||||
"last_episode_number": null,
|
||||
"interest_control_info": null,
|
||||
"pic": {
|
||||
"large": "https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp",
|
||||
"normal": "https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2707553644.webp"
|
||||
},
|
||||
"vendor_count": 6,
|
||||
"body_bg_color": "f4f5f9",
|
||||
"is_tv": false,
|
||||
"head_info": null,
|
||||
"album_no_interact": false,
|
||||
"ticket_price_info": "",
|
||||
"webisode_count": 0,
|
||||
"year": "2021",
|
||||
"card_subtitle": "2021 / 英国 美国 / 动作 惊悚 冒险 / 凯瑞·福永 / 丹尼尔·克雷格 蕾雅·赛杜",
|
||||
"forum_info": null,
|
||||
"webisode": null,
|
||||
"id": "20276229",
|
||||
"gallery_topic_count": 0,
|
||||
"languages": [
|
||||
"英语",
|
||||
"法语",
|
||||
"意大利语",
|
||||
"俄语",
|
||||
"西班牙语"
|
||||
],
|
||||
"genres": [
|
||||
"动作",
|
||||
"惊悚",
|
||||
"冒险"
|
||||
],
|
||||
"review_count": 926,
|
||||
"title": "007:无暇赴死",
|
||||
"intro": "世界局势波诡云谲,再度出山的邦德(丹尼尔·克雷格 饰)面临有史以来空前的危机,传奇特工007的故事在本片中达到高潮。新老角色集结亮相,蕾雅·赛杜回归,二度饰演邦女郎玛德琳。系列最恐怖反派萨芬(拉米·马雷克 饰)重磅登场,毫不留情地展示了自己狠辣的一面,不仅揭开了玛德琳身上隐藏的秘密,还酝酿着危及数百万人性命的阴谋,幽灵党的身影也似乎再次浮出水面。半路杀出的新00号特工(拉什纳·林奇 饰)与神秘女子(安娜·德·阿玛斯 饰)看似与邦德同阵作战,但其真实目的依然成谜。关乎邦德生死的新仇旧怨接踵而至,暗潮汹涌之下他能否拯救世界?",
|
||||
"interest_cmt_earlier_tip_title": "发布于上映前",
|
||||
"has_linewatch": true,
|
||||
"ugc_tabs": [
|
||||
{
|
||||
"source": "reviews",
|
||||
"type": "review",
|
||||
"title": "影评"
|
||||
},
|
||||
{
|
||||
"source": "forum_topics",
|
||||
"type": "forum",
|
||||
"title": "讨论"
|
||||
}
|
||||
],
|
||||
"forum_topic_count": 857,
|
||||
"ticket_promo_text": "",
|
||||
"webview_info": {},
|
||||
"is_released": true,
|
||||
"actors": [
|
||||
{
|
||||
"name": "丹尼尔·克雷格",
|
||||
"roles": [
|
||||
"演员",
|
||||
"制片人",
|
||||
"配音"
|
||||
],
|
||||
"title": "丹尼尔·克雷格(同名)英国,英格兰,柴郡,切斯特影视演员",
|
||||
"url": "https://movie.douban.com/celebrity/1025175/",
|
||||
"user": null,
|
||||
"character": "饰 詹姆斯·邦德 James Bond 007",
|
||||
"uri": "douban://douban.com/celebrity/1025175?subject_id=27230907",
|
||||
"avatar": {
|
||||
"large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/600/h/3000/format/webp",
|
||||
"normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/200/h/300/format/webp"
|
||||
},
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1025175/",
|
||||
"type": "celebrity",
|
||||
"id": "1025175",
|
||||
"latin_name": "Daniel Craig"
|
||||
}
|
||||
],
|
||||
"interest": null,
|
||||
"vendor_icons": [
|
||||
"https://img9.doubanio.com/f/frodo/fbc90f355fc45d5d2056e0d88c697f9414b56b44/pics/vendors/tencent.png",
|
||||
"https://img2.doubanio.com/f/frodo/8286b9b5240f35c7e59e1b1768cd2ccf0467cde5/pics/vendors/migu_video.png",
|
||||
"https://img9.doubanio.com/f/frodo/88a62f5e0cf9981c910e60f4421c3e66aac2c9bc/pics/vendors/bilibili.png"
|
||||
],
|
||||
"episodes_count": 0,
|
||||
"color_scheme": {
|
||||
"is_dark": true,
|
||||
"primary_color_light": "868ca5",
|
||||
"_base_color": [
|
||||
0.6333333333333333,
|
||||
0.18867924528301885,
|
||||
0.20784313725490197
|
||||
],
|
||||
"secondary_color": "f4f5f9",
|
||||
"_avg_color": [
|
||||
0.059523809523809625,
|
||||
0.09790209790209795,
|
||||
0.5607843137254902
|
||||
],
|
||||
"primary_color_dark": "676c7f"
|
||||
},
|
||||
"type": "movie",
|
||||
"null_rating_reason": "",
|
||||
"linewatches": [
|
||||
{
|
||||
"url": "http://v.youku.com/v_show/id_XNTIwMzM2NDg5Mg==.html?tpa=dW5pb25faWQ9MzAwMDA4XzEwMDAwMl8wMl8wMQ&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900",
|
||||
"source": {
|
||||
"literal": "youku",
|
||||
"pic": "https://img1.doubanio.com/img/files/file-1432869267.png",
|
||||
"name": "优酷视频"
|
||||
},
|
||||
"source_uri": "youku://play?vid=XNTIwMzM2NDg5Mg==&source=douban&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900",
|
||||
"free": false
|
||||
},
|
||||
],
|
||||
"info_url": "https://www.douban.com/doubanapp//h5/movie/20276229/desc",
|
||||
"tags": [],
|
||||
"durations": [
|
||||
"163分钟"
|
||||
],
|
||||
"comment_count": 97204,
|
||||
"cover": {
|
||||
"description": "",
|
||||
"author": {
|
||||
"loc": {
|
||||
"id": "108288",
|
||||
"name": "北京",
|
||||
"uid": "beijing"
|
||||
},
|
||||
"kind": "user",
|
||||
"name": "雨落下",
|
||||
"reg_time": "2020-08-11 16:22:48",
|
||||
"url": "https://www.douban.com/people/221011676/",
|
||||
"uri": "douban://douban.com/user/221011676",
|
||||
"id": "221011676",
|
||||
"avatar_side_icon_type": 3,
|
||||
"avatar_side_icon_id": "234",
|
||||
"avatar": "https://img2.doubanio.com/icon/up221011676-2.jpg",
|
||||
"is_club": false,
|
||||
"type": "user",
|
||||
"avatar_side_icon": "https://img2.doubanio.com/view/files/raw/file-1683625971.png",
|
||||
"uid": "221011676"
|
||||
},
|
||||
"url": "https://movie.douban.com/photos/photo/2707553644/",
|
||||
"image": {
|
||||
"large": {
|
||||
"url": "https://img9.doubanio.com/view/photo/l/public/p2707553644.webp",
|
||||
"width": 1082,
|
||||
"height": 1600,
|
||||
"size": 0
|
||||
},
|
||||
"raw": null,
|
||||
"small": {
|
||||
"url": "https://img9.doubanio.com/view/photo/s/public/p2707553644.webp",
|
||||
"width": 405,
|
||||
"height": 600,
|
||||
"size": 0
|
||||
},
|
||||
"normal": {
|
||||
"url": "https://img9.doubanio.com/view/photo/m/public/p2707553644.webp",
|
||||
"width": 405,
|
||||
"height": 600,
|
||||
"size": 0
|
||||
},
|
||||
"is_animated": false
|
||||
},
|
||||
"uri": "douban://douban.com/photo/2707553644",
|
||||
"create_time": "2021-10-26 15:05:01",
|
||||
"position": 0,
|
||||
"owner_uri": "douban://douban.com/movie/20276229",
|
||||
"type": "photo",
|
||||
"id": "2707553644",
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/photo/2707553644/"
|
||||
},
|
||||
"cover_url": "https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp",
|
||||
"restrictive_icon_url": "",
|
||||
"header_bg_color": "676c7f",
|
||||
"is_douban_intro": false,
|
||||
"ticket_vendor_icons": [
|
||||
"https://img9.doubanio.com/view/dale-online/dale_ad/public/0589a62f2f2d7c2.jpg"
|
||||
],
|
||||
"honor_infos": [],
|
||||
"sharing_url": "https://movie.douban.com/subject/20276229/",
|
||||
"subject_collections": [],
|
||||
"wechat_timeline_share": "screenshot",
|
||||
"countries": [
|
||||
"英国",
|
||||
"美国"
|
||||
],
|
||||
"url": "https://movie.douban.com/subject/20276229/",
|
||||
"release_date": null,
|
||||
"original_title": "No Time to Die",
|
||||
"uri": "douban://douban.com/movie/20276229",
|
||||
"pre_playable_date": null,
|
||||
"episodes_info": "",
|
||||
"subtype": "movie",
|
||||
"directors": [
|
||||
{
|
||||
"name": "凯瑞·福永",
|
||||
"roles": [
|
||||
"导演",
|
||||
"制片人",
|
||||
"编剧",
|
||||
"摄影",
|
||||
"演员"
|
||||
],
|
||||
"title": "凯瑞·福永(同名)美国,加利福尼亚州,奥克兰影视演员",
|
||||
"url": "https://movie.douban.com/celebrity/1009531/",
|
||||
"user": null,
|
||||
"character": "导演",
|
||||
"uri": "douban://douban.com/celebrity/1009531?subject_id=27215222",
|
||||
"avatar": {
|
||||
"large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/600/h/3000/format/webp",
|
||||
"normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/200/h/300/format/webp"
|
||||
},
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1009531/",
|
||||
"type": "celebrity",
|
||||
"id": "1009531",
|
||||
"latin_name": "Cary Fukunaga"
|
||||
}
|
||||
],
|
||||
"is_show": false,
|
||||
"in_blacklist": false,
|
||||
"pre_release_desc": "",
|
||||
"video": null,
|
||||
"aka": [
|
||||
"007:生死有时(港)",
|
||||
"007:生死交战(台)",
|
||||
"007:间不容死",
|
||||
"邦德25",
|
||||
"007:没空去死(豆友译名)",
|
||||
"James Bond 25",
|
||||
"Never Dream of Dying",
|
||||
"Shatterhand"
|
||||
],
|
||||
"is_restrictive": false,
|
||||
"trailer": {
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/movie/20276229/trailer%3Ftrailer_id%3D282585%26trailer_type%3DA",
|
||||
"video_url": "https://vt1.doubanio.com/202310011325/3b1f5827e91dde7826dc20930380dfc2/view/movie/M/402820585.mp4",
|
||||
"title": "中国预告片:终极决战版 (中文字幕)",
|
||||
"uri": "douban://douban.com/movie/20276229/trailer?trailer_id=282585&trailer_type=A",
|
||||
"cover_url": "https://img1.doubanio.com/img/trailer/medium/2712944408.jpg",
|
||||
"term_num": 0,
|
||||
"n_comments": 21,
|
||||
"create_time": "2021-11-01",
|
||||
"subject_title": "007:无暇赴死",
|
||||
"file_size": 10520074,
|
||||
"runtime": "00:42",
|
||||
"type": "A",
|
||||
"id": "282585",
|
||||
"desc": ""
|
||||
},
|
||||
"interest_cmt_earlier_tip_desc": "该短评的发布时间早于公开上映时间,作者可能通过其他渠道提前观看,请谨慎参考。其评分将不计入总评分。"
|
||||
}
|
||||
"""
|
||||
if not doubanid:
|
||||
return None
|
||||
logger.info(f"开始获取豆瓣信息:{doubanid} ...")
|
||||
@@ -129,22 +395,45 @@ class DoubanModule(_ModuleBase):
|
||||
|
||||
return ret_medias
|
||||
|
||||
def __match(self, name: str, year: str, season: int = None) -> dict:
|
||||
@retry(Exception, 5, 3, 3, logger=logger)
|
||||
def match_doubaninfo(self, name: str, mtype: str = None,
|
||||
year: str = None, season: int = None) -> dict:
|
||||
"""
|
||||
搜索和匹配豆瓣信息
|
||||
:param name: 名称
|
||||
:param mtype: 类型 电影/电视剧
|
||||
:param year: 年份
|
||||
:param season: 季号
|
||||
"""
|
||||
result = self.doubanapi.search(f"{name} {year or ''}")
|
||||
result = self.doubanapi.search(f"{name} {year or ''}".strip(),
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d%H%M%S'))
|
||||
if not result:
|
||||
logger.warn(f"未找到 {name} 的豆瓣信息")
|
||||
return {}
|
||||
# 触发rate limit
|
||||
if "search_access_rate_limit" in result.values():
|
||||
logger.warn(f"触发豆瓣API速率限制 错误信息 {result} ...")
|
||||
raise Exception("触发豆瓣API速率限制")
|
||||
for item_obj in result.get("items"):
|
||||
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
|
||||
type_name = item_obj.get("type_name")
|
||||
if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]:
|
||||
continue
|
||||
title = item_obj.get("title")
|
||||
if mtype and mtype != type_name:
|
||||
continue
|
||||
if mtype == MediaType.TV and not season:
|
||||
season = 1
|
||||
item = item_obj.get("target")
|
||||
title = item.get("title")
|
||||
if not title:
|
||||
continue
|
||||
meta = MetaInfo(title)
|
||||
if meta.name == name and (not season or meta.begin_season == season):
|
||||
return item_obj
|
||||
if type_name == MediaType.TV.value:
|
||||
meta.type = MediaType.TV
|
||||
meta.begin_season = meta.begin_season or 1
|
||||
if meta.name == name \
|
||||
and ((not season and not meta.begin_season) or meta.begin_season == season) \
|
||||
and (not year or item.get('year') == year):
|
||||
return item
|
||||
return {}
|
||||
|
||||
def movie_top250(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
@@ -166,22 +455,46 @@ class DoubanModule(_ModuleBase):
|
||||
"""
|
||||
if settings.SCRAP_SOURCE != "douban":
|
||||
return None
|
||||
# 目录下的所有文件
|
||||
for file in SystemUtils.list_files_with_extensions(path, settings.RMT_MEDIAEXT):
|
||||
if not file:
|
||||
continue
|
||||
logger.info(f"开始刮削媒体库文件:{file} ...")
|
||||
try:
|
||||
meta = MetaInfo(file.stem)
|
||||
if not meta.name:
|
||||
if SystemUtils.is_bluray_dir(path):
|
||||
# 蓝光原盘
|
||||
logger.info(f"开始刮削蓝光原盘:{path} ...")
|
||||
meta = MetaInfo(path.stem)
|
||||
if not meta.name:
|
||||
return
|
||||
# 根据名称查询豆瓣数据
|
||||
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
|
||||
mtype=mediainfo.type.value,
|
||||
year=mediainfo.year,
|
||||
season=meta.begin_season)
|
||||
if not doubaninfo:
|
||||
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
|
||||
return
|
||||
scrape_path = path / path.name
|
||||
self.scraper.gen_scraper_files(meta=meta,
|
||||
mediainfo=MediaInfo(douban_info=doubaninfo),
|
||||
file_path=scrape_path)
|
||||
else:
|
||||
# 目录下的所有文件
|
||||
for file in SystemUtils.list_files(path, settings.RMT_MEDIAEXT):
|
||||
if not file:
|
||||
continue
|
||||
# 根据名称查询豆瓣数据
|
||||
doubaninfo = self.__match(name=mediainfo.title, year=mediainfo.year, season=meta.begin_season)
|
||||
if not doubaninfo:
|
||||
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
|
||||
break
|
||||
# 刮削
|
||||
self.scraper.gen_scraper_files(meta, MediaInfo(douban_info=doubaninfo), file)
|
||||
except Exception as e:
|
||||
logger.error(f"刮削文件 {file} 失败,原因:{e}")
|
||||
logger.info(f"{file} 刮削完成")
|
||||
logger.info(f"开始刮削媒体库文件:{file} ...")
|
||||
try:
|
||||
meta = MetaInfo(file.stem)
|
||||
if not meta.name:
|
||||
continue
|
||||
# 根据名称查询豆瓣数据
|
||||
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
|
||||
mtype=mediainfo.type.value,
|
||||
year=mediainfo.year,
|
||||
season=meta.begin_season)
|
||||
if not doubaninfo:
|
||||
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
|
||||
break
|
||||
# 刮削
|
||||
self.scraper.gen_scraper_files(meta=meta,
|
||||
mediainfo=MediaInfo(douban_info=doubaninfo),
|
||||
file_path=file)
|
||||
except Exception as e:
|
||||
logger.error(f"刮削文件 {file} 失败,原因:{e}")
|
||||
logger.info(f"{path} 刮削完成")
|
||||
|
||||
@@ -146,72 +146,113 @@ class DoubanApi(metaclass=Singleton):
|
||||
_api_secret_key = "bf7dddc7c9cfe6f7"
|
||||
_api_key = "0dad551ec0f84ed02907ff5c42e8ec70"
|
||||
_base_url = "https://frodo.douban.com/api/v2"
|
||||
_session = requests.Session()
|
||||
_session = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
self._session = requests.Session()
|
||||
|
||||
@classmethod
|
||||
def __sign(cls, url: str, ts: int, method='GET') -> str:
|
||||
url_path = parse.urlparse(url).path
|
||||
raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), str(ts)])
|
||||
return base64.b64encode(hmac.new(cls._api_secret_key.encode(), raw_sign.encode(), hashlib.sha1).digest()
|
||||
).decode()
|
||||
return base64.b64encode(
|
||||
hmac.new(
|
||||
cls._api_secret_key.encode(),
|
||||
raw_sign.encode(),
|
||||
hashlib.sha1
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
|
||||
def __invoke(cls, url, **kwargs):
|
||||
req_url = cls._base_url + url
|
||||
def __invoke(self, url, **kwargs):
|
||||
req_url = self._base_url + url
|
||||
|
||||
params = {'apiKey': cls._api_key}
|
||||
params = {'apiKey': self._api_key}
|
||||
if kwargs:
|
||||
params.update(kwargs)
|
||||
|
||||
ts = params.pop('_ts', int(datetime.strftime(datetime.now(), '%Y%m%d')))
|
||||
params.update({'os_rom': 'android', 'apiKey': cls._api_key, '_ts': ts, '_sig': cls.__sign(url=req_url, ts=ts)})
|
||||
|
||||
resp = RequestUtils(ua=choice(cls._user_agents), session=cls._session).get_res(url=req_url, params=params)
|
||||
|
||||
ts = params.pop(
|
||||
'_ts',
|
||||
datetime.strftime(datetime.now(), '%Y%m%d')
|
||||
)
|
||||
params.update({
|
||||
'os_rom': 'android',
|
||||
'apiKey': self._api_key,
|
||||
'_ts': ts,
|
||||
'_sig': self.__sign(url=req_url, ts=ts)
|
||||
})
|
||||
resp = RequestUtils(
|
||||
ua=choice(self._user_agents),
|
||||
session=self._session
|
||||
).get_res(url=req_url, params=params)
|
||||
if resp.status_code == 400 and "rate_limit" in resp.text:
|
||||
return resp.json()
|
||||
return resp.json() if resp else {}
|
||||
|
||||
def search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def search(self, keyword, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def movie_search(self, keyword, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def tv_search(self, keyword, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def book_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["book_search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def book_search(self, keyword, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["book_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def group_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["group_search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def group_search(self, keyword, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["group_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_showing(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_showing"], start=start, count=count, _ts=ts)
|
||||
def movie_showing(self, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_showing"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_soon(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_soon"], start=start, count=count, _ts=ts)
|
||||
def movie_soon(self, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_soon"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_hot_gaia(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_hot_gaia"], start=start, count=count, _ts=ts)
|
||||
def movie_hot_gaia(self, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_hot_gaia"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_hot(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_hot"], start=start, count=count, _ts=ts)
|
||||
def tv_hot(self, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_hot"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_animation(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_animation"], start=start, count=count, _ts=ts)
|
||||
def tv_animation(self, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_animation"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_variety_show(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_variety_show"], start=start, count=count, _ts=ts)
|
||||
def tv_variety_show(self, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_variety_show"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_rank_list(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_rank_list"], start=start, count=count, _ts=ts)
|
||||
def tv_rank_list(self, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_rank_list"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def show_hot(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["show_hot"], start=start, count=count, _ts=ts)
|
||||
def show_hot(self, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["show_hot"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_detail(self, subject_id):
|
||||
return self.__invoke(self._urls["movie_detail"] + subject_id)
|
||||
@@ -228,20 +269,30 @@ class DoubanApi(metaclass=Singleton):
|
||||
def book_detail(self, subject_id):
|
||||
return self.__invoke(self._urls["book_detail"] + subject_id)
|
||||
|
||||
def movie_top250(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_top250"], start=start, count=count, _ts=ts)
|
||||
def movie_top250(self, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_top250"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_recommend(self, tags='', sort='R', start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts)
|
||||
def movie_recommend(self, tags='', sort='R', start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_recommend"], tags=tags, sort=sort,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_recommend(self, tags='', sort='R', start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts)
|
||||
def tv_recommend(self, tags='', sort='R', start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_recommend"], tags=tags, sort=sort,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_chinese_best_weekly(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_chinese_best_weekly"], start=start, count=count, _ts=ts)
|
||||
def tv_chinese_best_weekly(self, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_chinese_best_weekly"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_global_best_weekly(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_global_best_weekly"], start=start, count=count, _ts=ts)
|
||||
def tv_global_best_weekly(self, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_global_best_weekly"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def doulist_detail(self, subject_id):
|
||||
"""
|
||||
@@ -250,7 +301,8 @@ class DoubanApi(metaclass=Singleton):
|
||||
"""
|
||||
return self.__invoke(self._urls["doulist"] + subject_id)
|
||||
|
||||
def doulist_items(self, subject_id, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
def doulist_items(self, subject_id, start=0, count=20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
豆列列表
|
||||
:param subject_id: 豆列id
|
||||
@@ -258,4 +310,9 @@ class DoubanApi(metaclass=Singleton):
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["doulist_items"] % subject_id, start=start, count=count, _ts=ts)
|
||||
return self.__invoke(self._urls["doulist_items"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def __del__(self):
|
||||
if self._session:
|
||||
self._session.close()
|
||||
|
||||
@@ -17,7 +17,7 @@ class DoubanScraper:
|
||||
生成刮削文件
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param file_path: 文件路径
|
||||
:param file_path: 文件路径或者目录路径
|
||||
"""
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Union, Any, List, Generator
|
||||
|
||||
@@ -7,7 +6,6 @@ from app.core.context import MediaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.emby.emby import Emby
|
||||
from app.schemas import ExistMediaInfo, RefreshMediaItem, WebhookEventInfo
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
@@ -23,6 +21,14 @@ class EmbyModule(_ModuleBase):
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MEDIASERVER", "emby"
|
||||
|
||||
def scheduler_job(self) -> None:
|
||||
"""
|
||||
定时任务,每10分钟调用一次
|
||||
"""
|
||||
# 定时重连
|
||||
if not self.emby.is_inactive():
|
||||
self.emby.reconnect()
|
||||
|
||||
def user_authenticate(self, name: str, password: str) -> Optional[str]:
|
||||
"""
|
||||
使用Emby用户辅助完成用户认证
|
||||
@@ -33,7 +39,7 @@ class EmbyModule(_ModuleBase):
|
||||
# Emby认证
|
||||
return self.emby.authenticate(name, password)
|
||||
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> WebhookEventInfo:
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]:
|
||||
"""
|
||||
解析Webhook报文体
|
||||
:param body: 请求体
|
||||
@@ -41,13 +47,9 @@ class EmbyModule(_ModuleBase):
|
||||
:param args: 请求参数
|
||||
:return: 字典,解析为消息时需要包含:title、text、image
|
||||
"""
|
||||
if form and form.get("data"):
|
||||
result = form.get("data")
|
||||
else:
|
||||
result = json.dumps(dict(args))
|
||||
return self.emby.get_webhook_message(result)
|
||||
return self.emby.get_webhook_message(form, args)
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[schemas.ExistMediaInfo]:
|
||||
"""
|
||||
判断媒体文件是否存在
|
||||
:param mediainfo: 识别的媒体信息
|
||||
@@ -59,27 +61,42 @@ class EmbyModule(_ModuleBase):
|
||||
movie = self.emby.get_iteminfo(itemid)
|
||||
if movie:
|
||||
logger.info(f"媒体库中已存在:{movie}")
|
||||
return ExistMediaInfo(type=MediaType.MOVIE)
|
||||
movies = self.emby.get_movies(title=mediainfo.title, year=mediainfo.year)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.MOVIE,
|
||||
server="emby",
|
||||
itemid=movie.item_id
|
||||
)
|
||||
movies = self.emby.get_movies(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id)
|
||||
if not movies:
|
||||
logger.info(f"{mediainfo.title_year} 在媒体库中不存在")
|
||||
return None
|
||||
else:
|
||||
logger.info(f"媒体库中已存在:{movies}")
|
||||
return ExistMediaInfo(type=MediaType.MOVIE)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.MOVIE,
|
||||
server="emby",
|
||||
itemid=movies[0].item_id
|
||||
)
|
||||
else:
|
||||
tvs = self.emby.get_tv_episodes(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
item_id=itemid)
|
||||
itemid, tvs = self.emby.get_tv_episodes(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
item_id=itemid)
|
||||
if not tvs:
|
||||
logger.info(f"{mediainfo.title_year} 在媒体库中不存在")
|
||||
return None
|
||||
else:
|
||||
logger.info(f"{mediainfo.title_year} 媒体库中已存在:{tvs}")
|
||||
return ExistMediaInfo(type=MediaType.TV, seasons=tvs)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.TV,
|
||||
seasons=tvs,
|
||||
server="emby",
|
||||
itemid=itemid
|
||||
)
|
||||
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> Optional[bool]:
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
|
||||
"""
|
||||
刷新媒体库
|
||||
:param mediainfo: 识别的媒体信息
|
||||
@@ -87,7 +104,7 @@ class EmbyModule(_ModuleBase):
|
||||
:return: 成功或失败
|
||||
"""
|
||||
items = [
|
||||
RefreshMediaItem(
|
||||
schemas.RefreshMediaItem(
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
type=mediainfo.type,
|
||||
@@ -95,61 +112,48 @@ class EmbyModule(_ModuleBase):
|
||||
target_path=file_path
|
||||
)
|
||||
]
|
||||
return self.emby.refresh_library_by_items(items)
|
||||
self.emby.refresh_library_by_items(items)
|
||||
|
||||
def media_statistic(self) -> schemas.Statistic:
|
||||
def media_statistic(self) -> List[schemas.Statistic]:
|
||||
"""
|
||||
媒体数量统计
|
||||
"""
|
||||
media_statistic = self.emby.get_medias_count()
|
||||
user_count = self.emby.get_user_count()
|
||||
return schemas.Statistic(
|
||||
movie_count=media_statistic.get("MovieCount") or 0,
|
||||
tv_count=media_statistic.get("SeriesCount") or 0,
|
||||
episode_count=media_statistic.get("EpisodeCount") or 0,
|
||||
user_count=user_count or 0
|
||||
)
|
||||
media_statistic.user_count = self.emby.get_user_count()
|
||||
return [media_statistic]
|
||||
|
||||
def mediaserver_librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
"""
|
||||
媒体库列表
|
||||
"""
|
||||
librarys = self.emby.get_librarys()
|
||||
if not librarys:
|
||||
return []
|
||||
return [schemas.MediaServerLibrary(
|
||||
server="emby",
|
||||
id=library.get("id"),
|
||||
name=library.get("name"),
|
||||
type=library.get("type"),
|
||||
path=library.get("path")
|
||||
) for library in librarys]
|
||||
if server != "emby":
|
||||
return None
|
||||
return self.emby.get_librarys()
|
||||
|
||||
def mediaserver_items(self, library_id: str) -> Generator:
|
||||
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
|
||||
"""
|
||||
媒体库项目列表
|
||||
"""
|
||||
items = self.emby.get_items(library_id)
|
||||
for item in items:
|
||||
yield schemas.MediaServerItem(
|
||||
server="emby",
|
||||
library=item.get("library"),
|
||||
item_id=item.get("id"),
|
||||
item_type=item.get("type"),
|
||||
title=item.get("title"),
|
||||
original_title=item.get("original_title"),
|
||||
year=item.get("year"),
|
||||
tmdbid=item.get("tmdbid"),
|
||||
imdbid=item.get("imdbid"),
|
||||
tvdbid=item.get("tvdbid"),
|
||||
path=item.get("path"),
|
||||
)
|
||||
if server != "emby":
|
||||
return None
|
||||
return self.emby.get_items(library_id)
|
||||
|
||||
def mediaserver_tv_episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
||||
def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:
|
||||
"""
|
||||
媒体库项目详情
|
||||
"""
|
||||
if server != "emby":
|
||||
return None
|
||||
return self.emby.get_iteminfo(item_id)
|
||||
|
||||
def mediaserver_tv_episodes(self, server: str,
|
||||
item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:
|
||||
"""
|
||||
获取剧集信息
|
||||
"""
|
||||
seasoninfo = self.emby.get_tv_episodes(item_id=item_id)
|
||||
if server != "emby":
|
||||
return None
|
||||
_, seasoninfo = self.emby.get_tv_episodes(item_id=item_id)
|
||||
if not seasoninfo:
|
||||
return []
|
||||
return [schemas.MediaServerSeasonInfo(
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union, Dict, Generator
|
||||
from typing import List, Optional, Union, Dict, Generator, Tuple
|
||||
|
||||
from requests import Response
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.schemas import RefreshMediaItem, WebhookEventInfo
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class Emby(metaclass=Singleton):
|
||||
@@ -24,8 +23,23 @@ class Emby(metaclass=Singleton):
|
||||
if not self._host.startswith("http"):
|
||||
self._host = "http://" + self._host
|
||||
self._apikey = settings.EMBY_API_KEY
|
||||
self._user = self.get_user()
|
||||
self._folders = self.get_emby_folders()
|
||||
self.user = self.get_user()
|
||||
self.folders = self.get_emby_folders()
|
||||
|
||||
def is_inactive(self) -> bool:
|
||||
"""
|
||||
判断是否需要重连
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return False
|
||||
return True if not self.user else False
|
||||
|
||||
def reconnect(self):
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
self.user = self.get_user()
|
||||
self.folders = self.get_emby_folders()
|
||||
|
||||
def get_emby_folders(self) -> List[dict]:
|
||||
"""
|
||||
@@ -51,7 +65,7 @@ class Emby(metaclass=Singleton):
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
req_url = f"{self._host}emby/Users/{self._user}/Views?api_key={self._apikey}"
|
||||
req_url = f"{self._host}emby/Users/{self.user}/Views?api_key={self._apikey}"
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
@@ -63,7 +77,7 @@ class Emby(metaclass=Singleton):
|
||||
logger.error(f"连接User/Views 出错:" + str(e))
|
||||
return []
|
||||
|
||||
def get_librarys(self):
|
||||
def get_librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
"""
|
||||
@@ -78,12 +92,15 @@ class Emby(metaclass=Singleton):
|
||||
library_type = MediaType.TV.value
|
||||
case _:
|
||||
continue
|
||||
libraries.append({
|
||||
"id": library.get("Id"),
|
||||
"name": library.get("Name"),
|
||||
"path": library.get("Path"),
|
||||
"type": library_type
|
||||
})
|
||||
libraries.append(
|
||||
schemas.MediaServerLibrary(
|
||||
server="emby",
|
||||
id=library.get("Id"),
|
||||
name=library.get("Name"),
|
||||
path=library.get("Path"),
|
||||
type=library_type
|
||||
)
|
||||
)
|
||||
return libraries
|
||||
|
||||
def get_user(self, user_name: str = None) -> Optional[Union[str, int]]:
|
||||
@@ -185,59 +202,29 @@ class Emby(metaclass=Singleton):
|
||||
logger.error(f"连接Users/Query出错:" + str(e))
|
||||
return 0
|
||||
|
||||
def get_activity_log(self, num: int = 30) -> List[dict]:
|
||||
"""
|
||||
获取Emby活动记录
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
req_url = "%semby/System/ActivityLog/Entries?api_key=%s&" % (self._host, self._apikey)
|
||||
ret_array = []
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
ret_json = res.json()
|
||||
items = ret_json.get('Items')
|
||||
for item in items:
|
||||
if item.get("Type") == "AuthenticationSucceeded":
|
||||
event_type = "LG"
|
||||
event_date = StringUtils.get_time(item.get("Date"))
|
||||
event_str = "%s, %s" % (item.get("Name"), item.get("ShortOverview"))
|
||||
activity = {"type": event_type, "event": event_str, "date": event_date}
|
||||
ret_array.append(activity)
|
||||
if item.get("Type") in ["VideoPlayback", "VideoPlaybackStopped"]:
|
||||
event_type = "PL"
|
||||
event_date = StringUtils.get_time(item.get("Date"))
|
||||
event_str = item.get("Name")
|
||||
activity = {"type": event_type, "event": event_str, "date": event_date}
|
||||
ret_array.append(activity)
|
||||
else:
|
||||
logger.error(f"System/ActivityLog/Entries 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
|
||||
logger.error(f"连接System/ActivityLog/Entries出错:" + str(e))
|
||||
return []
|
||||
return ret_array[:num]
|
||||
|
||||
def get_medias_count(self) -> dict:
|
||||
def get_medias_count(self) -> schemas.Statistic:
|
||||
"""
|
||||
获得电影、电视剧、动漫媒体数量
|
||||
:return: MovieCount SeriesCount SongCount
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return {}
|
||||
return schemas.Statistic()
|
||||
req_url = "%semby/Items/Counts?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return res.json()
|
||||
result = res.json()
|
||||
return schemas.Statistic(
|
||||
movie_count=result.get("MovieCount") or 0,
|
||||
tv_count=result.get("SeriesCount") or 0,
|
||||
episode_count=result.get("EpisodeCount") or 0
|
||||
)
|
||||
else:
|
||||
logger.error(f"Items/Counts 未获取到返回数据")
|
||||
return {}
|
||||
return schemas.Statistic()
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Counts出错:" + str(e))
|
||||
return {}
|
||||
return schemas.Statistic()
|
||||
|
||||
def __get_emby_series_id_by_name(self, name: str, year: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -248,7 +235,15 @@ class Emby(metaclass=Singleton):
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
req_url = "%semby/Items?IncludeItemTypes=Series&Fields=ProductionYear&StartIndex=0&Recursive=true&SearchTerm=%s&Limit=10&IncludeSearchTypes=false&api_key=%s" % (
|
||||
req_url = ("%semby/Items?"
|
||||
"IncludeItemTypes=Series"
|
||||
"&Fields=ProductionYear"
|
||||
"&StartIndex=0"
|
||||
"&Recursive=true"
|
||||
"&SearchTerm=%s"
|
||||
"&Limit=10"
|
||||
"&IncludeSearchTypes=false"
|
||||
"&api_key=%s") % (
|
||||
self._host, name, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
@@ -264,11 +259,15 @@ class Emby(metaclass=Singleton):
|
||||
return None
|
||||
return ""
|
||||
|
||||
def get_movies(self, title: str, year: str = None) -> Optional[List[dict]]:
|
||||
def get_movies(self,
|
||||
title: str,
|
||||
year: str = None,
|
||||
tmdb_id: int = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
"""
|
||||
根据标题和年份,检查电影是否在Emby中存在,存在则返回列表
|
||||
:param title: 标题
|
||||
:param year: 年份,可以为空,为空时不按年份过滤
|
||||
:param tmdb_id: TMDB ID
|
||||
:return: 含title、year属性的字典列表
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
@@ -283,11 +282,30 @@ class Emby(metaclass=Singleton):
|
||||
if res_items:
|
||||
ret_movies = []
|
||||
for res_item in res_items:
|
||||
if res_item.get('Name') == title and (
|
||||
not year or str(res_item.get('ProductionYear')) == str(year)):
|
||||
ret_movies.append(
|
||||
{'title': res_item.get('Name'), 'year': str(res_item.get('ProductionYear'))})
|
||||
return ret_movies
|
||||
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
|
||||
mediaserver_item = schemas.MediaServerItem(
|
||||
server="emby",
|
||||
library=res_item.get("ParentId"),
|
||||
item_id=res_item.get("Id"),
|
||||
item_type=res_item.get("Type"),
|
||||
title=res_item.get("Name"),
|
||||
original_title=res_item.get("OriginalTitle"),
|
||||
year=res_item.get("ProductionYear"),
|
||||
tmdbid=int(item_tmdbid) if item_tmdbid else None,
|
||||
imdbid=res_item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=res_item.get("Path")
|
||||
)
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(item_tmdbid) != str(tmdb_id):
|
||||
continue
|
||||
else:
|
||||
ret_movies.append(mediaserver_item)
|
||||
continue
|
||||
if (mediaserver_item.title == title
|
||||
and (not year or str(mediaserver_item.year) == str(year))):
|
||||
ret_movies.append(mediaserver_item)
|
||||
return ret_movies
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items出错:" + str(e))
|
||||
return None
|
||||
@@ -298,7 +316,8 @@ class Emby(metaclass=Singleton):
|
||||
title: str = None,
|
||||
year: str = None,
|
||||
tmdb_id: int = None,
|
||||
season: int = None) -> Optional[Dict[int, list]]:
|
||||
season: int = None
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[Dict[int, list]]]]]:
|
||||
"""
|
||||
根据标题和年份和季,返回Emby中的剧集列表
|
||||
:param item_id: Emby中的ID
|
||||
@@ -309,20 +328,21 @@ class Emby(metaclass=Singleton):
|
||||
:return: 每一季的已有集数
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
return None, None
|
||||
# 电视剧
|
||||
if not item_id:
|
||||
item_id = self.__get_emby_series_id_by_name(title, year)
|
||||
if item_id is None:
|
||||
return None
|
||||
return None, None
|
||||
if not item_id:
|
||||
return {}
|
||||
return None, {}
|
||||
# 验证tmdbid是否相同
|
||||
item_tmdbid = self.get_iteminfo(item_id).get("ProviderIds", {}).get("Tmdb")
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(tmdb_id) != str(item_tmdbid):
|
||||
return {}
|
||||
# /Shows/Id/Episodes 查集的信息
|
||||
item_info = self.get_iteminfo(item_id)
|
||||
if item_info:
|
||||
if tmdb_id and item_info.tmdbid:
|
||||
if str(tmdb_id) != str(item_info.tmdbid):
|
||||
return None, {}
|
||||
# 查集的信息
|
||||
if not season:
|
||||
season = ""
|
||||
try:
|
||||
@@ -330,7 +350,8 @@ class Emby(metaclass=Singleton):
|
||||
self._host, item_id, season, self._apikey)
|
||||
res_json = RequestUtils().get_res(req_url)
|
||||
if res_json:
|
||||
res_items = res_json.json().get("Items")
|
||||
tv_item = res_json.json()
|
||||
res_items = tv_item.get("Items")
|
||||
season_episodes = {}
|
||||
for res_item in res_items:
|
||||
season_index = res_item.get("ParentIndexNumber")
|
||||
@@ -345,11 +366,11 @@ class Emby(metaclass=Singleton):
|
||||
season_episodes[season_index] = []
|
||||
season_episodes[season_index].append(episode_index)
|
||||
# 返回
|
||||
return season_episodes
|
||||
return tv_item.get("Id"), season_episodes
|
||||
except Exception as e:
|
||||
logger.error(f"连接Shows/Id/Episodes出错:" + str(e))
|
||||
return None
|
||||
return {}
|
||||
return None, None
|
||||
return None, {}
|
||||
|
||||
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -412,7 +433,7 @@ class Emby(metaclass=Singleton):
|
||||
return False
|
||||
return False
|
||||
|
||||
def refresh_library_by_items(self, items: List[RefreshMediaItem]) -> bool:
|
||||
def refresh_library_by_items(self, items: List[schemas.RefreshMediaItem]) -> bool:
|
||||
"""
|
||||
按类型、名称、年份来刷新媒体库
|
||||
:param items: 已识别的需要刷新媒体库的媒体信息列表
|
||||
@@ -434,7 +455,7 @@ class Emby(metaclass=Singleton):
|
||||
return self.__refresh_emby_library_by_id(library_id)
|
||||
logger.info(f"Emby媒体库刷新完成")
|
||||
|
||||
def __get_emby_library_id_by_item(self, item: RefreshMediaItem) -> Optional[str]:
|
||||
def __get_emby_library_id_by_item(self, item: schemas.RefreshMediaItem) -> Optional[str]:
|
||||
"""
|
||||
根据媒体信息查询在哪个媒体库,返回要刷新的位置的ID
|
||||
:param item: {title, year, type, category, target_path}
|
||||
@@ -452,33 +473,18 @@ class Emby(metaclass=Singleton):
|
||||
return None
|
||||
# 查找需要刷新的媒体库ID
|
||||
item_path = Path(item.target_path)
|
||||
for folder in self._folders:
|
||||
# 找同级路径最多的媒体库(要求容器内映射路径与实际一致)
|
||||
max_comm_path = ""
|
||||
match_num = 0
|
||||
match_id = None
|
||||
# 匹配子目录
|
||||
# 匹配子目录
|
||||
for folder in self.folders:
|
||||
for subfolder in folder.get("SubFolders"):
|
||||
try:
|
||||
# 查询最大公共路径
|
||||
# 匹配子目录
|
||||
subfolder_path = Path(subfolder.get("Path"))
|
||||
item_path_parents = list(item_path.parents)
|
||||
subfolder_path_parents = list(subfolder_path.parents)
|
||||
common_path = next(p1 for p1, p2 in zip(reversed(item_path_parents),
|
||||
reversed(subfolder_path_parents)
|
||||
) if p1 == p2)
|
||||
if len(common_path) > len(max_comm_path):
|
||||
max_comm_path = common_path
|
||||
match_id = subfolder.get("Id")
|
||||
match_num += 1
|
||||
except StopIteration:
|
||||
continue
|
||||
if item_path.is_relative_to(subfolder_path):
|
||||
return folder.get("Id")
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
# 检查匹配情况
|
||||
if match_id:
|
||||
return match_id if match_num == 1 else folder.get("Id")
|
||||
# 如果找不到,只要路径中有分类目录名就命中
|
||||
# 如果找不到,只要路径中有分类目录名就命中
|
||||
for folder in self.folders:
|
||||
for subfolder in folder.get("SubFolders"):
|
||||
if subfolder.get("Path") and re.search(r"[/\\]%s" % item.category,
|
||||
subfolder.get("Path")):
|
||||
@@ -486,32 +492,46 @@ class Emby(metaclass=Singleton):
|
||||
# 刷新根目录
|
||||
return "/"
|
||||
|
||||
def get_iteminfo(self, itemid: str) -> dict:
|
||||
def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]:
|
||||
"""
|
||||
获取单个项目详情
|
||||
"""
|
||||
if not itemid:
|
||||
return {}
|
||||
return None
|
||||
if not self._host or not self._apikey:
|
||||
return {}
|
||||
req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._host, self._user, itemid, self._apikey)
|
||||
return None
|
||||
req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._host, self.user, itemid, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code == 200:
|
||||
return res.json()
|
||||
item = res.json()
|
||||
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
return schemas.MediaServerItem(
|
||||
server="emby",
|
||||
library=item.get("ParentId"),
|
||||
item_id=item.get("Id"),
|
||||
item_type=item.get("Type"),
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(tmdbid) if tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=item.get("Path")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id出错:" + str(e))
|
||||
return {}
|
||||
return None
|
||||
|
||||
def get_items(self, parent: str) -> Generator:
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
"""
|
||||
if not parent:
|
||||
yield {}
|
||||
yield None
|
||||
if not self._host or not self._apikey:
|
||||
yield {}
|
||||
req_url = "%semby/Users/%s/Items?ParentId=%s&api_key=%s" % (self._host, self._user, parent, self._apikey)
|
||||
yield None
|
||||
req_url = "%semby/Users/%s/Items?ParentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code == 200:
|
||||
@@ -520,31 +540,268 @@ class Emby(metaclass=Singleton):
|
||||
if not result:
|
||||
continue
|
||||
if result.get("Type") in ["Movie", "Series"]:
|
||||
item_info = self.get_iteminfo(result.get("Id"))
|
||||
yield {"id": result.get("Id"),
|
||||
"library": item_info.get("ParentId"),
|
||||
"type": item_info.get("Type"),
|
||||
"title": item_info.get("Name"),
|
||||
"original_title": item_info.get("OriginalTitle"),
|
||||
"year": item_info.get("ProductionYear"),
|
||||
"tmdbid": item_info.get("ProviderIds", {}).get("Tmdb"),
|
||||
"imdbid": item_info.get("ProviderIds", {}).get("Imdb"),
|
||||
"tvdbid": item_info.get("ProviderIds", {}).get("Tvdb"),
|
||||
"path": item_info.get("Path"),
|
||||
"json": str(item_info)}
|
||||
yield self.get_iteminfo(result.get("Id"))
|
||||
elif "Folder" in result.get("Type"):
|
||||
for item in self.get_items(parent=result.get('Id')):
|
||||
yield item
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Items出错:" + str(e))
|
||||
yield {}
|
||||
yield None
|
||||
|
||||
def get_webhook_message(self, message_str: str) -> WebhookEventInfo:
|
||||
def get_webhook_message(self, form: any, args: dict) -> Optional[schemas.WebhookEventInfo]:
|
||||
"""
|
||||
解析Emby Webhook报文
|
||||
电影:
|
||||
{
|
||||
"Title": "admin 在 Microsoft Edge Windows 上停止播放 蜘蛛侠:纵横宇宙",
|
||||
"Date": "2023-08-19T00:49:07.8523469Z",
|
||||
"Event": "playback.stop",
|
||||
"User": {
|
||||
"Name": "admin",
|
||||
"Id": "e6a9dd89fd954d689870e7e0e3e72947"
|
||||
},
|
||||
"Item": {
|
||||
"Name": "蜘蛛侠:纵横宇宙",
|
||||
"OriginalTitle": "Spider-Man: Across the Spider-Verse",
|
||||
"ServerId": "f40a5bd0c6b64051bdbed00580fa1118",
|
||||
"Id": "240270",
|
||||
"DateCreated": "2023-06-21T21:01:27.0000000Z",
|
||||
"Container": "mp4",
|
||||
"SortName": "蜘蛛侠:纵横宇宙",
|
||||
"PremiereDate": "2023-05-30T16:00:00.0000000Z",
|
||||
"ExternalUrls": [
|
||||
{
|
||||
"Name": "IMDb",
|
||||
"Url": "https://www.imdb.com/title/tt9362722"
|
||||
},
|
||||
{
|
||||
"Name": "TheMovieDb",
|
||||
"Url": "https://www.themoviedb.org/movie/569094"
|
||||
},
|
||||
{
|
||||
"Name": "Trakt",
|
||||
"Url": "https://trakt.tv/search/tmdb/569094?id_type=movie"
|
||||
}
|
||||
],
|
||||
"Path": "\\\\10.10.10.10\\Video\\电影\\动画电影\\蜘蛛侠:纵横宇宙 (2023)\\蜘蛛侠:纵横宇宙 (2023).mp4",
|
||||
"OfficialRating": "PG",
|
||||
"Overview": "讲述了新生代蜘蛛侠迈尔斯(沙梅克·摩尔 Shameik Moore 配音)携手蜘蛛格温(海莉·斯坦菲尔德 Hailee Steinfeld 配音),穿越多元宇宙踏上更宏大的冒险征程的故事。面临每个蜘蛛侠都会失去至亲的宿命,迈尔斯誓言打破命运魔咒,找到属于自己的英雄之路。而这个决定和蜘蛛侠2099(奥斯卡·伊萨克 Oscar Is aac 配音)所领军的蜘蛛联盟产生了极大冲突,一场以一敌百的蜘蛛侠大内战即将拉响!",
|
||||
"Taglines": [],
|
||||
"Genres": [
|
||||
"动作",
|
||||
"冒险",
|
||||
"动画",
|
||||
"科幻"
|
||||
],
|
||||
"CommunityRating": 8.7,
|
||||
"RunTimeTicks": 80439590000,
|
||||
"Size": 3170164641,
|
||||
"FileName": "蜘蛛侠:纵横宇宙 (2023).mp4",
|
||||
"Bitrate": 3152840,
|
||||
"PlayAccess": "Full",
|
||||
"ProductionYear": 2023,
|
||||
"RemoteTrailers": [
|
||||
{
|
||||
"Url": "https://www.youtube.com/watch?v=BbXJ3_AQE_o"
|
||||
},
|
||||
{
|
||||
"Url": "https://www.youtube.com/watch?v=cqGjhVJWtEg"
|
||||
},
|
||||
{
|
||||
"Url": "https://www.youtube.com/watch?v=shW9i6k8cB0"
|
||||
},
|
||||
{
|
||||
"Url": "https://www.youtube.com/watch?v=Etv-L2JKCWk"
|
||||
},
|
||||
{
|
||||
"Url": "https://www.youtube.com/watch?v=yFrxzaBLDQM"
|
||||
}
|
||||
],
|
||||
"ProviderIds": {
|
||||
"Tmdb": "569094",
|
||||
"Imdb": "tt9362722"
|
||||
},
|
||||
"IsFolder": false,
|
||||
"ParentId": "240253",
|
||||
"Type": "Movie",
|
||||
"Studios": [
|
||||
{
|
||||
"Name": "Columbia Pictures",
|
||||
"Id": 1252
|
||||
},
|
||||
{
|
||||
"Name": "Sony Pictures Animation",
|
||||
"Id": 1814
|
||||
},
|
||||
{
|
||||
"Name": "Lord Miller",
|
||||
"Id": 240307
|
||||
},
|
||||
{
|
||||
"Name": "Pascal Pictures",
|
||||
"Id": 60101
|
||||
},
|
||||
{
|
||||
"Name": "Arad Productions",
|
||||
"Id": 67372
|
||||
}
|
||||
],
|
||||
"GenreItems": [
|
||||
{
|
||||
"Name": "动作",
|
||||
"Id": 767
|
||||
},
|
||||
{
|
||||
"Name": "冒险",
|
||||
"Id": 818
|
||||
},
|
||||
{
|
||||
"Name": "动画",
|
||||
"Id": 1382
|
||||
},
|
||||
{
|
||||
"Name": "科幻",
|
||||
"Id": 709
|
||||
}
|
||||
],
|
||||
"TagItems": [],
|
||||
"PrimaryImageAspectRatio": 0.7012622720897616,
|
||||
"ImageTags": {
|
||||
"Primary": "c080830ff3c964a775dd0b011b675a29",
|
||||
"Art": "a418b990ca0df95838884b5951883ad5",
|
||||
"Logo": "1782310274c108e85d02d2b0b1c7249c",
|
||||
"Thumb": "29d499a96b7da07cd1cf37edb58507a8",
|
||||
"Banner": "bec236365d57f7f646d8fda16fce2ecb",
|
||||
"Disc": "3e32d87be8655f52bcf43bd34ee94c2b"
|
||||
},
|
||||
"BackdropImageTags": [
|
||||
"13acab1246c95a6fbdee22cf65edf3f0"
|
||||
],
|
||||
"MediaType": "Video",
|
||||
"Width": 1920,
|
||||
"Height": 820
|
||||
},
|
||||
"Server": {
|
||||
"Name": "PN41",
|
||||
"Id": "f40a5bd0c6b64051bdbed00580fa1118",
|
||||
"Version": "4.7.13.0"
|
||||
},
|
||||
"Session": {
|
||||
"RemoteEndPoint": "10.10.10.253",
|
||||
"Client": "Emby Web",
|
||||
"DeviceName": "Microsoft Edge Windows",
|
||||
"DeviceId": "30239450-1748-4855-9799-de3544fc2744",
|
||||
"ApplicationVersion": "4.7.13.0",
|
||||
"Id": "c336b028b893558b333d1a49238b7db1"
|
||||
},
|
||||
"PlaybackInfo": {
|
||||
"PlayedToCompletion": false,
|
||||
"PositionTicks": 17431791950,
|
||||
"PlaylistIndex": 0,
|
||||
"PlaylistLength": 1
|
||||
}
|
||||
}
|
||||
|
||||
电视剧:
|
||||
{
|
||||
"Title": "admin 在 Microsoft Edge Windows 上开始播放 长风渡 - S1, Ep11 - 第 11 集",
|
||||
"Date": "2023-08-19T00:52:20.5200050Z",
|
||||
"Event": "playback.start",
|
||||
"User": {
|
||||
"Name": "admin",
|
||||
"Id": "e6a9dd89fd954d689870e7e0e3e72947"
|
||||
},
|
||||
"Item": {
|
||||
"Name": "第 11 集",
|
||||
"ServerId": "f40a5bd0c6b64051bdbed00580fa1118",
|
||||
"Id": "240252",
|
||||
"DateCreated": "2023-06-21T10:51:06.0000000Z",
|
||||
"Container": "mp4",
|
||||
"SortName": "第 11 集",
|
||||
"PremiereDate": "2023-06-20T16:00:00.0000000Z",
|
||||
"ExternalUrls": [
|
||||
{
|
||||
"Name": "Trakt",
|
||||
"Url": "https://trakt.tv/search/tmdb/4533239?id_type=episode"
|
||||
}
|
||||
],
|
||||
"Path": "\\\\10.10.10.10\\Video\\电视剧\\国产剧\\长风渡 (2023)\\Season 1\\长风渡 - S01E11 - 第 11 集.mp4",
|
||||
"Taglines": [],
|
||||
"Genres": [],
|
||||
"RunTimeTicks": 28021450000,
|
||||
"Size": 707122056,
|
||||
"FileName": "长风渡 - S01E11 - 第 11 集.mp4",
|
||||
"Bitrate": 2018802,
|
||||
"PlayAccess": "Full",
|
||||
"ProductionYear": 2023,
|
||||
"IndexNumber": 11,
|
||||
"ParentIndexNumber": 1,
|
||||
"RemoteTrailers": [],
|
||||
"ProviderIds": {
|
||||
"Tmdb": "4533239"
|
||||
},
|
||||
"IsFolder": false,
|
||||
"ParentId": "240203",
|
||||
"Type": "Episode",
|
||||
"Studios": [],
|
||||
"GenreItems": [],
|
||||
"TagItems": [],
|
||||
"ParentLogoItemId": "240202",
|
||||
"ParentBackdropItemId": "240202",
|
||||
"ParentBackdropImageTags": [
|
||||
"7dd568c67721c1f184b281001ced2f8e"
|
||||
],
|
||||
"SeriesName": "长风渡",
|
||||
"SeriesId": "240202",
|
||||
"SeasonId": "240203",
|
||||
"PrimaryImageAspectRatio": 2.4,
|
||||
"SeriesPrimaryImageTag": "e91c822173e9bcbf7a0efa7d1c16f6bd",
|
||||
"SeasonName": "季 1",
|
||||
"ImageTags": {
|
||||
"Primary": "d6bf1d76150cd86fdff746e4353569ee"
|
||||
},
|
||||
"BackdropImageTags": [],
|
||||
"ParentLogoImageTag": "51cf6b2661c3c9cef3796abafd6a1694",
|
||||
"MediaType": "Video",
|
||||
"Width": 1920,
|
||||
"Height": 800
|
||||
},
|
||||
"Server": {
|
||||
"Name": "PN41",
|
||||
"Id": "f40a5bd0c6b64051bdbed00580fa1118",
|
||||
"Version": "4.7.13.0"
|
||||
},
|
||||
"Session": {
|
||||
"RemoteEndPoint": "10.10.10.253",
|
||||
"Client": "Emby Web",
|
||||
"DeviceName": "Microsoft Edge Windows",
|
||||
"DeviceId": "30239450-1748-4855-9799-de3544fc2744",
|
||||
"ApplicationVersion": "4.7.13.0",
|
||||
"Id": "c336b028b893558b333d1a49238b7db1"
|
||||
},
|
||||
"PlaybackInfo": {
|
||||
"PositionTicks": 14256663550,
|
||||
"PlaylistIndex": 10,
|
||||
"PlaylistLength": 40
|
||||
}
|
||||
}
|
||||
"""
|
||||
message = json.loads(message_str)
|
||||
eventItem = WebhookEventInfo(event=message.get('Event', ''), channel="emby")
|
||||
if not form and not args:
|
||||
return None
|
||||
try:
|
||||
if form and form.get("data"):
|
||||
result = form.get("data")
|
||||
else:
|
||||
result = json.dumps(dict(args))
|
||||
message = json.loads(result)
|
||||
except Exception as e:
|
||||
logger.debug(f"解析emby webhook报文出错:" + str(e))
|
||||
return None
|
||||
eventType = message.get('Event')
|
||||
if not eventType:
|
||||
return None
|
||||
logger.info(f"接收到emby webhook:{message}")
|
||||
eventItem = schemas.WebhookEventInfo(event=eventType, channel="emby")
|
||||
if message.get('Item'):
|
||||
if message.get('Item', {}).get('Type') == 'Episode':
|
||||
eventItem.item_type = "TV"
|
||||
@@ -572,9 +829,9 @@ class Emby(metaclass=Singleton):
|
||||
eventItem.item_type = "MOV"
|
||||
eventItem.item_name = "%s %s" % (
|
||||
message.get('Item', {}).get('Name'), "(" + str(message.get('Item', {}).get('ProductionYear')) + ")")
|
||||
eventItem.item_path = message.get('Item', {}).get('Path')
|
||||
eventItem.item_id = message.get('Item', {}).get('Id')
|
||||
|
||||
eventItem.item_path = message.get('Item', {}).get('Path')
|
||||
eventItem.tmdb_id = message.get('Item', {}).get('ProviderIds', {}).get('Tmdb')
|
||||
if message.get('Item', {}).get('Overview') and len(message.get('Item', {}).get('Overview')) > 100:
|
||||
eventItem.overview = str(message.get('Item', {}).get('Overview'))[:100] + "..."
|
||||
@@ -610,16 +867,36 @@ class Emby(metaclass=Singleton):
|
||||
|
||||
def get_data(self, url: str) -> Optional[Response]:
|
||||
"""
|
||||
自定义URL从媒体服务器获取数据,其中{HOST}、{APIKEY}、{USER}会被替换成实际的值
|
||||
自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值
|
||||
:param url: 请求地址
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
url = url.replace("{HOST}", self._host)\
|
||||
.replace("{APIKEY}", self._apikey)\
|
||||
.replace("{USER}", self._user)
|
||||
url = url.replace("[HOST]", self._host) \
|
||||
.replace("[APIKEY]", self._apikey) \
|
||||
.replace("[USER]", self.user)
|
||||
try:
|
||||
return RequestUtils().get_res(url=url)
|
||||
return RequestUtils(content_type="application/json").get_res(url=url)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Emby出错:" + str(e))
|
||||
return None
|
||||
|
||||
def post_data(self, url: str, data: str = None, headers: dict = None) -> Optional[Response]:
|
||||
"""
|
||||
自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值
|
||||
:param url: 请求地址
|
||||
:param data: 请求数据
|
||||
:param headers: 请求头
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
url = url.replace("[HOST]", self._host) \
|
||||
.replace("[APIKEY]", self._apikey) \
|
||||
.replace("[USER]", self.user)
|
||||
try:
|
||||
return RequestUtils(
|
||||
headers=headers,
|
||||
).post_res(url=url, data=data)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Emby出错:" + str(e))
|
||||
return None
|
||||
|
||||
@@ -11,6 +11,299 @@ from app.schemas.types import MediaType
|
||||
|
||||
class FanartModule(_ModuleBase):
|
||||
|
||||
"""
|
||||
{
|
||||
"name": "The Wheel of Time",
|
||||
"thetvdb_id": "355730",
|
||||
"tvposter": [
|
||||
{
|
||||
"id": "174068",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64b009de9548d.jpg",
|
||||
"lang": "en",
|
||||
"likes": "3"
|
||||
},
|
||||
{
|
||||
"id": "176424",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64de44fe42073.jpg",
|
||||
"lang": "00",
|
||||
"likes": "3"
|
||||
},
|
||||
{
|
||||
"id": "176407",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64dde63c7c941.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "177321",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64eda10599c3d.jpg",
|
||||
"lang": "cz",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "155050",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-6313adbd1fd58.jpg",
|
||||
"lang": "pl",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "140198",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-61a0d7b11952e.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "140034",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-619e65b73871d.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0"
|
||||
}
|
||||
],
|
||||
"hdtvlogo": [
|
||||
{
|
||||
"id": "139835",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-6197d9392faba.png",
|
||||
"lang": "en",
|
||||
"likes": "3"
|
||||
},
|
||||
{
|
||||
"id": "140039",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-619e87941a128.png",
|
||||
"lang": "pt",
|
||||
"likes": "3"
|
||||
},
|
||||
{
|
||||
"id": "140092",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-619fa2347bada.png",
|
||||
"lang": "en",
|
||||
"likes": "3"
|
||||
},
|
||||
{
|
||||
"id": "164312",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-63c8185cb8824.png",
|
||||
"lang": "hu",
|
||||
"likes": "1"
|
||||
},
|
||||
{
|
||||
"id": "139827",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-6197539658a9e.png",
|
||||
"lang": "en",
|
||||
"likes": "1"
|
||||
},
|
||||
{
|
||||
"id": "177214",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-64ebae44c23a6.png",
|
||||
"lang": "cz",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "177215",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-64ebae472deef.png",
|
||||
"lang": "cz",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "156163",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-63316bef1ff9d.png",
|
||||
"lang": "cz",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "155051",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-6313add04ca92.png",
|
||||
"lang": "pl",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "152668",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-62ced3775a40a.png",
|
||||
"lang": "pl",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "142266",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-61ccd93eeac2b.png",
|
||||
"lang": "de",
|
||||
"likes": "0"
|
||||
}
|
||||
],
|
||||
"hdclearart": [
|
||||
{
|
||||
"id": "164313",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-63c81871c982c.png",
|
||||
"lang": "en",
|
||||
"likes": "3"
|
||||
},
|
||||
{
|
||||
"id": "140284",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-61a2128ed1df2.png",
|
||||
"lang": "pt",
|
||||
"likes": "3"
|
||||
},
|
||||
{
|
||||
"id": "139828",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-61975401e894c.png",
|
||||
"lang": "en",
|
||||
"likes": "1"
|
||||
},
|
||||
{
|
||||
"id": "164314",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-63c8188488a5f.png",
|
||||
"lang": "hu",
|
||||
"likes": "1"
|
||||
},
|
||||
{
|
||||
"id": "177322",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-64eda135933b6.png",
|
||||
"lang": "cz",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "142267",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-61ccda9918c5c.png",
|
||||
"lang": "de",
|
||||
"likes": "0"
|
||||
}
|
||||
],
|
||||
"seasonposter": [
|
||||
{
|
||||
"id": "140199",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/seasonposter/the-wheel-of-time-61a0d7c2976de.jpg",
|
||||
"lang": "en",
|
||||
"likes": "1",
|
||||
"season": "1"
|
||||
},
|
||||
{
|
||||
"id": "176395",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/seasonposter/the-wheel-of-time-64dd80b3d79a9.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0",
|
||||
"season": "1"
|
||||
},
|
||||
{
|
||||
"id": "140035",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/seasonposter/the-wheel-of-time-619e65c4d5357.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0",
|
||||
"season": "1"
|
||||
}
|
||||
],
|
||||
"tvthumb": [
|
||||
{
|
||||
"id": "140242",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-61a1813035506.jpg",
|
||||
"lang": "en",
|
||||
"likes": "1"
|
||||
},
|
||||
{
|
||||
"id": "177323",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-64eda15b6dce6.jpg",
|
||||
"lang": "cz",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "176399",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-64dd85c9b618c.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "152669",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-62ced53d16574.jpg",
|
||||
"lang": "pl",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "141983",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-61c6d04a6d701.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0"
|
||||
}
|
||||
],
|
||||
"showbackground": [
|
||||
{
|
||||
"id": "177324",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/showbackground/the-wheel-of-time-64eda1833ccb1.jpg",
|
||||
"lang": "",
|
||||
"likes": "0",
|
||||
"season": "all"
|
||||
},
|
||||
{
|
||||
"id": "141986",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/showbackground/the-wheel-of-time-61c6d08f7c7e2.jpg",
|
||||
"lang": "",
|
||||
"likes": "0",
|
||||
"season": "all"
|
||||
},
|
||||
{
|
||||
"id": "139868",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/showbackground/the-wheel-of-time-6198ce358b98a.jpg",
|
||||
"lang": "",
|
||||
"likes": "0",
|
||||
"season": "all"
|
||||
}
|
||||
],
|
||||
"seasonthumb": [
|
||||
{
|
||||
"id": "176396",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/seasonthumb/the-wheel-of-time-64dd80c8593f9.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0",
|
||||
"season": "1"
|
||||
},
|
||||
{
|
||||
"id": "176400",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/seasonthumb/the-wheel-of-time-64dd85da7c5e9.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0",
|
||||
"season": "0"
|
||||
}
|
||||
],
|
||||
"tvbanner": [
|
||||
{
|
||||
"id": "176397",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-64dd80da9a255.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "176401",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-64dd85e8904ea.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "141988",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-61c6d34bceb5f.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0"
|
||||
},
|
||||
{
|
||||
"id": "141984",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-61c6d06c1c21c.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0"
|
||||
}
|
||||
],
|
||||
"seasonbanner": [
|
||||
{
|
||||
"id": "176398",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/seasonbanner/the-wheel-of-time-64dd80e7dbd9f.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0",
|
||||
"season": "1"
|
||||
},
|
||||
{
|
||||
"id": "176402",
|
||||
"url": "http://assets.fanart.tv/fanart/tv/355730/seasonbanner/the-wheel-of-time-64dd85fb4f1b1.jpg",
|
||||
"lang": "en",
|
||||
"likes": "0",
|
||||
"season": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
# 代理
|
||||
_proxies: dict = settings.PROXY
|
||||
|
||||
@@ -36,10 +329,15 @@ class FanartModule(_ModuleBase):
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
result = self.__request_fanart(mediainfo.type, mediainfo.tmdb_id)
|
||||
else:
|
||||
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
|
||||
if mediainfo.tvdb_id:
|
||||
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
|
||||
else:
|
||||
logger.info(f"{mediainfo.title_year} 没有tvdbid,无法获取Fanart图片")
|
||||
return
|
||||
if not result or result.get('status') == 'error':
|
||||
logger.warn(f"没有获取到 {mediainfo.title_year} 的Fanart图片数据")
|
||||
return
|
||||
# 获取所有图片
|
||||
for name, images in result.items():
|
||||
if not images:
|
||||
continue
|
||||
@@ -47,7 +345,18 @@ class FanartModule(_ModuleBase):
|
||||
continue
|
||||
# 按欢迎程度倒排
|
||||
images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
mediainfo.set_image(self.__name(name), images[0].get('url'))
|
||||
# 取第一张图片
|
||||
image_obj = images[0]
|
||||
# 图片属性xx_path
|
||||
image_name = self.__name(name)
|
||||
image_season = image_obj.get('season')
|
||||
# 设置图片
|
||||
if image_name.startswith("season") and image_season:
|
||||
# 季图片格式 seasonxx-poster
|
||||
image_name = f"season{str(image_season).rjust(2, '0')}-{image_name[6:]}"
|
||||
if not mediainfo.get_image(image_name):
|
||||
# 没有图片才设置
|
||||
mediainfo.set_image(image_name, image_obj.get('url'))
|
||||
|
||||
return mediainfo
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Optional, List, Tuple, Union
|
||||
from typing import Optional, List, Tuple, Union, Dict
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas import TransferInfo
|
||||
from app.utils.system import SystemUtils
|
||||
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
lock = Lock()
|
||||
|
||||
@@ -29,40 +29,36 @@ class FileTransferModule(_ModuleBase):
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
def transfer(self, path: Path, mediainfo: MediaInfo, transfer_type: str) -> Optional[TransferInfo]:
|
||||
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transfer_type: str, target: Path = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
|
||||
"""
|
||||
文件转移
|
||||
:param path: 文件路径
|
||||
:param meta: 预识别的元数据,仅单文件转移时传递
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移方式
|
||||
:param target: 目标路径
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
# 获取目标路径
|
||||
target_path = self.get_target_path(in_path=path)
|
||||
if not target_path:
|
||||
if not target:
|
||||
target = self.get_target_path(in_path=path)
|
||||
else:
|
||||
target = self.get_library_path(target)
|
||||
if not target:
|
||||
logger.error("未找到媒体库目录,无法转移文件")
|
||||
return TransferInfo(message="未找到媒体库目录,无法转移文件")
|
||||
return TransferInfo(success=False,
|
||||
path=path,
|
||||
message="未找到媒体库目录")
|
||||
# 转移
|
||||
result = self.transfer_media(in_path=path,
|
||||
mediainfo=mediainfo,
|
||||
transfer_type=transfer_type,
|
||||
target_dir=target_path)
|
||||
if not result:
|
||||
return TransferInfo()
|
||||
if isinstance(result, str):
|
||||
return TransferInfo(message=result)
|
||||
# 解包结果
|
||||
is_bluray, target_path, file_list, file_list_new, file_size, fail_list, msg = result
|
||||
# 返回
|
||||
return TransferInfo(path=path,
|
||||
target_path=target_path,
|
||||
message=msg,
|
||||
file_count=len(file_list),
|
||||
total_size=file_size,
|
||||
fail_list=fail_list,
|
||||
is_bluray=is_bluray,
|
||||
file_list=file_list,
|
||||
file_list_new=file_list_new)
|
||||
return self.transfer_media(in_path=path,
|
||||
in_meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
transfer_type=transfer_type,
|
||||
target_dir=target,
|
||||
episodes_info=episodes_info)
|
||||
|
||||
@staticmethod
|
||||
def __transfer_command(file_item: Path, target_file: Path, transfer_type: str) -> int:
|
||||
@@ -122,17 +118,17 @@ class FileTransferModule(_ModuleBase):
|
||||
r"|chinese|(cn|ch[si]|sg|zho?|eng)[-_&](cn|ch[si]|sg|zho?|eng)" \
|
||||
r"|简[体中]?)[.\])])" \
|
||||
r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})" \
|
||||
r"|简体|简中" \
|
||||
r"|简体|简中|JPSC" \
|
||||
r"|(?<![a-z0-9])gb(?![a-z0-9])"
|
||||
_zhtw_sub_re = r"([.\[(](((zh[-_])?(hk|tw|cht|tc))" \
|
||||
r"|繁[体中]?)[.\])])" \
|
||||
r"|繁体中[文字]|中[文字]繁体|繁体" \
|
||||
r"|繁体中[文字]|中[文字]繁体|繁体|JPTC" \
|
||||
r"|(?<![a-z0-9])big5(?![a-z0-9])"
|
||||
_eng_sub_re = r"[.\[(]eng[.\])]"
|
||||
|
||||
# 比对文件名并转移字幕
|
||||
org_dir: Path = org_path.parent
|
||||
file_list: List[Path] = SystemUtils.list_files_with_extensions(org_dir, settings.RMT_SUBEXT)
|
||||
file_list: List[Path] = SystemUtils.list_files(org_dir, settings.RMT_SUBEXT)
|
||||
if len(file_list) == 0:
|
||||
logger.debug(f"{org_dir} 目录下没有找到字幕文件...")
|
||||
else:
|
||||
@@ -162,12 +158,12 @@ class FileTransferModule(_ModuleBase):
|
||||
continue
|
||||
new_file_type = ""
|
||||
# 兼容jellyfin字幕识别(多重识别), emby则会识别最后一个后缀
|
||||
if re.search(_zhcn_sub_re, file_item.stem, re.I):
|
||||
if re.search(_zhcn_sub_re, file_item.name, re.I):
|
||||
new_file_type = ".chi.zh-cn"
|
||||
elif re.search(_zhtw_sub_re, file_item.stem,
|
||||
elif re.search(_zhtw_sub_re, file_item.name,
|
||||
re.I):
|
||||
new_file_type = ".zh-tw"
|
||||
elif re.search(_eng_sub_re, file_item.stem, re.I):
|
||||
elif re.search(_eng_sub_re, file_item.name, re.I):
|
||||
new_file_type = ".eng"
|
||||
# 通过对比字幕文件大小 尽量转移所有存在的字幕
|
||||
file_ext = file_item.suffix
|
||||
@@ -218,7 +214,7 @@ class FileTransferModule(_ModuleBase):
|
||||
"""
|
||||
dir_name = org_path.parent
|
||||
file_name = org_path.name
|
||||
file_list: List[Path] = SystemUtils.list_files_with_extensions(dir_name, ['.mka'])
|
||||
file_list: List[Path] = SystemUtils.list_files(dir_name, ['.mka'])
|
||||
pending_file_list: List[Path] = [file for file in file_list if org_path.stem == file.stem]
|
||||
if len(pending_file_list) == 0:
|
||||
logger.debug(f"{dir_name} 目录下没有找到匹配的音轨文件")
|
||||
@@ -247,9 +243,9 @@ class FileTransferModule(_ModuleBase):
|
||||
logger.error(f"音轨文件 {file_name} {transfer_type}失败:{reason}")
|
||||
return 0
|
||||
|
||||
def __transfer_bluray_dir(self, file_path: Path, new_path: Path, transfer_type: str) -> int:
|
||||
def __transfer_dir(self, file_path: Path, new_path: Path, transfer_type: str) -> int:
|
||||
"""
|
||||
转移蓝光文件夹
|
||||
转移整个文件夹
|
||||
:param file_path: 原路径
|
||||
:param new_path: 新路径
|
||||
:param transfer_type: RmtMode转移方式
|
||||
@@ -268,14 +264,18 @@ class FileTransferModule(_ModuleBase):
|
||||
|
||||
def __transfer_dir_files(self, src_dir: Path, target_dir: Path, transfer_type: str) -> int:
|
||||
"""
|
||||
按目录结构转移所有文件
|
||||
按目录结构转移目录下所有文件
|
||||
:param src_dir: 原路径
|
||||
:param target_dir: 新路径
|
||||
:param transfer_type: RmtMode转移方式
|
||||
"""
|
||||
retcode = 0
|
||||
for file in src_dir.glob("**/*"):
|
||||
new_file = target_dir.with_name(src_dir.name)
|
||||
# 过滤掉目录
|
||||
if file.is_dir():
|
||||
continue
|
||||
# 使用target_dir的父目录作为新的父目录
|
||||
new_file = target_dir.joinpath(file.relative_to(src_dir))
|
||||
if new_file.exists():
|
||||
logger.warn(f"{new_file} 文件已存在")
|
||||
continue
|
||||
@@ -290,7 +290,7 @@ class FileTransferModule(_ModuleBase):
|
||||
return retcode
|
||||
|
||||
def __transfer_file(self, file_item: Path, new_file: Path, transfer_type: str,
|
||||
over_flag: bool = False, old_file: Path = None) -> int:
|
||||
over_flag: bool = False) -> int:
|
||||
"""
|
||||
转移一个文件,同时处理其他相关文件
|
||||
:param file_item: 原文件路径
|
||||
@@ -298,12 +298,13 @@ class FileTransferModule(_ModuleBase):
|
||||
:param transfer_type: RmtMode转移方式
|
||||
:param over_flag: 是否覆盖,为True时会先删除再转移
|
||||
"""
|
||||
if not over_flag and new_file.exists():
|
||||
logger.warn(f"文件已存在:{new_file}")
|
||||
return 0
|
||||
if over_flag and old_file and old_file.exists():
|
||||
logger.info(f"正在删除已存在的文件:{old_file}")
|
||||
old_file.unlink()
|
||||
if new_file.exists():
|
||||
if not over_flag:
|
||||
logger.warn(f"文件已存在:{new_file}")
|
||||
return 0
|
||||
else:
|
||||
logger.info(f"正在删除已存在的文件:{new_file}")
|
||||
new_file.unlink()
|
||||
logger.info(f"正在转移文件:{file_item} 到 {new_file}")
|
||||
# 创建父目录
|
||||
new_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -322,40 +323,14 @@ class FileTransferModule(_ModuleBase):
|
||||
over_flag=over_flag)
|
||||
|
||||
@staticmethod
|
||||
def __is_bluray_dir(dir_path: Path) -> bool:
|
||||
def __get_dest_dir(mediainfo: MediaInfo, target_dir: Path) -> Path:
|
||||
"""
|
||||
判断是否为蓝光原盘目录
|
||||
"""
|
||||
# 蓝光原盘目录必备的文件或文件夹
|
||||
required_files = ['BDMV', 'CERTIFICATE']
|
||||
# 检查目录下是否存在所需文件或文件夹
|
||||
for item in required_files:
|
||||
if (dir_path / item).exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
def transfer_media(self,
|
||||
in_path: Path,
|
||||
mediainfo: MediaInfo,
|
||||
transfer_type: str,
|
||||
target_dir: Path = None
|
||||
) -> Union[str, Tuple[bool, Path, list, list, int, List[Path], str]]:
|
||||
"""
|
||||
识别并转移一个文件、多个文件或者目录
|
||||
:param in_path: 转移的路径,可能是一个文件也可以是一个目录
|
||||
:param target_dir: 目的文件夹,非空的转移到该文件夹,为空时则按类型转移到配置文件中的媒体库文件夹
|
||||
:param transfer_type: 文件转移方式
|
||||
根据设置并装媒体库目录
|
||||
:param mediainfo: 媒体信息
|
||||
:return: 是否蓝光原盘、目的路径、处理文件清单、总大小、失败文件列表、错误信息
|
||||
:target_dir: 媒体库根目录
|
||||
"""
|
||||
# 检查目录路径
|
||||
if not in_path.exists():
|
||||
return f"{in_path} 路径不存在"
|
||||
|
||||
if not target_dir.exists():
|
||||
return f"{target_dir} 目标路径不存在"
|
||||
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
if settings.LIBRARY_MOVIE_NAME:
|
||||
target_dir = target_dir / settings.LIBRARY_MOVIE_NAME / mediainfo.category
|
||||
else:
|
||||
@@ -363,178 +338,191 @@ class FileTransferModule(_ModuleBase):
|
||||
target_dir = target_dir / mediainfo.type.value / mediainfo.category
|
||||
|
||||
if mediainfo.type == MediaType.TV:
|
||||
if settings.LIBRARY_TV_NAME:
|
||||
# 电视剧
|
||||
if settings.LIBRARY_ANIME_NAME \
|
||||
and mediainfo.genre_ids \
|
||||
and set(mediainfo.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
# 动漫
|
||||
target_dir = target_dir / settings.LIBRARY_ANIME_NAME / mediainfo.category
|
||||
elif settings.LIBRARY_TV_NAME:
|
||||
# 电视剧
|
||||
target_dir = target_dir / settings.LIBRARY_TV_NAME / mediainfo.category
|
||||
else:
|
||||
# 目的目录加上类型和二级分类
|
||||
target_dir = target_dir / mediainfo.type.value / mediainfo.category
|
||||
return target_dir
|
||||
|
||||
def transfer_media(self,
|
||||
in_path: Path,
|
||||
in_meta: MetaBase,
|
||||
mediainfo: MediaInfo,
|
||||
transfer_type: str,
|
||||
target_dir: Path,
|
||||
episodes_info: List[TmdbEpisode] = None
|
||||
) -> TransferInfo:
|
||||
"""
|
||||
识别并转移一个文件或者一个目录下的所有文件
|
||||
:param in_path: 转移的路径,可能是一个文件也可以是一个目录
|
||||
:param in_meta:预识别元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param target_dir: 媒体库根目录
|
||||
:param transfer_type: 文件转移方式
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: TransferInfo、错误信息
|
||||
"""
|
||||
# 检查目录路径
|
||||
if not in_path.exists():
|
||||
return TransferInfo(success=False,
|
||||
path=in_path,
|
||||
message=f"{in_path} 路径不存在")
|
||||
|
||||
if not target_dir.exists():
|
||||
return TransferInfo(success=False,
|
||||
path=in_path,
|
||||
message=f"{target_dir} 目标路径不存在")
|
||||
|
||||
# 媒体库目的目录
|
||||
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
|
||||
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
|
||||
# 总大小
|
||||
total_filesize = 0
|
||||
|
||||
# 处理文件清单
|
||||
file_list = []
|
||||
|
||||
# 目标文件清单
|
||||
file_list_new = []
|
||||
|
||||
# 失败文件清单
|
||||
fail_list = []
|
||||
|
||||
# 错误信息
|
||||
err_msgs = []
|
||||
|
||||
# 判断是否为蓝光原盘
|
||||
bluray_flag = self.__is_bluray_dir(in_path)
|
||||
if bluray_flag:
|
||||
# 识别目录名称,不包括后缀
|
||||
meta = MetaInfo(in_path.stem)
|
||||
# 判断是否为文件夹
|
||||
if in_path.is_dir():
|
||||
# 转移整个目录
|
||||
# 是否蓝光原盘
|
||||
bluray_flag = SystemUtils.is_bluray_dir(in_path)
|
||||
if bluray_flag:
|
||||
logger.info(f"{in_path} 是蓝光原盘文件夹")
|
||||
# 目的路径
|
||||
new_path = self.get_rename_path(
|
||||
path=target_dir,
|
||||
template_string=rename_format,
|
||||
rename_dict=self.__get_naming_dict(meta=meta,
|
||||
rename_dict=self.__get_naming_dict(meta=in_meta,
|
||||
mediainfo=mediainfo)
|
||||
).parent
|
||||
# 转移蓝光原盘
|
||||
retcode = self.__transfer_bluray_dir(file_path=in_path,
|
||||
new_path=new_path,
|
||||
transfer_type=transfer_type)
|
||||
retcode = self.__transfer_dir(file_path=in_path,
|
||||
new_path=new_path,
|
||||
transfer_type=transfer_type)
|
||||
if retcode != 0:
|
||||
return f"{retcode},蓝光原盘转移失败"
|
||||
else:
|
||||
# 计算大小
|
||||
total_filesize += in_path.stat().st_size
|
||||
# 返回转移后的路径
|
||||
return bluray_flag, new_path, [], [], total_filesize, [], ""
|
||||
logger.error(f"文件夹 {in_path} 转移失败,错误码:{retcode}")
|
||||
return TransferInfo(success=False,
|
||||
message=f"错误码:{retcode}",
|
||||
path=in_path,
|
||||
target_path=new_path,
|
||||
is_bluray=bluray_flag)
|
||||
|
||||
logger.info(f"文件夹 {in_path} 转移成功")
|
||||
# 返回转移后的路径
|
||||
return TransferInfo(success=True,
|
||||
path=in_path,
|
||||
target_path=new_path,
|
||||
total_size=new_path.stat().st_size,
|
||||
is_bluray=bluray_flag)
|
||||
else:
|
||||
# 获取文件清单
|
||||
transfer_files: List[Path] = SystemUtils.list_files_with_extensions(in_path, settings.RMT_MEDIAEXT)
|
||||
if len(transfer_files) == 0:
|
||||
return f"{in_path} 目录下没有找到可转移的文件"
|
||||
# 识别目录名称,不包括后缀
|
||||
meta = MetaInfo(in_path.stem)
|
||||
# 目的路径
|
||||
new_path = target_dir / self.get_rename_path(
|
||||
# 转移单个文件
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧
|
||||
if in_meta.begin_episode is None:
|
||||
logger.warn(f"文件 {in_path} 转移失败:未识别到文件集数")
|
||||
return TransferInfo(success=False,
|
||||
message=f"未识别到文件集数",
|
||||
path=in_path,
|
||||
fail_list=[str(in_path)])
|
||||
|
||||
# 文件结束季为空
|
||||
in_meta.end_season = None
|
||||
# 文件总季数为1
|
||||
if in_meta.total_season:
|
||||
in_meta.total_season = 1
|
||||
# 文件不可能超过2集
|
||||
if in_meta.total_episode > 2:
|
||||
in_meta.total_episode = 1
|
||||
in_meta.end_episode = None
|
||||
|
||||
# 目的文件名
|
||||
new_file = self.get_rename_path(
|
||||
path=target_dir,
|
||||
template_string=rename_format,
|
||||
rename_dict=self.__get_naming_dict(meta=meta,
|
||||
mediainfo=mediainfo)
|
||||
).parents[-2].name
|
||||
# 转移所有文件
|
||||
for transfer_file in transfer_files:
|
||||
try:
|
||||
# 识别文件元数据,不包含后缀
|
||||
file_meta = MetaInfo(transfer_file.stem)
|
||||
# 开始季
|
||||
if not file_meta.begin_season:
|
||||
file_meta.begin_season = meta.begin_season
|
||||
# 结束季为空
|
||||
file_meta.end_season = None
|
||||
# 总季数
|
||||
if file_meta.begin_season:
|
||||
file_meta.total_seasons = 1
|
||||
# 开始集
|
||||
if not file_meta.begin_episode:
|
||||
file_meta.begin_episode = meta.begin_episode
|
||||
# 结束集
|
||||
if not file_meta.end_episode:
|
||||
file_meta.end_episode = meta.end_episode
|
||||
# 总集数
|
||||
if not file_meta.total_episode:
|
||||
file_meta.total_episode = meta.total_episode
|
||||
# 版本
|
||||
if not file_meta.resource_type:
|
||||
file_meta.resource_type = meta.resource_type
|
||||
# 分辨率
|
||||
if not file_meta.resource_pix:
|
||||
file_meta.resource_pix = meta.resource_pix
|
||||
# 制作组/字幕组
|
||||
if not file_meta.resource_team:
|
||||
file_meta.resource_team = meta.resource_team
|
||||
# 特效
|
||||
if not file_meta.resource_effect:
|
||||
file_meta.resource_effect = meta.resource_effect
|
||||
# 视频编码
|
||||
if not file_meta.video_encode:
|
||||
file_meta.video_encode = meta.video_encode
|
||||
# 音频编码
|
||||
if not file_meta.audio_encode:
|
||||
file_meta.audio_encode = meta.audio_encode
|
||||
# Part
|
||||
if not file_meta.part:
|
||||
file_meta.part = meta.part
|
||||
# 目的文件名
|
||||
new_file = self.get_rename_path(
|
||||
path=target_dir,
|
||||
template_string=rename_format,
|
||||
rename_dict=self.__get_naming_dict(meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
file_ext=transfer_file.suffix)
|
||||
)
|
||||
# 判断是否要覆盖
|
||||
overflag = False
|
||||
if new_file.exists():
|
||||
if new_file.stat().st_size < transfer_file.stat().st_size:
|
||||
logger.info(f"目标文件已存在,但文件大小更小,将覆盖:{new_file}")
|
||||
overflag = True
|
||||
# 转移文件
|
||||
retcode = self.__transfer_file(file_item=transfer_file,
|
||||
new_file=new_file,
|
||||
transfer_type=transfer_type,
|
||||
over_flag=overflag)
|
||||
if retcode != 0:
|
||||
logger.error(f"{transfer_file} 转移文件失败,错误码:{retcode}")
|
||||
err_msgs.append(f"{transfer_file.name}:错误码 {retcode}")
|
||||
fail_list.append(transfer_file)
|
||||
continue
|
||||
# 计算文件数
|
||||
file_list.append(str(transfer_file))
|
||||
file_list_new.append(str(new_file))
|
||||
# 计算大小
|
||||
total_filesize += transfer_file.stat().st_size
|
||||
except Exception as err:
|
||||
err_msgs.append(f"{transfer_file.name}:{err}")
|
||||
logger.error(f"{transfer_file}转移失败:{err}")
|
||||
fail_list.append(transfer_file)
|
||||
rename_dict=self.__get_naming_dict(
|
||||
meta=in_meta,
|
||||
mediainfo=mediainfo,
|
||||
episodes_info=episodes_info,
|
||||
file_ext=in_path.suffix
|
||||
)
|
||||
)
|
||||
|
||||
if not file_list:
|
||||
# 没有成功的
|
||||
return "\n".join(err_msgs)
|
||||
# 判断是否要覆盖
|
||||
overflag = False
|
||||
if new_file.exists():
|
||||
if new_file.stat().st_size < in_path.stat().st_size:
|
||||
logger.info(f"目标文件已存在,但文件大小更小,将覆盖:{new_file}")
|
||||
overflag = True
|
||||
|
||||
# 蓝光原盘、新路径、处理文件清单、总大小、失败文件列表、错误信息
|
||||
return bluray_flag, new_path, file_list, file_list_new, total_filesize, fail_list, "\n".join(err_msgs)
|
||||
# 转移文件
|
||||
retcode = self.__transfer_file(file_item=in_path,
|
||||
new_file=new_file,
|
||||
transfer_type=transfer_type,
|
||||
over_flag=overflag)
|
||||
if retcode != 0:
|
||||
logger.error(f"文件 {in_path} 转移失败,错误码:{retcode}")
|
||||
return TransferInfo(success=False,
|
||||
message=f"错误码:{retcode}",
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
fail_list=[str(in_path)])
|
||||
|
||||
logger.info(f"文件 {in_path} 转移成功")
|
||||
return TransferInfo(success=True,
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
file_count=1,
|
||||
total_size=new_file.stat().st_size,
|
||||
is_bluray=False,
|
||||
file_list=[str(in_path)],
|
||||
file_list_new=[str(new_file)])
|
||||
|
||||
@staticmethod
|
||||
def __get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: str = None) -> dict:
|
||||
def __get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: str = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> dict:
|
||||
"""
|
||||
根据媒体信息,返回Format字典
|
||||
:param meta: 文件元数据
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param file_ext: 文件扩展名
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
"""
|
||||
# 获取集标题
|
||||
episode_title = None
|
||||
if meta.begin_episode and episodes_info:
|
||||
for episode in episodes_info:
|
||||
if episode.episode_number == meta.begin_episode:
|
||||
episode_title = episode.name
|
||||
break
|
||||
|
||||
return {
|
||||
# 标题
|
||||
"title": mediainfo.title,
|
||||
# 原文件名
|
||||
"original_name": meta.org_string,
|
||||
"original_name": f"{meta.org_string}{file_ext}",
|
||||
# 原语种标题
|
||||
"original_title": mediainfo.original_title,
|
||||
# 识别名称
|
||||
"name": meta.name,
|
||||
# 年份
|
||||
"year": mediainfo.year,
|
||||
"year": mediainfo.year or meta.year,
|
||||
# 资源类型
|
||||
"resourceType": meta.resource_type,
|
||||
# 特效
|
||||
"effect": meta.resource_effect,
|
||||
# 版本
|
||||
"edition": meta.edition,
|
||||
# 分辨率
|
||||
"videoFormat": meta.resource_pix,
|
||||
# 制作组/字幕组
|
||||
"releaseGroup": meta.resource_team,
|
||||
# 特效
|
||||
"effect": meta.resource_effect,
|
||||
# 视频编码
|
||||
"videoCodec": meta.video_encode,
|
||||
# 音频编码
|
||||
@@ -551,8 +539,12 @@ class FileTransferModule(_ModuleBase):
|
||||
"season_episode": "%s%s" % (meta.season, meta.episodes),
|
||||
# 段/节
|
||||
"part": meta.part,
|
||||
# 剧集标题
|
||||
"episode_title": episode_title,
|
||||
# 文件后缀
|
||||
"fileExt": file_ext
|
||||
"fileExt": file_ext,
|
||||
# 自定义占位符
|
||||
"customization": meta.customization
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -570,39 +562,125 @@ class FileTransferModule(_ModuleBase):
|
||||
else:
|
||||
return Path(render_str)
|
||||
|
||||
@staticmethod
|
||||
def get_library_path(path: Path):
|
||||
"""
|
||||
根据目录查询其所在的媒体库目录,查询不到的返回输入目录
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
if not settings.LIBRARY_PATHS:
|
||||
return path
|
||||
# 目的路径,多路径以,分隔
|
||||
dest_paths = settings.LIBRARY_PATHS
|
||||
for libpath in dest_paths:
|
||||
try:
|
||||
if path.is_relative_to(libpath):
|
||||
return libpath
|
||||
except Exception as e:
|
||||
logger.debug(f"计算媒体库路径时出错:{e}")
|
||||
continue
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def get_target_path(in_path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
计算一个最好的目的目录,有in_path时找与in_path同路径的,没有in_path时,顺序查找1个符合大小要求的,没有in_path和size时,返回第1个
|
||||
:param in_path: 源目录
|
||||
"""
|
||||
if not settings.LIBRARY_PATH:
|
||||
if not settings.LIBRARY_PATHS:
|
||||
return None
|
||||
# 目的路径,多路径以,分隔
|
||||
dest_paths = str(settings.LIBRARY_PATH).split(",")
|
||||
dest_paths = settings.LIBRARY_PATHS
|
||||
# 只有一个路径,直接返回
|
||||
if len(dest_paths) == 1:
|
||||
return Path(dest_paths[0])
|
||||
return dest_paths[0]
|
||||
# 匹配有最长共同上级路径的目录
|
||||
max_length = 0
|
||||
target_path = None
|
||||
if in_path:
|
||||
for path in dest_paths:
|
||||
try:
|
||||
relative = Path(path).relative_to(in_path).as_posix()
|
||||
if relative.startswith("..") or len(relative) > max_length:
|
||||
relative = in_path.relative_to(path).as_posix()
|
||||
if len(relative) > max_length:
|
||||
max_length = len(relative)
|
||||
target_path = path
|
||||
except Exception as e:
|
||||
logger.debug(f"计算目标路径时出错:{e}")
|
||||
continue
|
||||
if target_path:
|
||||
return Path(target_path)
|
||||
return target_path
|
||||
# 顺序匹配第1个满足空间存储要求的目录
|
||||
if in_path.exists():
|
||||
file_size = in_path.stat().st_size
|
||||
for path in dest_paths:
|
||||
if SystemUtils.free_space(Path(path)) > file_size:
|
||||
return Path(path)
|
||||
if SystemUtils.free_space(path) > file_size:
|
||||
return path
|
||||
# 默认返回第1个
|
||||
return Path(dest_paths[0])
|
||||
return dest_paths[0]
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||
"""
|
||||
判断媒体文件是否存在于本地文件系统
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param itemid: 媒体服务器ItemID
|
||||
:return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
|
||||
"""
|
||||
if not settings.LIBRARY_PATHS:
|
||||
return None
|
||||
# 目的路径
|
||||
dest_paths = settings.LIBRARY_PATHS
|
||||
# 检查每一个媒体库目录
|
||||
for dest_path in dest_paths:
|
||||
# 媒体库路径
|
||||
target_dir = self.get_target_path(dest_path)
|
||||
if not target_dir:
|
||||
continue
|
||||
# 媒体分类路径
|
||||
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
# 相对路径
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
rel_path = self.get_rename_path(
|
||||
template_string=rename_format,
|
||||
rename_dict=self.__get_naming_dict(meta=meta,
|
||||
mediainfo=mediainfo)
|
||||
)
|
||||
# 取相对路径的第1层目录
|
||||
if rel_path.parts:
|
||||
media_path = target_dir / rel_path.parts[0]
|
||||
else:
|
||||
continue
|
||||
|
||||
# 检查媒体文件夹是否存在
|
||||
if not media_path.exists():
|
||||
continue
|
||||
|
||||
# 检索媒体文件
|
||||
media_files = SystemUtils.list_files(directory=media_path, extensions=settings.RMT_MEDIAEXT)
|
||||
if not media_files:
|
||||
continue
|
||||
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影存在任何文件为存在
|
||||
logger.info(f"文件系统已存在:{mediainfo.title_year}")
|
||||
return ExistMediaInfo(type=MediaType.MOVIE)
|
||||
else:
|
||||
# 电视剧检索集数
|
||||
seasons: Dict[int, list] = {}
|
||||
for media_file in media_files:
|
||||
file_meta = MetaInfo(media_file.stem)
|
||||
season_index = file_meta.begin_season or 1
|
||||
episode_index = file_meta.begin_episode
|
||||
if not episode_index:
|
||||
continue
|
||||
if season_index not in seasons:
|
||||
seasons[season_index] = []
|
||||
seasons[season_index].append(episode_index)
|
||||
# 返回剧集情况
|
||||
logger.info(f"{mediainfo.title_year} 文件系统已存在:{seasons}")
|
||||
return ExistMediaInfo(type=MediaType.TV, seasons=seasons)
|
||||
# 不存在
|
||||
return None
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user