mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-07 16:53:03 +08:00
Compare commits
399 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8e161661c | ||
|
|
2b07766f9a | ||
|
|
adeb5361ab | ||
|
|
bd6e43c41d | ||
|
|
450289c7b7 | ||
|
|
aa93c560e5 | ||
|
|
22b1ebe1cf | ||
|
|
84bcf15e9b | ||
|
|
5b66803f6d | ||
|
|
88cbde47da | ||
|
|
03b96fa88b | ||
|
|
397a8a9536 | ||
|
|
1da0a706a3 | ||
|
|
4f2a110b5f | ||
|
|
bb356ffcee | ||
|
|
6c986416ca | ||
|
|
951ec138ef | ||
|
|
23e779ed94 | ||
|
|
29fccd3887 | ||
|
|
1bef723332 | ||
|
|
3c41fed0ef | ||
|
|
5947d0e6d0 | ||
|
|
0e4fa86372 | ||
|
|
f32405b646 | ||
|
|
13955dafe3 | ||
|
|
eaca396a9f | ||
|
|
fabd9f2f75 | ||
|
|
0d8480769f | ||
|
|
dc850f1c48 | ||
|
|
fb311f3d8a | ||
|
|
293d89510a | ||
|
|
9446e88012 | ||
|
|
6f593beeed | ||
|
|
0dc20cd9b4 | ||
|
|
a0543e914e | ||
|
|
1435cd6526 | ||
|
|
7e24181c37 | ||
|
|
922c391ffc | ||
|
|
39169e8faa | ||
|
|
433712aa80 | ||
|
|
23650657cd | ||
|
|
b5d58b8a9e | ||
|
|
0514ff0189 | ||
|
|
9a15e3f9b3 | ||
|
|
104113852a | ||
|
|
430702abd3 | ||
|
|
d7300777cb | ||
|
|
4fd61a9c8d | ||
|
|
af2b4aa867 | ||
|
|
7e252f1692 | ||
|
|
a7e7174cb2 | ||
|
|
6e2d0c2aad | ||
|
|
aeb65d7cac | ||
|
|
e7c580d375 | ||
|
|
90fedade76 | ||
|
|
49d9715106 | ||
|
|
c194e8c59a | ||
|
|
b6f9315e2b | ||
|
|
f91f99de52 | ||
|
|
3ad3a769ab | ||
|
|
261bb5fa81 | ||
|
|
704dcf46d3 | ||
|
|
9fab50edb0 | ||
|
|
5d2a911849 | ||
|
|
89e96ee27a | ||
|
|
41636395ff | ||
|
|
6f1f89ac26 | ||
|
|
607eb4b4aa | ||
|
|
3078c076dc | ||
|
|
a7794fa2ad | ||
|
|
846b4e645c | ||
|
|
3775e99b02 | ||
|
|
cea77bddee | ||
|
|
8ac0d169d2 | ||
|
|
d5ac9f65f6 | ||
|
|
4b3f04c73f | ||
|
|
bb478c949a | ||
|
|
11b1003d4d | ||
|
|
c0ad5f2970 | ||
|
|
54c98cf3a1 | ||
|
|
dfbe8a2c0e | ||
|
|
873f80d534 | ||
|
|
089992db74 | ||
|
|
f07ab73fde | ||
|
|
166674bfe7 | ||
|
|
adb4a8fe01 | ||
|
|
c49e79dda3 | ||
|
|
a3b5e51356 | ||
|
|
8f91e23208 | ||
|
|
b768929cd8 | ||
|
|
49d5e5b953 | ||
|
|
ce4792e87b | ||
|
|
3ea0b1f36b | ||
|
|
51c7852b77 | ||
|
|
7947f10579 | ||
|
|
fca9297fa7 | ||
|
|
0ec5e3b365 | ||
|
|
c18937ecc7 | ||
|
|
8b962757b7 | ||
|
|
2b40e42965 | ||
|
|
0eac7816bc | ||
|
|
e3552d4086 | ||
|
|
75bb52ccca | ||
|
|
22c485d177 | ||
|
|
78dab5038c | ||
|
|
15cc02b083 | ||
|
|
419f2e90ce | ||
|
|
a29e3c23fe | ||
|
|
aa9ae4dd09 | ||
|
|
d02bf33345 | ||
|
|
0a1dc1724c | ||
|
|
80b866e135 | ||
|
|
e7030c734e | ||
|
|
e5458ee127 | ||
|
|
3f60cb3f7d | ||
|
|
8c800836d5 | ||
|
|
abfc146335 | ||
|
|
dd4ff03b08 | ||
|
|
be792cb40a | ||
|
|
cec5cf22de | ||
|
|
6ec5f3b98b | ||
|
|
0ac43fd3c7 | ||
|
|
a600f2f05b | ||
|
|
0c0a1c1dad | ||
|
|
c69df36b98 | ||
|
|
20ac9fbfbe | ||
|
|
b9756db115 | ||
|
|
5bfa36418b | ||
|
|
30c696adfe | ||
|
|
31887ab4b1 | ||
|
|
3678de09bf | ||
|
|
3f9172146d | ||
|
|
fc4480644a | ||
|
|
2062214a3b | ||
|
|
01487cfdf6 | ||
|
|
a2c913a5b2 | ||
|
|
84f5d1c879 | ||
|
|
48c289edf2 | ||
|
|
c9949581ef | ||
|
|
b4e3dc275d | ||
|
|
00f85836fa | ||
|
|
c4300332c9 | ||
|
|
10f8efc457 | ||
|
|
1b48eb8959 | ||
|
|
61d7374d95 | ||
|
|
baa48610ea | ||
|
|
ece8d0368b | ||
|
|
a9ffebb3ea | ||
|
|
b6c043aae9 | ||
|
|
d45d49edbd | ||
|
|
27f474b192 | ||
|
|
544119c49f | ||
|
|
800a66dc99 | ||
|
|
33de1c3618 | ||
|
|
6fec16d78a | ||
|
|
a5d6062aa8 | ||
|
|
de532f47fb | ||
|
|
60bcc802cf | ||
|
|
c143545ef9 | ||
|
|
0e8fdac6d6 | ||
|
|
45e6dd1561 | ||
|
|
23c37c9a81 | ||
|
|
098279ceb6 | ||
|
|
1fb791455e | ||
|
|
3339bbca50 | ||
|
|
ec77213ca6 | ||
|
|
de1c2c98d2 | ||
|
|
98247fa47a | ||
|
|
1eef95421a | ||
|
|
b8de563a45 | ||
|
|
fd5fbd779b | ||
|
|
cb07550388 | ||
|
|
a51632c0a3 | ||
|
|
9756bf6ac8 | ||
|
|
aaa96cff87 | ||
|
|
a50959d254 | ||
|
|
b1bd858df1 | ||
|
|
c2d6d9b1ac | ||
|
|
7288dd24e0 | ||
|
|
8f05ea581c | ||
|
|
03a0bc907b | ||
|
|
5ce4c8a055 | ||
|
|
b04181fed9 | ||
|
|
eee843bafd | ||
|
|
134fd0761d | ||
|
|
669481af06 | ||
|
|
b5640b3179 | ||
|
|
9abb305dbb | ||
|
|
0fd4791479 | ||
|
|
ce2ecdf44c | ||
|
|
949c0d3b76 | ||
|
|
316915842a | ||
|
|
1dd7dc36c3 | ||
|
|
fca763b814 | ||
|
|
9311125c72 | ||
|
|
3f1d4933c1 | ||
|
|
7fb23b5069 | ||
|
|
d74ad343f1 | ||
|
|
c0a8351e58 | ||
|
|
8e309e8658 | ||
|
|
3400a9f87a | ||
|
|
c6830059b2 | ||
|
|
7e4a18b365 | ||
|
|
9ecc8c14d8 | ||
|
|
a3c048b9c8 | ||
|
|
3c08054234 | ||
|
|
07e91d4eb1 | ||
|
|
c104498b43 | ||
|
|
91ba71ad23 | ||
|
|
5ae8914060 | ||
|
|
77c8f1244f | ||
|
|
5d5c8a0af7 | ||
|
|
dcaf3e6678 | ||
|
|
c0170a173c | ||
|
|
d182a7079d | ||
|
|
b5cc5653b2 | ||
|
|
bdbd908b3a | ||
|
|
11fedb1ffc | ||
|
|
7de82f6c0d | ||
|
|
782829c992 | ||
|
|
6ab76453d4 | ||
|
|
56767b92d7 | ||
|
|
621df40c66 | ||
|
|
ba7cb76640 | ||
|
|
d353853472 | ||
|
|
1fcf5f4709 | ||
|
|
0ec4630461 | ||
|
|
fa45dea1aa | ||
|
|
2217583052 | ||
|
|
f4dc7a133e | ||
|
|
26b1e64bad | ||
|
|
a1d8af6521 | ||
|
|
9fb3d093ff | ||
|
|
8c9b37a12f | ||
|
|
73e4596d1a | ||
|
|
83798e6823 | ||
|
|
6d9595b643 | ||
|
|
dc047d949d | ||
|
|
a31b4bc0a1 | ||
|
|
94b8633803 | ||
|
|
107e85033f | ||
|
|
eea8060182 | ||
|
|
83f7869de4 | ||
|
|
4f0eff8b88 | ||
|
|
58b438c345 | ||
|
|
bc57bb1a78 | ||
|
|
e08ab0dd33 | ||
|
|
64bfa246ae | ||
|
|
cde4db1a56 | ||
|
|
29ae910953 | ||
|
|
314f90cc40 | ||
|
|
1c22e3d024 | ||
|
|
233d62479f | ||
|
|
6974f2ebd7 | ||
|
|
c030166cf5 | ||
|
|
4c511eaea6 | ||
|
|
6e443a1127 | ||
|
|
896e473c41 | ||
|
|
12f10ebedf | ||
|
|
ba9f85747c | ||
|
|
2954c02a7c | ||
|
|
312e602f12 | ||
|
|
ed37fcbb07 | ||
|
|
6acf8fbf00 | ||
|
|
a1e178c805 | ||
|
|
922e2fc446 | ||
|
|
db4c8cb3f2 | ||
|
|
1c578746fe | ||
|
|
68f88117b6 | ||
|
|
108c0a89f6 | ||
|
|
92dacdf6a2 | ||
|
|
6aa684d6a5 | ||
|
|
efece8cc56 | ||
|
|
383c8ca19a | ||
|
|
0a73681280 | ||
|
|
c1ecda280c | ||
|
|
825fc35134 | ||
|
|
8f543ca602 | ||
|
|
f0ecc1a497 | ||
|
|
71f170a1ad | ||
|
|
3709b65b0e | ||
|
|
9d6eb0f1e1 | ||
|
|
c93306147b | ||
|
|
5e8f924a2f | ||
|
|
54988d6397 | ||
|
|
112761dc4c | ||
|
|
ef20508840 | ||
|
|
589a1765ed | ||
|
|
2c666e24f3 | ||
|
|
168e3c5533 | ||
|
|
cda8b2573a | ||
|
|
4cb4eb23b8 | ||
|
|
f208b65570 | ||
|
|
8a0a530036 | ||
|
|
76643f13ed | ||
|
|
6992284a77 | ||
|
|
9a142799cd | ||
|
|
027d1567c3 | ||
|
|
65af737dfd | ||
|
|
48aa0e3d0b | ||
|
|
b4e31893ff | ||
|
|
4f1b95352a | ||
|
|
ca664cb569 | ||
|
|
fe4ea73286 | ||
|
|
9e9cca6de4 | ||
|
|
2e7e74c803 | ||
|
|
916597047d | ||
|
|
83fc474dbe | ||
|
|
f67bf49e69 | ||
|
|
bf9043f526 | ||
|
|
a98de604a1 | ||
|
|
e160a745a7 | ||
|
|
7f2c6ef167 | ||
|
|
2086651dbe | ||
|
|
132fde2308 | ||
|
|
4e27a1e623 | ||
|
|
a453831deb | ||
|
|
1035ceb4ac | ||
|
|
b7cb917347 | ||
|
|
680ad164dc | ||
|
|
aed68253e9 | ||
|
|
b83c7a5656 | ||
|
|
491456b0a2 | ||
|
|
84465a6536 | ||
|
|
9acbcf4922 | ||
|
|
8dc4290695 | ||
|
|
5c95945691 | ||
|
|
11115d50fb | ||
|
|
7f83d56a7e | ||
|
|
28805e9e17 | ||
|
|
88a098abc1 | ||
|
|
a3cc9830de | ||
|
|
43623efa99 | ||
|
|
ff73b2cb5d | ||
|
|
6cab14366c | ||
|
|
576d215d8c | ||
|
|
a2c10c86bf | ||
|
|
21bede3f00 | ||
|
|
0a39322281 | ||
|
|
be323d3da1 | ||
|
|
fa8860bf62 | ||
|
|
a700958edb | ||
|
|
9349973d16 | ||
|
|
c0d3637d12 | ||
|
|
79473ca229 | ||
|
|
fccbe39547 | ||
|
|
85324acacc | ||
|
|
9dec4d704b | ||
|
|
72732277a1 | ||
|
|
8d737f9e37 | ||
|
|
96b3746caa | ||
|
|
c690ea3c39 | ||
|
|
3282fb88e0 | ||
|
|
b9c2b9a044 | ||
|
|
24b58dc002 | ||
|
|
42c56497c6 | ||
|
|
c7512d1580 | ||
|
|
7d25bf7b48 | ||
|
|
99daa3a95e | ||
|
|
0a923bced9 | ||
|
|
06e3b0def2 | ||
|
|
0feecc3eca | ||
|
|
0afbc58263 | ||
|
|
7c7561029a | ||
|
|
65683999e1 | ||
|
|
f72e26015f | ||
|
|
b4e5c50655 | ||
|
|
f395dc68c3 | ||
|
|
27cf5bb7e6 | ||
|
|
9b573535cd | ||
|
|
cb32305b86 | ||
|
|
f7164450d0 | ||
|
|
344862dbd4 | ||
|
|
f1d0e9d50a | ||
|
|
9ba9e8f41c | ||
|
|
78fc5b7017 | ||
|
|
fe07830b71 | ||
|
|
350f1faf2a | ||
|
|
103cfe0b47 | ||
|
|
0953c1be16 | ||
|
|
c299bf6f7c | ||
|
|
c0eb9d824c | ||
|
|
ebffdebdb2 | ||
|
|
acd9e38477 | ||
|
|
9f4cf530f8 | ||
|
|
84897aa592 | ||
|
|
23c5982f5a | ||
|
|
1849930b72 | ||
|
|
4f1d3a7572 | ||
|
|
824c3ac5d6 | ||
|
|
1cec6ed6d1 | ||
|
|
fff75c7fe2 | ||
|
|
81fecf1e07 | ||
|
|
ad8f687f8e | ||
|
|
a3172d7503 | ||
|
|
8d5e0b26d5 | ||
|
|
b1b980f550 | ||
|
|
8196589cff | ||
|
|
cb9f41cb65 |
45
.github/ISSUE_TEMPLATE/rfc.yml
vendored
Normal file
45
.github/ISSUE_TEMPLATE/rfc.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: 功能提案
|
||||
description: Request for Comments
|
||||
title: "[RFC]"
|
||||
labels: ["RFC"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
一份提案(RFC)定位为 **「在某功能/重构的具体开发前,用于开发者间 review 技术设计/方案的文档」**,
|
||||
目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论;
|
||||
以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突),
|
||||
因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。
|
||||
|
||||
如果仅希望讨论是否添加或改进某功能本身,请使用 -> [Issue: 功能改进](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.yml&title=%5BFeature+Request%5D%3A+)
|
||||
- type: textarea
|
||||
id: background
|
||||
attributes:
|
||||
label: 背景 or 问题
|
||||
description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: goal
|
||||
attributes:
|
||||
label: "目标 & 方案简述"
|
||||
description: 简单描述提案此提案实现后,**预期的目标效果**,以及简单大致描述会采取的方案/步骤,可能会/不会产生什么影响。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: design
|
||||
attributes:
|
||||
label: "方案设计 & 实现步骤"
|
||||
description: |
|
||||
详细描述你设计的具体方案,可以考虑拆分列表或要点,一步步描述具体打算如何实现的步骤和相关细节。
|
||||
这部份不需要一次性写完整,即使在创建完此提案 issue 后,依旧可以再次编辑修改。
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: alternative
|
||||
attributes:
|
||||
label: "替代方案 & 对比"
|
||||
description: |
|
||||
[可选] 为来实现目标效果,还考虑过什么其他方案,有什么对比?
|
||||
validations:
|
||||
required: false
|
||||
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
@@ -5,10 +5,7 @@ on:
|
||||
branches:
|
||||
- v2
|
||||
paths:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'Dockerfile'
|
||||
- 'version.py'
|
||||
- 'requirements.in'
|
||||
|
||||
jobs:
|
||||
Docker-build:
|
||||
|
||||
@@ -10,10 +10,7 @@ ENV LANG="C.UTF-8" \
|
||||
UMASK=000 \
|
||||
PORT=3001 \
|
||||
NGINX_PORT=3000 \
|
||||
PROXY_HOST="" \
|
||||
MOVIEPILOT_AUTO_UPDATE=false \
|
||||
AUTH_SITE="iyuu" \
|
||||
IYUU_SIGN=""
|
||||
MOVIEPILOT_AUTO_UPDATE=release
|
||||
WORKDIR "/app"
|
||||
RUN apt-get update -y \
|
||||
&& apt-get upgrade -y \
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
|
||||
@@ -17,10 +18,7 @@ def calendar(page: int = 1,
|
||||
"""
|
||||
浏览Bangumi每日放送
|
||||
"""
|
||||
medias = BangumiChain().calendar()
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||
return []
|
||||
return RecommendChain().bangumi_calendar(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson])
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.schemas import MediaType
|
||||
@@ -40,10 +41,7 @@ def movie_showing(page: int = 1,
|
||||
"""
|
||||
浏览豆瓣正在热映
|
||||
"""
|
||||
movies = DoubanChain().movie_showing(page=page, count=count)
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
return RecommendChain().douban_movie_showing(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
|
||||
@@ -55,11 +53,7 @@ def douban_movies(sort: str = "R",
|
||||
"""
|
||||
浏览豆瓣电影信息
|
||||
"""
|
||||
movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
return RecommendChain().douban_movies(sort=sort, tags=tags, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
|
||||
@@ -71,11 +65,7 @@ def douban_tvs(sort: str = "R",
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
return RecommendChain().douban_tvs(sort=sort, tags=tags, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
||||
@@ -85,10 +75,7 @@ def movie_top250(page: int = 1,
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
movies = DoubanChain().movie_top250(page=page, count=count)
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
return RecommendChain().douban_movie_top250(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
@@ -98,10 +85,7 @@ def tv_weekly_chinese(page: int = 1,
|
||||
"""
|
||||
中国每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
return RecommendChain().douban_tv_weekly_chinese(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
@@ -111,10 +95,7 @@ def tv_weekly_global(page: int = 1,
|
||||
"""
|
||||
全球每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
return RecommendChain().douban_tv_weekly_global(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
|
||||
@@ -124,10 +105,7 @@ def tv_animation(page: int = 1,
|
||||
"""
|
||||
热门动画剧集
|
||||
"""
|
||||
tvs = DoubanChain().tv_animation(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
return RecommendChain().douban_tv_animation(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
|
||||
@@ -137,10 +115,7 @@ def movie_hot(page: int = 1,
|
||||
"""
|
||||
热门电影
|
||||
"""
|
||||
movies = DoubanChain().movie_hot(page=page, count=count)
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
return RecommendChain().douban_movie_hot(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
|
||||
@@ -150,10 +125,7 @@ def tv_hot(page: int = 1,
|
||||
"""
|
||||
热门电视剧
|
||||
"""
|
||||
tvs = DoubanChain().tv_hot(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
return RecommendChain().douban_tv_hot(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson])
|
||||
|
||||
@@ -9,13 +9,15 @@ 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.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_user
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
||||
def list(
|
||||
def current(
|
||||
name: str = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -49,7 +51,7 @@ def download(
|
||||
torrent_info=torrentinfo
|
||||
)
|
||||
did = DownloadChain().download_single(context=context, username=current_user.name,
|
||||
downloader=downloader, save_path=save_path)
|
||||
downloader=downloader, save_path=save_path, source="Manual")
|
||||
if not did:
|
||||
return schemas.Response(success=False, message="任务添加失败")
|
||||
return schemas.Response(success=True, data={
|
||||
@@ -82,7 +84,7 @@ def add(
|
||||
torrent_info=torrentinfo
|
||||
)
|
||||
did = DownloadChain().download_single(context=context, username=current_user.name,
|
||||
downloader=downloader, save_path=save_path)
|
||||
downloader=downloader, save_path=save_path, source="Manual")
|
||||
if not did:
|
||||
return schemas.Response(success=False, message="任务添加失败")
|
||||
return schemas.Response(success=True, data={
|
||||
@@ -111,6 +113,17 @@ def stop(hashString: str,
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@router.get("/clients", summary="查询可用下载器", response_model=List[dict])
|
||||
def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询可用下载器
|
||||
"""
|
||||
downloaders: List[dict] = SystemConfigOper().get(SystemConfigKey.Downloaders)
|
||||
if downloaders:
|
||||
return [{"name": d.get("name"), "type": d.get("type")} for d in downloaders if d.get("enabled")]
|
||||
return []
|
||||
|
||||
|
||||
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
||||
def delete(hashString: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from typing import List, Any
|
||||
|
||||
import jieba
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.storage import StorageChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
@@ -39,7 +41,7 @@ def delete_download_history(history_in: schemas.DownloadHistory,
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/transfer", summary="查询转移历史记录", response_model=schemas.Response)
|
||||
@router.get("/transfer", summary="查询整理记录", response_model=schemas.Response)
|
||||
def transfer_history(title: str = None,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
@@ -47,7 +49,7 @@ def transfer_history(title: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询转移历史记录
|
||||
查询整理记录
|
||||
"""
|
||||
if title == "失败":
|
||||
title = None
|
||||
@@ -57,6 +59,9 @@ def transfer_history(title: str = None,
|
||||
status = True
|
||||
|
||||
if title:
|
||||
if settings.TOKENIZED_SEARCH:
|
||||
words = jieba.cut(title, HMM=False)
|
||||
title = "%".join(words)
|
||||
total = TransferHistory.count_by_title(db, title=title, status=status)
|
||||
result = TransferHistory.list_by_title(db, title=title, page=page,
|
||||
count=count, status=status)
|
||||
@@ -71,14 +76,14 @@ def transfer_history(title: str = None,
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/transfer", summary="删除转移历史记录", response_model=schemas.Response)
|
||||
@router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response)
|
||||
def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
deletesrc: bool = False,
|
||||
deletedest: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
删除转移历史记录
|
||||
删除整理记录
|
||||
"""
|
||||
history: TransferHistory = TransferHistory.get(db, history_in.id)
|
||||
if not history:
|
||||
@@ -86,9 +91,7 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
# 册除媒体库文件
|
||||
if deletedest and history.dest_fileitem:
|
||||
dest_fileitem = schemas.FileItem(**history.dest_fileitem)
|
||||
state = StorageChain().delete_media_file(fileitem=dest_fileitem, mtype=MediaType(history.type))
|
||||
if not state:
|
||||
return schemas.Response(success=False, message=f"{dest_fileitem.path} 删除失败")
|
||||
StorageChain().delete_media_file(fileitem=dest_fileitem, mtype=MediaType(history.type))
|
||||
|
||||
# 删除源文件
|
||||
if deletesrc and history.src_fileitem:
|
||||
@@ -109,11 +112,11 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/empty/transfer", summary="清空转移历史记录", response_model=schemas.Response)
|
||||
@router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response)
|
||||
def delete_transfer_history(db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
清空转移历史记录
|
||||
清空整理记录
|
||||
"""
|
||||
TransferHistory.truncate(db)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -72,7 +72,7 @@ def search(title: str,
|
||||
"""
|
||||
模糊搜索媒体/人物信息列表 media:媒体信息,person:人物信息
|
||||
"""
|
||||
def __get_source(obj: Union[dict, schemas.MediaPerson]):
|
||||
def __get_source(obj: Union[schemas.MediaInfo, schemas.MediaPerson, dict]):
|
||||
"""
|
||||
获取对象属性
|
||||
"""
|
||||
@@ -85,6 +85,8 @@ def search(title: str,
|
||||
_, medias = MediaChain().search(title=title)
|
||||
if medias:
|
||||
result = [media.to_dict() for media in medias]
|
||||
elif type == "collection":
|
||||
result = MediaChain().search_collections(name=title)
|
||||
else:
|
||||
result = MediaChain().search_persons(name=title)
|
||||
if result:
|
||||
@@ -111,13 +113,13 @@ def scrape(fileitem: schemas.FileItem,
|
||||
scrape_path = Path(fileitem.path)
|
||||
meta = MetaInfoPath(scrape_path)
|
||||
mediainfo = chain.recognize_by_meta(meta)
|
||||
if not media_info:
|
||||
if not mediainfo:
|
||||
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
|
||||
if storage == "local":
|
||||
if not scrape_path.exists():
|
||||
return schemas.Response(success=False, message="刮削路径不存在")
|
||||
# 手动刮削
|
||||
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True)
|
||||
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||||
|
||||
|
||||
|
||||
@@ -12,8 +12,10 @@ from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.db.models import MediaServerItem
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.mediaserver import MediaServerHelper
|
||||
from app.schemas import MediaType, NotExistMediaInfo
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -143,3 +145,14 @@ def library(server: str, hidden: bool = False,
|
||||
获取媒体服务器媒体库列表
|
||||
"""
|
||||
return MediaServerChain().librarys(server=server, username=userinfo.username, hidden=hidden) or []
|
||||
|
||||
|
||||
@router.get("/clients", summary="查询可用媒体服务器", response_model=List[dict])
|
||||
def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询可用媒体服务器
|
||||
"""
|
||||
mediaservers: List[dict] = SystemConfigOper().get(SystemConfigKey.MediaServers)
|
||||
if mediaservers:
|
||||
return [{"name": d.get("name"), "type": d.get("type")} for d in mediaservers if d.get("enabled")]
|
||||
return []
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Annotated, Any, List, Optional
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
|
||||
from app import schemas
|
||||
from app.chain.command import CommandChain
|
||||
from app.command import Command
|
||||
from app.core.config import settings
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_apikey, verify_token
|
||||
@@ -212,7 +212,7 @@ def install(plugin_id: str,
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
CommandChain().init_commands(plugin_id)
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
@@ -280,7 +280,7 @@ def reset_plugin(plugin_id: str,
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
CommandChain().init_commands(plugin_id)
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
@@ -308,7 +308,7 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
CommandChain().init_commands(plugin_id)
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -8,6 +8,7 @@ from app import schemas
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.event import EventManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models import User
|
||||
@@ -351,6 +352,8 @@ def auth_site(
|
||||
return schemas.Response(success=False, message="请输入认证站点和认证参数")
|
||||
status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)
|
||||
SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.dict())
|
||||
PluginManager().init_config()
|
||||
Scheduler().init_plugin_jobs()
|
||||
return schemas.Response(success=status, message=msg)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from app import schemas
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import eventmanager
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token, verify_apitoken
|
||||
from app.db import get_db
|
||||
@@ -17,7 +18,7 @@ from app.db.models.user import User
|
||||
from app.db.user_oper import get_current_active_user
|
||||
from app.helper.subscribe import SubscribeHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaType, EventType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -121,6 +122,32 @@ def update_subscribe(
|
||||
if subscribe_in.total_episode != subscribe.total_episode:
|
||||
subscribe_dict["manual_total_episode"] = 1
|
||||
subscribe.update(db, subscribe_dict)
|
||||
# 发送订阅调整事件
|
||||
eventmanager.send_event(EventType.SubscribeModified, {
|
||||
"subscribe_id": subscribe.id,
|
||||
"subscribe_info": subscribe_dict,
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response)
|
||||
def update_subscribe_status(
|
||||
subid: int,
|
||||
state: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
更新订阅状态
|
||||
"""
|
||||
subscribe = Subscribe.get(db, subid)
|
||||
if not subscribe:
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
valid_states = ["R", "P", "S"]
|
||||
if state not in valid_states:
|
||||
return schemas.Response(success=False, message="无效的订阅状态")
|
||||
subscribe.update(db, {
|
||||
"state": state
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -186,8 +213,9 @@ def reset_subscribes(
|
||||
subscribe = Subscribe.get(db, subid)
|
||||
if subscribe:
|
||||
subscribe.update(db, {
|
||||
"note": "",
|
||||
"lack_episode": subscribe.total_episode
|
||||
"note": [],
|
||||
"lack_episode": subscribe.total_episode,
|
||||
"state": "R"
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
@@ -252,17 +280,27 @@ def delete_subscribe_by_mediaid(
|
||||
"""
|
||||
根据TMDBID或豆瓣ID删除订阅 tmdb:/douban:
|
||||
"""
|
||||
delete_subscribes = []
|
||||
if mediaid.startswith("tmdb:"):
|
||||
tmdbid = mediaid[5:]
|
||||
if not tmdbid or not str(tmdbid).isdigit():
|
||||
return schemas.Response(success=False)
|
||||
Subscribe().delete_by_tmdbid(db, int(tmdbid), season)
|
||||
subscribes = Subscribe().get_by_tmdbid(db, int(tmdbid), season)
|
||||
delete_subscribes.extend(subscribes)
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid[7:]
|
||||
if not doubanid:
|
||||
return schemas.Response(success=False)
|
||||
Subscribe().delete_by_doubanid(db, doubanid)
|
||||
|
||||
subscribe = Subscribe().get_by_doubanid(db, doubanid)
|
||||
if subscribe:
|
||||
delete_subscribes.append(subscribe)
|
||||
for subscribe in delete_subscribes:
|
||||
Subscribe().delete(db, subscribe.id)
|
||||
# 发送事件
|
||||
eventmanager.send_event(EventType.SubscribeDeleted, {
|
||||
"subscribe_id": subscribe.id,
|
||||
"subscribe_info": subscribe.to_dict()
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -429,6 +467,17 @@ def subscribe_share(
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
|
||||
def subscribe_share_delete(
|
||||
share_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除分享
|
||||
"""
|
||||
state, errmsg = SubscribeHelper().share_delete(share_id=share_id)
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.post("/fork", summary="复用订阅", response_model=schemas.Response)
|
||||
def subscribe_fork(
|
||||
sub: schemas.SubscribeShare,
|
||||
@@ -485,9 +534,14 @@ def delete_subscribe(
|
||||
subscribe = Subscribe.get(db, subscribe_id)
|
||||
if subscribe:
|
||||
subscribe.delete(db, subscribe_id)
|
||||
# 统计订阅
|
||||
SubscribeHelper().sub_done_async({
|
||||
"tmdbid": subscribe.tmdbid,
|
||||
"doubanid": subscribe.doubanid
|
||||
})
|
||||
# 发送事件
|
||||
eventmanager.send_event(EventType.SubscribeDeleted, {
|
||||
"subscribe_id": subscribe_id,
|
||||
"subscribe_info": subscribe.to_dict()
|
||||
})
|
||||
# 统计订阅
|
||||
SubscribeHelper().sub_done_async({
|
||||
"tmdbid": subscribe.tmdbid,
|
||||
"doubanid": subscribe.doubanid
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -16,6 +16,7 @@ from app import schemas
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.system import SystemChain
|
||||
from app.core.config import global_vars, settings
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.security import verify_apitoken, verify_resource_token, verify_token
|
||||
from app.db.models import User
|
||||
@@ -173,6 +174,10 @@ def get_global_setting():
|
||||
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY", "API_TOKEN", "TMDB_API_KEY", "TVDB_API_KEY", "FANART_API_KEY",
|
||||
"COOKIECLOUD_KEY", "COOKIECLOUD_PASSWORD", "GITHUB_TOKEN", "REPO_GITHUB_TOKEN"}
|
||||
)
|
||||
# 追加用户唯一ID
|
||||
info.update({
|
||||
"USER_UNIQUE_ID": SystemUtils.generate_user_unique_id()
|
||||
})
|
||||
return schemas.Response(success=True,
|
||||
data=info)
|
||||
|
||||
@@ -385,9 +390,14 @@ def ruletest(title: str,
|
||||
if not rulegroup:
|
||||
return schemas.Response(success=False, message=f"过滤规则组 {rulegroup_name} 不存在!")
|
||||
|
||||
# 根据标题查询媒体信息
|
||||
media_info = SearchChain().recognize_media(MetaInfo(title=title, subtitle=subtitle))
|
||||
if not media_info:
|
||||
return schemas.Response(success=False, message="未识别到媒体信息!")
|
||||
|
||||
# 过滤
|
||||
result = SearchChain().filter_torrents(rule_groups=[rulegroup.name],
|
||||
torrent_list=[torrent])
|
||||
torrent_list=[torrent], mediainfo=media_info)
|
||||
if not result:
|
||||
return schemas.Response(success=False, message="不符合过滤规则!")
|
||||
return schemas.Response(success=True, data={
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import List, Any
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.security import verify_token
|
||||
from app.schemas.types import MediaType
|
||||
@@ -59,6 +60,20 @@ def tmdb_recommend(tmdbid: int,
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/collection/{collection_id}", summary="系列合集详情", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_collection(collection_id: int,
|
||||
page: int = 1,
|
||||
count: int = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据合集ID查询合集详情
|
||||
"""
|
||||
medias = TmdbChain().tmdb_collection(collection_id=collection_id)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias][(page - 1) * count:page * count]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.MediaPerson])
|
||||
def tmdb_credits(tmdbid: int,
|
||||
type_name: str,
|
||||
@@ -108,14 +123,10 @@ def tmdb_movies(sort_by: str = "popularity.desc",
|
||||
"""
|
||||
浏览TMDB电影信息
|
||||
"""
|
||||
movies = TmdbChain().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 [movie.to_dict() for movie in movies]
|
||||
return RecommendChain().tmdb_movies(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
|
||||
|
||||
@router.get("/tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
|
||||
@@ -127,26 +138,19 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
tvs = TmdbChain().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 [tv.to_dict() for tv in tvs]
|
||||
return RecommendChain().tmdb_tvs(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
|
||||
|
||||
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_trending(page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
TMDB流行趋势
|
||||
"""
|
||||
infos = TmdbChain().tmdb_trending(page=page)
|
||||
if not infos:
|
||||
return []
|
||||
return [info.to_dict() for info in infos]
|
||||
return RecommendChain().tmdb_trending(page=page)
|
||||
|
||||
|
||||
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
@@ -14,30 +13,11 @@ from app.core.security import verify_token, verify_apitoken
|
||||
from app.db import get_db
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.schemas import MediaType, FileItem
|
||||
from app.schemas import MediaType, FileItem, ManualTransferItem
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ManualTransferItem(BaseModel):
|
||||
fileitem: FileItem = None,
|
||||
logid: Optional[int] = None,
|
||||
target_storage: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
doubanid: Optional[str] = None,
|
||||
type_name: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
transfer_type: Optional[str] = None,
|
||||
episode_format: Optional[str] = None,
|
||||
episode_detail: Optional[str] = None,
|
||||
episode_part: Optional[str] = None,
|
||||
episode_offset: Optional[str] = None,
|
||||
min_filesize: Optional[int] = 0,
|
||||
scrape: bool = False,
|
||||
from_history: bool = False
|
||||
|
||||
|
||||
@router.get("/name", summary="查询整理后的名称", response_model=schemas.Response)
|
||||
def query_name(path: str, filetype: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
@@ -67,13 +47,35 @@ def query_name(path: str, filetype: str,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/queue", summary="查询整理队列", response_model=List[schemas.TransferJob])
|
||||
def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询整理队列
|
||||
:param _: Token校验
|
||||
"""
|
||||
return TransferChain().get_queue_tasks()
|
||||
|
||||
|
||||
@router.delete("/queue", summary="从整理队列中删除任务", response_model=schemas.Response)
|
||||
def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询整理队列
|
||||
:param fileitem: 文件项
|
||||
:param _: Token校验
|
||||
"""
|
||||
TransferChain().remove_from_queue(fileitem)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
||||
def manual_transfer(transer_item: ManualTransferItem,
|
||||
background: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
手动转移,文件或历史记录,支持自定义剧集识别格式
|
||||
:param transer_item: 手工整理项
|
||||
:param background: 后台运行
|
||||
:param db: 数据库
|
||||
:param _: Token校验
|
||||
"""
|
||||
@@ -83,7 +85,7 @@ def manual_transfer(transer_item: ManualTransferItem,
|
||||
# 查询历史记录
|
||||
history: TransferHistory = TransferHistory.get(db, transer_item.logid)
|
||||
if not history:
|
||||
return schemas.Response(success=False, message=f"历史记录不存在,ID:{transer_item.logid}")
|
||||
return schemas.Response(success=False, message=f"整理记录不存在,ID:{transer_item.logid}")
|
||||
# 强制转移
|
||||
force = True
|
||||
if history.status and ("move" in history.mode):
|
||||
@@ -148,7 +150,10 @@ def manual_transfer(transer_item: ManualTransferItem,
|
||||
epformat=epformat,
|
||||
min_filesize=transer_item.min_filesize,
|
||||
scrape=transer_item.scrape,
|
||||
force=force
|
||||
library_type_folder=transer_item.library_type_folder,
|
||||
library_category_folder=transer_item.library_category_folder,
|
||||
force=force,
|
||||
background=background
|
||||
)
|
||||
# 失败
|
||||
if not state:
|
||||
|
||||
@@ -19,7 +19,7 @@ class GzipRequest(Request):
|
||||
body = await super().body()
|
||||
if "gzip" in self.headers.getlist("Content-Encoding"):
|
||||
body = gzip.decompress(body)
|
||||
self._body = body
|
||||
self._body = body # noqa
|
||||
return self._body
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import gc
|
||||
import pickle
|
||||
import traceback
|
||||
@@ -61,7 +62,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
try:
|
||||
with open(settings.TEMP_PATH / filename, 'wb') as f:
|
||||
pickle.dump(cache, f)
|
||||
pickle.dump(cache, f) # noqa
|
||||
except Exception as err:
|
||||
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
|
||||
finally:
|
||||
@@ -300,6 +301,13 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("search_persons", name=name)
|
||||
|
||||
def search_collections(self, name: str) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索集合信息
|
||||
:param name: 集合名称
|
||||
"""
|
||||
return self.run_module("search_collections", name=name)
|
||||
|
||||
def search_torrents(self, site: CommentedMap,
|
||||
keywords: List[str],
|
||||
mtype: MediaType = None,
|
||||
@@ -325,24 +333,21 @@ class ChainBase(metaclass=ABCMeta):
|
||||
|
||||
def filter_torrents(self, rule_groups: List[str],
|
||||
torrent_list: List[TorrentInfo],
|
||||
season_episodes: Dict[int, list] = None,
|
||||
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
||||
"""
|
||||
过滤种子资源
|
||||
:param rule_groups: 过滤规则组名称列表
|
||||
:param torrent_list: 资源列表
|
||||
:param season_episodes: 季集数过滤 {season:[episodes]}
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 过滤后的资源列表,添加资源优先级
|
||||
"""
|
||||
return self.run_module("filter_torrents", rule_groups=rule_groups,
|
||||
torrent_list=torrent_list, season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
torrent_list=torrent_list, mediainfo=mediainfo)
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None,
|
||||
downloader: str = None
|
||||
) -> Optional[Tuple[Optional[str], Optional[str], str]]:
|
||||
) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
:param content: 种子文件地址或者磁力链接
|
||||
@@ -351,7 +356,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param episodes: 需要下载的集数
|
||||
:param category: 种子分类
|
||||
:param downloader: 下载器
|
||||
:return: 下载器名称、种子Hash、错误信息
|
||||
:return: 下载器名称、种子Hash、种子文件布局、错误原因
|
||||
"""
|
||||
return self.run_module("download", content=content, download_dir=download_dir,
|
||||
cookie=cookie, episodes=episodes, category=category,
|
||||
@@ -385,6 +390,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
target_directory: TransferDirectoryConf = None,
|
||||
target_storage: str = None, target_path: Path = None,
|
||||
transfer_type: str = None, scrape: bool = None,
|
||||
library_type_folder: bool = None, library_category_folder: bool = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
||||
"""
|
||||
文件转移
|
||||
@@ -396,6 +402,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param target_path: 目标路径
|
||||
:param transfer_type: 转移模式
|
||||
:param scrape: 是否刮削元数据
|
||||
:param library_type_folder: 是否按类型创建目录
|
||||
:param library_category_folder: 是否按类别创建目录
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
@@ -404,6 +412,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
target_directory=target_directory,
|
||||
target_path=target_path, target_storage=target_storage,
|
||||
transfer_type=transfer_type, scrape=scrape,
|
||||
library_type_folder=library_type_folder,
|
||||
library_category_folder=library_category_folder,
|
||||
episodes_info=episodes_info)
|
||||
|
||||
def transfer_completed(self, hashs: str, downloader: str = None) -> None:
|
||||
@@ -483,32 +493,58 @@ class ChainBase(metaclass=ABCMeta):
|
||||
f"title={message.title}, "
|
||||
f"text={message.text},"
|
||||
f"userid={message.userid}")
|
||||
if not message.userid and message.mtype:
|
||||
# 没有指定用户ID时,按规则确定发送对象
|
||||
# 默认发送全体
|
||||
to_targets = None
|
||||
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
|
||||
if notify_action == "admin":
|
||||
# 仅发送管理员
|
||||
logger.info(f"已设置 {message.mtype} 的消息只发送给管理员")
|
||||
to_targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
elif notify_action == "user":
|
||||
# 发送对应用户
|
||||
if message.username:
|
||||
logger.info(f"已设置 {message.mtype} 的消息只发送给用户 {message.username}")
|
||||
to_targets = self.useroper.get_settings(message.username)
|
||||
if not message.username or to_targets is None:
|
||||
if message.username:
|
||||
logger.info(f"没有 {message.username} 这个用户,该消息将发送给管理员")
|
||||
# 回滚发送管理员
|
||||
to_targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
message.targets = to_targets
|
||||
# 发送事件
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
|
||||
# 保存消息
|
||||
# 保存原消息
|
||||
self.messagehelper.put(message, role="user", title=message.title)
|
||||
self.messageoper.add(**message.dict())
|
||||
# 发送
|
||||
# 发送消息按设置隔离
|
||||
if not message.userid and message.mtype:
|
||||
# 消息隔离设置
|
||||
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
|
||||
if notify_action:
|
||||
# 'admin' 'user,admin' 'user' 'all'
|
||||
actions = notify_action.split(",")
|
||||
# 是否已发送管理员标志
|
||||
admin_sended = False
|
||||
send_orignal = False
|
||||
for action in actions:
|
||||
send_message = copy.deepcopy(message)
|
||||
if action == "admin" and not admin_sended:
|
||||
# 仅发送管理员
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
elif action == "user" and send_message.username:
|
||||
# 发送对应用户
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}")
|
||||
# 读取用户消息IDS
|
||||
send_message.targets = self.useroper.get_settings(send_message.username)
|
||||
if send_message.targets is None:
|
||||
# 没有找到用户
|
||||
if not admin_sended:
|
||||
# 回滚发送管理员
|
||||
logger.info(f"用户 {send_message.username} 不存在,消息将发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
else:
|
||||
# 管理员发过了,此消息不发了
|
||||
logger.info(f"用户 {send_message.username} 不存在,消息无法发送到对应用户")
|
||||
continue
|
||||
else:
|
||||
# 按原消息发送全体
|
||||
if not admin_sended:
|
||||
send_orignal = True
|
||||
break
|
||||
# 按设定发送
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage,
|
||||
data={**send_message.dict(), "type": send_message.mtype})
|
||||
self.run_module("post_message", message=send_message)
|
||||
if not send_orignal:
|
||||
return
|
||||
# 发送消息事件
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
|
||||
# 按原消息发送
|
||||
self.run_module("post_message", message=message)
|
||||
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||
|
||||
@@ -19,8 +19,8 @@ from app.helper.directory import DirectoryHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification
|
||||
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType
|
||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, ResourceDownloadEventData
|
||||
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ChainEventType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -191,7 +191,7 @@ class DownloadChain(ChainBase):
|
||||
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
source=source if channel else None,
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{torrent.title} 种子下载失败!",
|
||||
text=f"错误信息:{error_msg}\n站点:{torrent.site_name}",
|
||||
@@ -203,7 +203,8 @@ class DownloadChain(ChainBase):
|
||||
|
||||
def download_single(self, context: Context, torrent_file: Path = None,
|
||||
episodes: Set[int] = None,
|
||||
channel: MessageChannel = None, source: str = None,
|
||||
channel: MessageChannel = None,
|
||||
source: str = None,
|
||||
downloader: str = None,
|
||||
save_path: str = None,
|
||||
userid: Union[str, int] = None,
|
||||
@@ -215,13 +216,38 @@ class DownloadChain(ChainBase):
|
||||
:param torrent_file: 种子文件路径
|
||||
:param episodes: 需要下载的集数
|
||||
:param channel: 通知渠道
|
||||
:param source: 通知来源
|
||||
:param source: 来源(消息通知、Subscribe、Manual等)
|
||||
:param downloader: 下载器
|
||||
:param save_path: 保存路径
|
||||
:param userid: 用户ID
|
||||
:param username: 调用下载的用户名/插件名
|
||||
:param media_category: 自定义媒体类别
|
||||
"""
|
||||
# 发送资源下载事件,允许外部拦截下载
|
||||
event_data = ResourceDownloadEventData(
|
||||
context=context,
|
||||
episodes=episodes or context.meta_info.episode_list,
|
||||
channel=channel,
|
||||
origin=source,
|
||||
downloader=downloader,
|
||||
options={
|
||||
"save_path": save_path,
|
||||
"userid": userid,
|
||||
"username": username,
|
||||
"media_category": media_category
|
||||
}
|
||||
)
|
||||
# 触发资源下载事件
|
||||
event = eventmanager.send_event(ChainEventType.ResourceDownload, event_data)
|
||||
if event and event.event_data:
|
||||
event_data: ResourceDownloadEventData = event.event_data
|
||||
# 如果事件被取消,跳过资源下载
|
||||
if event_data.cancel:
|
||||
logger.debug(
|
||||
f"Resource download canceled by event: {event_data.source},"
|
||||
f"Reason: {event_data.reason}")
|
||||
return None
|
||||
|
||||
_torrent = context.torrent_info
|
||||
_media = context.media_info
|
||||
_meta = context.meta_info
|
||||
@@ -256,7 +282,7 @@ class DownloadChain(ChainBase):
|
||||
download_dir = Path(save_path)
|
||||
else:
|
||||
# 根据媒体信息查询下载目录配置
|
||||
dir_info = self.directoryhelper.get_dir(_media)
|
||||
dir_info = self.directoryhelper.get_dir(_media, storage="local", include_unsorted=True)
|
||||
# 拼装子目录
|
||||
if dir_info:
|
||||
# 一级目录
|
||||
@@ -286,16 +312,23 @@ class DownloadChain(ChainBase):
|
||||
category=_media.category,
|
||||
downloader=downloader or _site_downloader)
|
||||
if result:
|
||||
_downloader, _hash, error_msg = result
|
||||
_downloader, _hash, _layout, error_msg = result
|
||||
else:
|
||||
_downloader, _hash, error_msg = None, None, "未找到下载器"
|
||||
_downloader, _hash, _layout, error_msg = None, None, None, "未找到下载器"
|
||||
|
||||
if _hash:
|
||||
# 下载文件路径
|
||||
if _folder_name:
|
||||
download_path = download_dir / _folder_name
|
||||
else:
|
||||
# `不创建子文件夹` 或 `不存在子文件夹`
|
||||
if _layout == "NoSubfolder" or not _folder_name:
|
||||
# 下载路径记录至文件
|
||||
download_path = download_dir / _file_list[0] if _file_list else download_dir
|
||||
# 原始布局
|
||||
elif _folder_name:
|
||||
download_path = download_dir / _folder_name
|
||||
# 创建子文件夹
|
||||
else:
|
||||
download_path = download_dir / Path(_file_list[0]).stem if _file_list else download_dir
|
||||
# 文件保存路径
|
||||
_save_path = download_dir if _layout == "NoSubfolder" or not _folder_name else download_path
|
||||
|
||||
# 登记下载记录
|
||||
self.downloadhis.add(
|
||||
@@ -310,6 +343,7 @@ class DownloadChain(ChainBase):
|
||||
seasons=_meta.season,
|
||||
episodes=download_episodes or _meta.episode,
|
||||
image=_media.get_backdrop_image(),
|
||||
downloader=_downloader,
|
||||
download_hash=_hash,
|
||||
torrent_name=_torrent.title,
|
||||
torrent_description=_torrent.description,
|
||||
@@ -318,7 +352,8 @@ class DownloadChain(ChainBase):
|
||||
username=username,
|
||||
channel=channel.value if channel else None,
|
||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
media_category=media_category
|
||||
media_category=media_category,
|
||||
note={"source": source}
|
||||
)
|
||||
|
||||
# 登记下载文件
|
||||
@@ -337,8 +372,8 @@ class DownloadChain(ChainBase):
|
||||
files_to_add.append({
|
||||
"download_hash": _hash,
|
||||
"downloader": _downloader,
|
||||
"fullpath": str(download_dir / _folder_name / file),
|
||||
"savepath": str(download_dir / _folder_name),
|
||||
"fullpath": str(_save_path / file),
|
||||
"savepath": str(_save_path),
|
||||
"filepath": file,
|
||||
"torrentname": _meta.org_string,
|
||||
})
|
||||
@@ -355,7 +390,9 @@ class DownloadChain(ChainBase):
|
||||
"hash": _hash,
|
||||
"context": context,
|
||||
"username": username,
|
||||
"downloader": _downloader
|
||||
"downloader": _downloader,
|
||||
"episodes": episodes or _meta.episode_list,
|
||||
"source": source
|
||||
})
|
||||
else:
|
||||
# 下载失败
|
||||
@@ -364,7 +401,7 @@ class DownloadChain(ChainBase):
|
||||
# 只发送给对应渠道和用户
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
source=source if channel else None,
|
||||
mtype=NotificationType.Manual,
|
||||
title="添加下载任务失败:%s %s"
|
||||
% (_media.title_year, _meta.season_episode),
|
||||
@@ -392,7 +429,7 @@ class DownloadChain(ChainBase):
|
||||
:param no_exists: 缺失的剧集信息
|
||||
:param save_path: 保存路径
|
||||
:param channel: 通知渠道
|
||||
:param source: 通知来源
|
||||
:param source: 来源(消息通知、订阅、手工下载等)
|
||||
:param userid: 用户ID
|
||||
:param username: 调用下载的用户名/插件名
|
||||
:param media_category: 自定义媒体类别
|
||||
@@ -457,6 +494,22 @@ class DownloadChain(ChainBase):
|
||||
return 9999
|
||||
return no_exist[season].total_episode
|
||||
|
||||
# 发送资源选择事件,允许外部修改上下文数据
|
||||
logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}")
|
||||
event_data = ResourceSelectionEventData(
|
||||
contexts=contexts,
|
||||
downloader=downloader,
|
||||
origin=source
|
||||
)
|
||||
event = eventmanager.send_event(ChainEventType.ResourceSelection, event_data)
|
||||
# 如果事件修改了上下文数据,使用更新后的数据
|
||||
if event and event.event_data:
|
||||
event_data: ResourceSelectionEventData = event.event_data
|
||||
if event_data.updated and event_data.updated_contexts is not None:
|
||||
logger.debug(f"Contexts updated by event: "
|
||||
f"{len(event_data.updated_contexts)} items (source: {event_data.source})")
|
||||
contexts = event_data.updated_contexts
|
||||
|
||||
# 分组排序
|
||||
contexts = TorrentHelper().sort_group_torrents(contexts)
|
||||
|
||||
@@ -474,8 +527,8 @@ class DownloadChain(ChainBase):
|
||||
downloaded_list.append(context)
|
||||
|
||||
# 电视剧整季匹配
|
||||
logger.info(f"开始匹配电视剧整季:{no_exists}")
|
||||
if no_exists:
|
||||
logger.info(f"开始匹配电视剧整季:{no_exists}")
|
||||
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
|
||||
need_seasons: Dict[int, list] = {}
|
||||
for need_mid, need_tv in no_exists.items():
|
||||
@@ -578,8 +631,8 @@ class DownloadChain(ChainBase):
|
||||
# 全部下载完成
|
||||
break
|
||||
# 电视剧季内的集匹配
|
||||
logger.info(f"开始电视剧完整集匹配:{no_exists}")
|
||||
if no_exists:
|
||||
logger.info(f"开始电视剧完整集匹配:{no_exists}")
|
||||
# TMDBID列表
|
||||
need_tv_list = list(no_exists)
|
||||
for need_mid in need_tv_list:
|
||||
@@ -648,8 +701,8 @@ class DownloadChain(ChainBase):
|
||||
logger.info(f"季 {need_season} 剩余需要集:{need_episodes}")
|
||||
|
||||
# 仍然缺失的剧集,从整季中选择需要的集数文件下载,仅支持QB和TR
|
||||
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
|
||||
if no_exists:
|
||||
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
|
||||
# TMDBID列表
|
||||
no_exists_list = list(no_exists)
|
||||
for need_mid in no_exists_list:
|
||||
|
||||
@@ -11,12 +11,15 @@ from app.core.event import eventmanager, Event
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.log import logger
|
||||
from app.schemas import FileItem
|
||||
from app.schemas.types import EventType, MediaType, ChainEventType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
recognize_lock = Lock()
|
||||
scraping_lock = Lock()
|
||||
scraping_files = []
|
||||
|
||||
|
||||
class MediaChain(ChainBase, metaclass=Singleton):
|
||||
@@ -301,12 +304,24 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if not event:
|
||||
return
|
||||
event_data = event.event_data or {}
|
||||
fileitem = event_data.get("fileitem")
|
||||
meta = event_data.get("meta")
|
||||
mediainfo = event_data.get("mediainfo")
|
||||
fileitem: FileItem = event_data.get("fileitem")
|
||||
meta: MetaBase = event_data.get("meta")
|
||||
mediainfo: MediaInfo = event_data.get("mediainfo")
|
||||
overwrite = event_data.get("overwrite", False)
|
||||
if not fileitem:
|
||||
return
|
||||
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||
# 刮削锁
|
||||
with scraping_lock:
|
||||
if fileitem.path in scraping_files:
|
||||
return
|
||||
scraping_files.append(fileitem.path)
|
||||
try:
|
||||
# 执行刮削
|
||||
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=overwrite)
|
||||
finally:
|
||||
# 释放锁
|
||||
with scraping_lock:
|
||||
scraping_files.remove(fileitem.path)
|
||||
|
||||
def scrape_metadata(self, fileitem: schemas.FileItem,
|
||||
meta: MetaBase = None, mediainfo: MediaInfo = None,
|
||||
@@ -322,6 +337,20 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
:param overwrite: 是否覆盖已有文件
|
||||
"""
|
||||
|
||||
def is_bluray_folder(_fileitem: schemas.FileItem) -> bool:
|
||||
"""
|
||||
判断是否为原盘目录
|
||||
"""
|
||||
if not _fileitem or _fileitem.type != "dir":
|
||||
return False
|
||||
# 蓝光原盘目录必备的文件或文件夹
|
||||
required_files = ['BDMV', 'CERTIFICATE']
|
||||
# 检查目录下是否存在所需文件或文件夹
|
||||
for item in self.storagechain.list_files(_fileitem):
|
||||
if item.name in required_files:
|
||||
return True
|
||||
return False
|
||||
|
||||
def __list_files(_fileitem: schemas.FileItem):
|
||||
"""
|
||||
列出下级文件
|
||||
@@ -337,14 +366,19 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
if not _fileitem or not _content or not _path:
|
||||
return
|
||||
tmp_file = settings.TEMP_PATH / _path.name
|
||||
# 保存文件到临时目录,文件名随机
|
||||
tmp_file = settings.TEMP_PATH / f"{_path.name}.{StringUtils.generate_random_str(10)}"
|
||||
tmp_file.write_bytes(_content)
|
||||
_fileitem.path = str(_path.parent)
|
||||
item = self.storagechain.upload_file(fileitem=_fileitem, path=tmp_file)
|
||||
if item:
|
||||
logger.info(f"已保存文件:{Path(item.path) / item.name}")
|
||||
if tmp_file.exists():
|
||||
tmp_file.unlink()
|
||||
# 获取文件的父目录
|
||||
try:
|
||||
item = self.storagechain.upload_file(fileitem=_fileitem, path=tmp_file, new_name=_path.name)
|
||||
if item:
|
||||
logger.info(f"已保存文件:{item.path}")
|
||||
else:
|
||||
logger.warn(f"文件保存失败:{item.path}")
|
||||
finally:
|
||||
if tmp_file.exists():
|
||||
tmp_file.unlink()
|
||||
|
||||
def __download_image(_url: str) -> Optional[bytes]:
|
||||
"""
|
||||
@@ -379,26 +413,38 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if fileitem.type == "file":
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
logger.debug(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 电影文件
|
||||
logger.info(f"正在生成电影nfo:{mediainfo.title_year} - {filepath.name}")
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if not movie_nfo:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
return
|
||||
# 保存或上传nfo文件到上级目录
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 电影文件
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if movie_nfo:
|
||||
# 保存或上传nfo文件到上级目录
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
else:
|
||||
# 电影目录
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
for file in files:
|
||||
self.scrape_metadata(fileitem=file,
|
||||
meta=meta, mediainfo=mediainfo,
|
||||
init_folder=False, parent=fileitem)
|
||||
if is_bluray_folder(fileitem):
|
||||
# 原盘目录
|
||||
nfo_path = filepath / (filepath.name + ".nfo")
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 生成原盘nfo
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if movie_nfo:
|
||||
# 保存或上传nfo文件到当前目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
else:
|
||||
# 处理目录内的文件
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
for file in files:
|
||||
self.scrape_metadata(fileitem=file,
|
||||
meta=meta, mediainfo=mediainfo,
|
||||
init_folder=False, parent=fileitem)
|
||||
# 生成目录内图片文件
|
||||
if init_folder:
|
||||
# 图片
|
||||
@@ -410,59 +456,58 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
and attr_value.startswith("http"):
|
||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||
image_path = filepath / image_name
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
logger.debug(f"已存在图片文件:{image_path}")
|
||||
continue
|
||||
# 下载图片
|
||||
content = __download_image(_url=attr_value)
|
||||
# 写入图片到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(_url=attr_value)
|
||||
# 写入图片到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
else:
|
||||
# 电视剧
|
||||
if fileitem.type == "file":
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
logger.debug(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 重新识别季集
|
||||
file_meta = MetaInfoPath(filepath)
|
||||
if not file_meta.begin_episode:
|
||||
logger.warn(f"{filepath.name} 无法识别文件集数!")
|
||||
return
|
||||
file_mediainfo = self.recognize_media(meta=file_meta)
|
||||
file_mediainfo = self.recognize_media(meta=file_meta, tmdbid=mediainfo.tmdb_id)
|
||||
if not file_mediainfo:
|
||||
logger.warn(f"{filepath.name} 无法识别文件媒体信息!")
|
||||
return
|
||||
# 获取集的nfo文件
|
||||
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||
if not episode_nfo:
|
||||
logger.warn(f"{filepath.name} nfo生成失败!")
|
||||
return
|
||||
# 保存或上传nfo文件到上级目录
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 获取集的nfo文件
|
||||
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||
if episode_nfo:
|
||||
# 保存或上传nfo文件到上级目录
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
# 获取集的图片
|
||||
image_dict = self.metadata_img(mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||
if image_dict:
|
||||
for episode, image_url in image_dict.items():
|
||||
image_path = filepath.with_suffix(Path(image_url).suffix)
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
|
||||
logger.debug(f"已存在图片文件:{image_path}")
|
||||
continue
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
else:
|
||||
# 当前为目录,处理目录内的文件
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
@@ -475,60 +520,90 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if init_folder:
|
||||
# 识别文件夹名称
|
||||
season_meta = MetaInfo(filepath.name)
|
||||
if season_meta.begin_season:
|
||||
# 当前文件夹为Specials或者SPs时,设置为S0
|
||||
if filepath.name in settings.RENAME_FORMAT_S0_NAMES:
|
||||
season_meta.begin_season = 0
|
||||
if season_meta.begin_season is not None:
|
||||
# 是否已存在
|
||||
nfo_path = filepath / "season.nfo"
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
logger.debug(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 当前目录有季号,生成季nfo
|
||||
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo, season=season_meta.begin_season)
|
||||
if not season_nfo:
|
||||
logger.warn(f"无法生成电视剧季nfo文件:{meta.name}")
|
||||
return
|
||||
# 写入nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 当前目录有季号,生成季nfo
|
||||
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo,
|
||||
season=season_meta.begin_season)
|
||||
if season_nfo:
|
||||
# 写入nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
|
||||
else:
|
||||
logger.warn(f"无法生成电视剧季nfo文件:{meta.name}")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
# TMDB季poster图片
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
|
||||
if image_dict:
|
||||
for image_name, image_url in image_dict.items():
|
||||
image_path = filepath.with_name(image_name)
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
logger.debug(f"已存在图片文件:{image_path}")
|
||||
continue
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到剧集目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
# 额外fanart季图片:poster thumb banner
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||
if image_dict:
|
||||
for image_name, image_url in image_dict.items():
|
||||
if image_name.startswith("season"):
|
||||
image_path = filepath.with_name(image_name)
|
||||
# 只下载当前刮削季的图片
|
||||
image_season = "00" if "specials" in image_name else image_name[6:8]
|
||||
if image_season != str(season_meta.begin_season).rjust(2, '0'):
|
||||
logger.info(f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
|
||||
continue
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
# 判断当前目录是不是剧集根目录
|
||||
if season_meta.name:
|
||||
if not season_meta.season:
|
||||
# 是否已存在
|
||||
nfo_path = filepath / "tvshow.nfo"
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
logger.debug(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
||||
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if not tv_nfo:
|
||||
logger.warn(f"无法生成电视剧nfo文件:{meta.name}")
|
||||
return
|
||||
# 写入tvshow nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
||||
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if tv_nfo:
|
||||
# 写入tvshow nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
|
||||
else:
|
||||
logger.warn(f"无法生成电视剧nfo文件:{meta.name}")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
# 生成目录图片
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||
if image_dict:
|
||||
for image_name, image_url in image_dict.items():
|
||||
image_path = filepath / image_name
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
logger.debug(f"已存在图片文件:{image_path}")
|
||||
# 不下载季图片
|
||||
if image_name.startswith("season"):
|
||||
continue
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
|
||||
image_path = filepath / image_name
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
logger.info(f"{filepath.name} 刮削完成")
|
||||
|
||||
@@ -111,6 +111,8 @@ class MessageChain(ChainBase):
|
||||
info = self.message_parser(source=source, body=body, form=form, args=args)
|
||||
if not info:
|
||||
return
|
||||
# 更新消息来源
|
||||
source = info.source
|
||||
# 渠道
|
||||
channel = info.channel
|
||||
# 用户ID
|
||||
|
||||
323
app/chain/recommend.py
Normal file
323
app/chain/recommend.py
Normal file
@@ -0,0 +1,323 @@
|
||||
import inspect
|
||||
import io
|
||||
import tempfile
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List
|
||||
|
||||
from PIL import Image
|
||||
from cachetools import TTLCache
|
||||
from cachetools.keys import hashkey
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.utils.common import log_execution_time
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.security import SecurityUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
# 推荐相关的专用缓存
|
||||
recommend_ttl = 24 * 3600
|
||||
recommend_cache = TTLCache(maxsize=256, ttl=recommend_ttl)
|
||||
|
||||
|
||||
# 推荐缓存装饰器,避免偶发网络获取数据为空时,页面由于缓存为空长时间渲染异常问题
|
||||
def cached_with_empty_check(func: Callable):
|
||||
"""
|
||||
缓存装饰器,用于缓存函数的返回结果,仅在结果非空时进行缓存
|
||||
|
||||
:param func: 被装饰的函数
|
||||
:return: 包装后的函数
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
signature = inspect.signature(func)
|
||||
resolved_kwargs = {}
|
||||
# 获取默认值并结合传递的参数(如果有)
|
||||
for param, value in signature.parameters.items():
|
||||
if param in kwargs:
|
||||
# 使用显式传递的参数
|
||||
resolved_kwargs[param] = kwargs[param]
|
||||
elif value.default is not inspect.Parameter.empty:
|
||||
# 没有传递参数时使用默认值
|
||||
resolved_kwargs[param] = value.default
|
||||
# 使用 cachetools 缓存,构造缓存键
|
||||
cache_key = f"{func.__name__}_{hashkey(*args, **resolved_kwargs)}"
|
||||
if cache_key in recommend_cache:
|
||||
return recommend_cache[cache_key]
|
||||
result = func(*args, **kwargs)
|
||||
# 如果返回值为空,则不缓存
|
||||
if result in [None, [], {}]:
|
||||
return result
|
||||
recommend_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
推荐处理链,单例运行
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.doubanchain = DoubanChain()
|
||||
self.bangumichain = BangumiChain()
|
||||
self.cache_max_pages = 5
|
||||
|
||||
def refresh_recommend(self):
|
||||
"""
|
||||
刷新推荐
|
||||
"""
|
||||
logger.debug("Starting to refresh Recommend data.")
|
||||
recommend_cache.clear()
|
||||
logger.debug("Recommend Cache has been cleared.")
|
||||
|
||||
# 推荐来源方法
|
||||
recommend_methods = [
|
||||
self.tmdb_movies,
|
||||
self.tmdb_tvs,
|
||||
self.tmdb_trending,
|
||||
self.bangumi_calendar,
|
||||
self.douban_movie_showing,
|
||||
self.douban_movies,
|
||||
self.douban_tvs,
|
||||
self.douban_movie_top250,
|
||||
self.douban_tv_weekly_chinese,
|
||||
self.douban_tv_weekly_global,
|
||||
self.douban_tv_animation,
|
||||
self.douban_movie_hot,
|
||||
self.douban_tv_hot,
|
||||
]
|
||||
|
||||
# 缓存并刷新所有推荐数据
|
||||
recommends = []
|
||||
# 记录哪些方法已完成
|
||||
methods_finished = set()
|
||||
# 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历
|
||||
for page in range(1, self.cache_max_pages + 1):
|
||||
for method in recommend_methods:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
if method in methods_finished:
|
||||
continue
|
||||
logger.debug(f"Fetch {method.__name__} data for page {page}.")
|
||||
data = method(page=page)
|
||||
if not data:
|
||||
logger.debug("All recommendation methods have finished fetching data. Ending pagination early.")
|
||||
methods_finished.add(method)
|
||||
continue
|
||||
recommends.extend(data)
|
||||
# 如果所有方法都已经完成,提前结束循环
|
||||
if len(methods_finished) == len(recommend_methods):
|
||||
break
|
||||
|
||||
# 缓存收集到的海报
|
||||
self.__cache_posters(recommends)
|
||||
logger.debug("Recommend data refresh completed.")
|
||||
|
||||
def __cache_posters(self, datas: List[dict]):
|
||||
"""
|
||||
提取 poster_path 并缓存图片
|
||||
:param datas: 数据列表
|
||||
"""
|
||||
if not settings.GLOBAL_IMAGE_CACHE:
|
||||
return
|
||||
|
||||
for data in datas:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
poster_path = data.get("poster_path")
|
||||
if poster_path:
|
||||
poster_url = poster_path.replace("original", "w500")
|
||||
logger.debug(f"Caching poster image: {poster_url}")
|
||||
self.__fetch_and_save_image(poster_url)
|
||||
|
||||
@staticmethod
|
||||
def __fetch_and_save_image(url: str):
|
||||
"""
|
||||
请求并保存图片
|
||||
:param url: 图片路径
|
||||
"""
|
||||
if not settings.GLOBAL_IMAGE_CACHE or not url:
|
||||
return
|
||||
|
||||
# 生成缓存路径
|
||||
sanitized_path = SecurityUtils.sanitize_url_path(url)
|
||||
cache_path = settings.CACHE_PATH / "images" / sanitized_path
|
||||
|
||||
# 确保缓存路径和文件类型合法
|
||||
if not SecurityUtils.is_safe_path(settings.CACHE_PATH, cache_path, settings.SECURITY_IMAGE_SUFFIXES):
|
||||
logger.debug(f"Invalid cache path or file type for URL: {url}, sanitized path: {sanitized_path}")
|
||||
return
|
||||
|
||||
# 本地存在缓存图片,则直接跳过
|
||||
if cache_path.exists():
|
||||
logger.debug(f"Cache hit: Image already exists at {cache_path}")
|
||||
return
|
||||
|
||||
# 请求远程图片
|
||||
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
|
||||
proxies = settings.PROXY if not referer else None
|
||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=proxies, referer=referer).get_res(url=url)
|
||||
if not response:
|
||||
logger.debug(f"Empty response for URL: {url}")
|
||||
return
|
||||
|
||||
# 验证下载的内容是否为有效图片
|
||||
try:
|
||||
Image.open(io.BytesIO(response.content)).verify()
|
||||
except Exception as e:
|
||||
logger.debug(f"Invalid image format for URL {url}: {e}")
|
||||
return
|
||||
|
||||
if not cache_path:
|
||||
return
|
||||
|
||||
try:
|
||||
if not cache_path.parent.exists():
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file:
|
||||
tmp_file.write(response.content)
|
||||
temp_path = Path(tmp_file.name)
|
||||
temp_path.replace(cache_path)
|
||||
logger.debug(f"Successfully cached image at {cache_path} for URL: {url}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to write cache file {cache_path} for URL {url}: {e}")
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def tmdb_movies(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
||||
with_original_language: str = "", page: int = 1) -> Any:
|
||||
"""
|
||||
TMDB热门电影
|
||||
"""
|
||||
movies = self.tmdbchain.tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
return [movie.to_dict() for movie in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def tmdb_tvs(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
||||
with_original_language: str = "zh|en|ja|ko", page: int = 1) -> Any:
|
||||
"""
|
||||
TMDB热门电视剧
|
||||
"""
|
||||
tvs = self.tmdbchain.tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
return [tv.to_dict() for tv in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def tmdb_trending(self, page: int = 1) -> Any:
|
||||
"""
|
||||
TMDB流行趋势
|
||||
"""
|
||||
infos = self.tmdbchain.tmdb_trending(page=page)
|
||||
return [info.to_dict() for info in infos] if infos else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def bangumi_calendar(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
Bangumi每日放送
|
||||
"""
|
||||
medias = self.bangumichain.calendar()
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movie_showing(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣正在热映
|
||||
"""
|
||||
movies = self.doubanchain.movie_showing(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movies(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣最新电影
|
||||
"""
|
||||
movies = self.doubanchain.douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tvs(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣最新电视剧
|
||||
"""
|
||||
tvs = self.doubanchain.douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movie_top250(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣电影TOP250
|
||||
"""
|
||||
movies = self.doubanchain.movie_top250(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣国产剧集榜
|
||||
"""
|
||||
tvs = self.doubanchain.tv_weekly_chinese(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_weekly_global(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣全球剧集榜
|
||||
"""
|
||||
tvs = self.doubanchain.tv_weekly_global(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_animation(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣热门动漫
|
||||
"""
|
||||
tvs = self.doubanchain.tv_animation(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movie_hot(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣热门电影
|
||||
"""
|
||||
movies = self.doubanchain.movie_hot(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_hot(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣热门电视剧
|
||||
"""
|
||||
tvs = self.doubanchain.tv_hot(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
@@ -125,7 +125,6 @@ class SearchChain(ChainBase):
|
||||
"""
|
||||
return self.filter_torrents(rule_groups=rule_groups,
|
||||
torrent_list=torrent_list,
|
||||
season_episodes=season_episodes,
|
||||
mediainfo=mediainfo) or []
|
||||
|
||||
# 豆瓣标题处理
|
||||
@@ -185,7 +184,10 @@ class SearchChain(ChainBase):
|
||||
# 开始过滤
|
||||
self.progress.update(value=0, text=f'开始过滤,总 {len(torrents)} 个资源,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
|
||||
# 匹配订阅附加参数
|
||||
if filter_params:
|
||||
logger.info(f'开始附加参数过滤,附加参数:{filter_params} ...')
|
||||
torrents = [torrent for torrent in torrents if self.torrenthelper.filter_torrent(torrent, filter_params)]
|
||||
# 开始过滤规则过滤
|
||||
if rule_groups is None:
|
||||
# 取搜索过滤规则
|
||||
@@ -221,11 +223,19 @@ class SearchChain(ChainBase):
|
||||
key=ProgressKey.Search)
|
||||
if not torrent.title:
|
||||
continue
|
||||
|
||||
# 识别元数据
|
||||
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description,
|
||||
custom_words=custom_words)
|
||||
if torrent.title != torrent_meta.org_string:
|
||||
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}")
|
||||
# 季集数过滤
|
||||
if season_episodes \
|
||||
and not self.torrenthelper.match_season_episodes(
|
||||
torrent=torrent,
|
||||
meta=torrent_meta,
|
||||
season_episodes=season_episodes):
|
||||
continue
|
||||
# 比对IMDBID
|
||||
if torrent.imdbid \
|
||||
and mediainfo.imdb_id \
|
||||
@@ -234,11 +244,6 @@ class SearchChain(ChainBase):
|
||||
_match_torrents.append((torrent, torrent_meta))
|
||||
continue
|
||||
|
||||
# 匹配订阅附加参数
|
||||
if filter_params and not self.torrenthelper.filter_torrent(torrent_info=torrent,
|
||||
filter_params=filter_params):
|
||||
continue
|
||||
|
||||
# 比对种子
|
||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import base64
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple, Union
|
||||
from time import time
|
||||
from typing import Optional, Tuple, Union, Dict
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from lxml import etree
|
||||
@@ -86,25 +87,36 @@ class SiteChain(ChainBase):
|
||||
f"{userdata.message_unread} 条新消息,请登陆查看",
|
||||
link=site.get("url")
|
||||
))
|
||||
# 低分享率警告
|
||||
if userdata.ratio and float(userdata.ratio) < 1 and not bool(
|
||||
re.search(r"(贵宾|VIP?)", userdata.user_level or "", re.IGNORECASE)):
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【站点分享率低预警】",
|
||||
text=f"站点 {site.get('name')} 分享率 {userdata.ratio},请注意!"
|
||||
))
|
||||
return userdata
|
||||
|
||||
def refresh_userdatas(self) -> None:
|
||||
def refresh_userdatas(self) -> Optional[Dict[str, SiteUserData]]:
|
||||
"""
|
||||
刷新所有站点的用户数据
|
||||
"""
|
||||
sites = self.siteshelper.get_indexers()
|
||||
any_site_updated = False
|
||||
result = {}
|
||||
for site in sites:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
return None
|
||||
if site.get("is_active"):
|
||||
userdata = self.refresh_userdata(site)
|
||||
if userdata:
|
||||
any_site_updated = True
|
||||
result[site.get("name")] = userdata
|
||||
if any_site_updated:
|
||||
EventManager().send_event(EventType.SiteRefreshed, {
|
||||
"site_id": "*"
|
||||
})
|
||||
return result
|
||||
|
||||
def is_special_site(self, domain: str) -> bool:
|
||||
"""
|
||||
@@ -161,27 +173,37 @@ class SiteChain(ChainBase):
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": user_agent,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Authorization": site.token
|
||||
"Authorization": site.token,
|
||||
"x-api-key": site.apikey,
|
||||
"ts": str(int(time()))
|
||||
}
|
||||
res = RequestUtils(
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=site.timeout or 15
|
||||
).post_res(url=url)
|
||||
state = False
|
||||
message = "鉴权已过期或无效"
|
||||
if res and res.status_code == 200:
|
||||
user_info = res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
user_info = res.json() or {}
|
||||
if user_info.get("data"):
|
||||
# 更新最后访问时间
|
||||
del headers["x-api-key"]
|
||||
res = RequestUtils(headers=headers,
|
||||
timeout=site.timeout or 15,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
referer=f"{site.url}index"
|
||||
).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse")
|
||||
if res:
|
||||
return True, "连接成功"
|
||||
else:
|
||||
return True, f"连接成功,但更新状态失败"
|
||||
return False, "鉴权已过期或无效"
|
||||
state = True
|
||||
message = "连接成功,但更新状态失败"
|
||||
if res and res.status_code == 200:
|
||||
update_info = res.json() or {}
|
||||
if "code" in update_info and int(update_info["code"]) == 0:
|
||||
message = "连接成功"
|
||||
elif user_info.get("message"):
|
||||
# 使用馒头的错误提示
|
||||
message = user_info.get("message")
|
||||
return state, message
|
||||
|
||||
@staticmethod
|
||||
def __yema_test(site: Site) -> Tuple[bool, str]:
|
||||
@@ -308,6 +330,7 @@ class SiteChain(ChainBase):
|
||||
continue
|
||||
# 新增站点
|
||||
domain_url = __indexer_domain(inx=indexer, sub_domain=domain)
|
||||
proxy = False
|
||||
res = RequestUtils(cookies=cookie,
|
||||
ua=settings.USER_AGENT
|
||||
).get_res(url=domain_url)
|
||||
@@ -325,16 +348,37 @@ class SiteChain(ChainBase):
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接状态码:{res.status_code},无法添加站点")
|
||||
continue
|
||||
else:
|
||||
_fail_count += 1
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
|
||||
continue
|
||||
if not settings.PROXY_HOST:
|
||||
_fail_count += 1
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
|
||||
continue
|
||||
else:
|
||||
# 如果配置了代理,尝试通过代理重试
|
||||
logger.info(f"站点 {indexer.get('name')} 初次连接失败,尝试通过代理重试...")
|
||||
proxy = True
|
||||
res = RequestUtils(cookies=cookie,
|
||||
ua=settings.USER_AGENT,
|
||||
proxies=settings.PROXY
|
||||
).get_res(url=domain_url)
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
if not indexer.get("public") and not SiteUtils.is_logged_in(res.text):
|
||||
logger.warn(f"站点 {indexer.get('name')} 登录失败,即使通过代理,无法添加站点")
|
||||
_fail_count += 1
|
||||
continue
|
||||
logger.info(f"站点 {indexer.get('name')} 通过代理连接成功")
|
||||
else:
|
||||
logger.warn(f"站点 {indexer.get('name')} 通过代理连接失败,无法添加站点")
|
||||
_fail_count += 1
|
||||
continue
|
||||
|
||||
# 获取rss地址
|
||||
rss_url = None
|
||||
if not indexer.get("public") and domain_url:
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(url=domain_url,
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT)
|
||||
ua=settings.USER_AGENT,
|
||||
proxy=proxy)
|
||||
if errmsg:
|
||||
logger.warn(errmsg)
|
||||
# 插入数据库
|
||||
@@ -344,6 +388,7 @@ class SiteChain(ChainBase):
|
||||
domain=domain,
|
||||
cookie=cookie,
|
||||
rss=rss_url,
|
||||
proxy=1 if proxy else 0,
|
||||
public=1 if indexer.get("public") else 0)
|
||||
_add_count += 1
|
||||
|
||||
@@ -705,3 +750,66 @@ class SiteChain(ChainBase):
|
||||
source=source,
|
||||
title=f"【{site_info.name}】 Cookie&UA更新成功",
|
||||
userid=userid))
|
||||
|
||||
def remote_refresh_userdatas(self, channel: MessageChannel,
|
||||
userid: Union[str, int] = None, source: str = None):
|
||||
"""
|
||||
刷新所有站点用户数据
|
||||
"""
|
||||
logger.info("收到命令,开始刷新站点数据 ...")
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
title="开始刷新站点数据 ...",
|
||||
userid=userid
|
||||
))
|
||||
# 刷新站点数据
|
||||
site_datas = self.refresh_userdatas()
|
||||
if site_datas:
|
||||
# 发送消息
|
||||
messages = {}
|
||||
# 总上传
|
||||
incUploads = 0
|
||||
# 总下载
|
||||
incDownloads = 0
|
||||
# 今天日期
|
||||
today_date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
for rand, site in enumerate(site_datas.keys()):
|
||||
upload = int(site_datas[site].upload or 0)
|
||||
download = int(site_datas[site].download or 0)
|
||||
updated_date = site_datas[site].updated_day
|
||||
if updated_date and updated_date != today_date:
|
||||
updated_date = f"({updated_date})"
|
||||
else:
|
||||
updated_date = ""
|
||||
|
||||
if upload > 0 or download > 0:
|
||||
incUploads += upload
|
||||
incDownloads += download
|
||||
messages[upload + (rand / 1000)] = (
|
||||
f"【{site}】{updated_date}\n"
|
||||
+ f"上传量:{StringUtils.str_filesize(upload)}\n"
|
||||
+ f"下载量:{StringUtils.str_filesize(download)}\n"
|
||||
+ "————————————"
|
||||
)
|
||||
if incDownloads or incUploads:
|
||||
sorted_messages = [messages[key] for key in sorted(messages.keys(), reverse=True)]
|
||||
sorted_messages.insert(0, f"【汇总】\n"
|
||||
f"总上传:{StringUtils.str_filesize(incUploads)}\n"
|
||||
f"总下载:{StringUtils.str_filesize(incDownloads)}\n"
|
||||
f"————————————")
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
title="【站点数据统计】",
|
||||
text="\n".join(sorted_messages),
|
||||
userid=userid
|
||||
))
|
||||
else:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
title="没有刷新到任何站点数据!",
|
||||
userid=userid
|
||||
))
|
||||
|
||||
@@ -84,6 +84,12 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("rename_file", fileitem=fileitem, name=name)
|
||||
|
||||
def get_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
查询目录或文件
|
||||
"""
|
||||
return self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path))
|
||||
|
||||
def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
根据路径获取文件项
|
||||
@@ -108,7 +114,7 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("storage_usage", storage=storage)
|
||||
|
||||
def support_transtype(self, storage: str) -> Optional[str]:
|
||||
def support_transtype(self, storage: str) -> Optional[dict]:
|
||||
"""
|
||||
获取支持的整理方式
|
||||
"""
|
||||
@@ -125,6 +131,12 @@ class StorageChain(ChainBase):
|
||||
return False
|
||||
if fileitem.type == "dir":
|
||||
# 本身是目录
|
||||
if _blue_dir := self.list_files(fileitem=fileitem, recursion=False):
|
||||
# 删除蓝光目录
|
||||
for _f in _blue_dir:
|
||||
if _f.type == "dir" and _f.name in ["BDMV", "CERTIFICATE"]:
|
||||
logger.warn(f"【{fileitem.storage}】{_f.path} 删除蓝光目录")
|
||||
self.delete_file(_f)
|
||||
if self.any_files(fileitem, extensions=media_exts) is False:
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 不存在其它媒体文件,删除空目录")
|
||||
return self.delete_file(fileitem)
|
||||
@@ -135,9 +147,17 @@ class StorageChain(ChainBase):
|
||||
if not self.delete_file(fileitem):
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||
return False
|
||||
# 处理上级目录
|
||||
if mtype and mtype == MediaType.TV:
|
||||
dir_item = self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path).parent.parent)
|
||||
if mtype:
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mtype == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
# 计算重命名中的文件夹层数
|
||||
rename_format_level = len(rename_format.split("/")) - 1
|
||||
if rename_format_level < 1:
|
||||
return True
|
||||
# 处理上级目录
|
||||
dir_item = self.get_file_item(storage=fileitem.storage,
|
||||
path=Path(fileitem.path).parents[rename_format_level - 1])
|
||||
else:
|
||||
dir_item = self.get_parent_item(fileitem)
|
||||
if dir_item and len(Path(dir_item.path).parts) > 2:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from app.chain import ChainBase
|
||||
@@ -161,4 +162,15 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
获取前端版本
|
||||
"""
|
||||
if SystemUtils.is_frozen() and SystemUtils.is_windows():
|
||||
version_file = settings.CONFIG_PATH.parent / "nginx" / "html" / "version.txt"
|
||||
else:
|
||||
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
|
||||
if version_file.exists():
|
||||
try:
|
||||
with open(version_file, 'r') as f:
|
||||
version = str(f.read()).strip()
|
||||
return version
|
||||
except Exception as err:
|
||||
logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||
return FRONTEND_VERSION
|
||||
|
||||
@@ -38,6 +38,13 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("tmdb_trending", page=page)
|
||||
|
||||
def tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据合集ID查询集合
|
||||
:param collection_id: 合集ID
|
||||
"""
|
||||
return self.run_module("tmdb_collection", collection_id=collection_id)
|
||||
|
||||
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
||||
"""
|
||||
根据TMDBID查询themoviedb所有季信息
|
||||
|
||||
@@ -175,7 +175,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# 按pubdate降序排列
|
||||
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
|
||||
# 取前N条
|
||||
torrents = torrents[:settings.CACHE_CONF.get('refresh')]
|
||||
torrents = torrents[:settings.CACHE_CONF["refresh"]]
|
||||
if torrents:
|
||||
# 过滤出没有处理过的种子
|
||||
torrents = [torrent for torrent in torrents
|
||||
@@ -215,8 +215,8 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
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'):]
|
||||
if len(torrents_cache[domain]) > settings.CACHE_CONF["torrents"]:
|
||||
torrents_cache[domain] = torrents_cache[domain][-settings.CACHE_CONF["torrents"]:]
|
||||
# 回收资源
|
||||
del torrents
|
||||
else:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ from app.core.security import get_password_hash, verify_password
|
||||
from app.db.models.user import User
|
||||
from app.db.user_oper import UserOper
|
||||
from app.log import logger
|
||||
from app.schemas.event import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas.types import ChainEventType
|
||||
from app.utils.otp import OtpUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
@@ -15,15 +15,18 @@ from app.helper.message import MessageHelper
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas import Notification
|
||||
from app.schemas.event import CommandRegisterEventData
|
||||
from app.schemas import Notification, CommandRegisterEventData
|
||||
from app.schemas.types import EventType, MessageChannel, ChainEventType
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.structures import DictUtils
|
||||
|
||||
|
||||
class CommandChain(ChainBase, metaclass=Singleton):
|
||||
class CommandChain(ChainBase):
|
||||
pass
|
||||
|
||||
|
||||
class Command(metaclass=Singleton):
|
||||
"""
|
||||
全局命令管理,消费事件
|
||||
"""
|
||||
@@ -54,6 +57,11 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
||||
"description": "更新站点Cookie",
|
||||
"data": {}
|
||||
},
|
||||
"/site_statistic": {
|
||||
"func": SiteChain().remote_refresh_userdatas,
|
||||
"description": "站点数据统计",
|
||||
"data": {}
|
||||
},
|
||||
"/site_enable": {
|
||||
"func": SiteChain().remote_enable,
|
||||
"description": "启用站点",
|
||||
@@ -205,7 +213,7 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
||||
if filtered_initial_commands != self._registered_commands or force_register:
|
||||
logger.debug("Command set has changed or force registration is enabled.")
|
||||
self._registered_commands = filtered_initial_commands
|
||||
super().register_commands(commands=filtered_initial_commands)
|
||||
CommandChain().register_commands(commands=filtered_initial_commands)
|
||||
else:
|
||||
logger.debug("Command set unchanged, skipping broadcast registration.")
|
||||
except Exception as e:
|
||||
@@ -243,7 +251,7 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
||||
event = eventmanager.send_event(ChainEventType.CommandRegister, event_data)
|
||||
return event, commands
|
||||
|
||||
def __build_plugin_commands(self, pid: Optional[str] = None) -> Dict[str, dict]:
|
||||
def __build_plugin_commands(self, _: Optional[str] = None) -> Dict[str, dict]:
|
||||
"""
|
||||
构建插件命令
|
||||
"""
|
||||
@@ -272,7 +280,7 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
||||
if command.get("type") == "scheduler":
|
||||
# 定时服务
|
||||
if userid:
|
||||
self.post_message(
|
||||
CommandChain().post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
@@ -285,7 +293,7 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
||||
self.scheduler.start(job_id=command.get("id"))
|
||||
|
||||
if userid:
|
||||
self.post_message(
|
||||
CommandChain().post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
@@ -402,7 +410,7 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
||||
channel=event_channel, source=event_source, userid=event_user)
|
||||
|
||||
@eventmanager.register(EventType.ModuleReload)
|
||||
def module_reload_event(self, event: ManagerEvent) -> None:
|
||||
def module_reload_event(self, _: ManagerEvent) -> None:
|
||||
"""
|
||||
注册模块重载事件
|
||||
"""
|
||||
@@ -8,9 +8,9 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type
|
||||
|
||||
from dotenv import set_key
|
||||
from pydantic import BaseModel, BaseSettings, validator
|
||||
from pydantic import BaseModel, BaseSettings, validator, Field
|
||||
|
||||
from app.log import logger
|
||||
from app.log import logger, log_settings, LogConfigModel
|
||||
from app.utils.system import SystemUtils
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
@@ -36,7 +36,7 @@ class ConfigModel(BaseModel):
|
||||
# RESOURCE密钥
|
||||
RESOURCE_SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||
# 允许的域名
|
||||
ALLOWED_HOSTS: list = ["*"]
|
||||
ALLOWED_HOSTS: list = Field(default_factory=lambda: ["*"])
|
||||
# TOKEN过期时间
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
||||
# RESOURCE_TOKEN过期时间
|
||||
@@ -114,29 +114,39 @@ class ConfigModel(BaseModel):
|
||||
# 是否启用DOH解析域名
|
||||
DOH_ENABLE: bool = True
|
||||
# 使用 DOH 解析的域名列表
|
||||
DOH_DOMAINS: str = "api.themoviedb.org,api.tmdb.org,webservice.fanart.tv,api.github.com,github.com,raw.githubusercontent.com,api.telegram.org"
|
||||
DOH_DOMAINS: str = ("api.themoviedb.org,"
|
||||
"api.tmdb.org,"
|
||||
"webservice.fanart.tv,"
|
||||
"api.github.com,"
|
||||
"github.com,"
|
||||
"raw.githubusercontent.com,"
|
||||
"api.telegram.org")
|
||||
# DOH 解析服务器列表
|
||||
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
|
||||
# 支持的后缀格式
|
||||
RMT_MEDIAEXT: list = ['.mp4', '.mkv', '.ts', '.iso',
|
||||
'.rmvb', '.avi', '.mov', '.mpeg',
|
||||
'.mpg', '.wmv', '.3gp', '.asf',
|
||||
'.m4v', '.flv', '.m2ts', '.strm',
|
||||
'.tp', '.f4v']
|
||||
RMT_MEDIAEXT: list = Field(
|
||||
default_factory=lambda: ['.mp4', '.mkv', '.ts', '.iso',
|
||||
'.rmvb', '.avi', '.mov', '.mpeg',
|
||||
'.mpg', '.wmv', '.3gp', '.asf',
|
||||
'.m4v', '.flv', '.m2ts', '.strm',
|
||||
'.tp', '.f4v']
|
||||
)
|
||||
# 支持的字幕文件后缀格式
|
||||
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa', '.sup']
|
||||
RMT_SUBEXT: list = Field(default_factory=lambda: ['.srt', '.ass', '.ssa', '.sup'])
|
||||
# 支持的音轨文件后缀格式
|
||||
RMT_AUDIO_TRACK_EXT: list = ['.mka']
|
||||
RMT_AUDIO_TRACK_EXT: list = Field(default_factory=lambda: ['.mka'])
|
||||
# 音轨文件后缀格式
|
||||
RMT_AUDIOEXT: list = ['.aac', '.ac3', '.amr', '.caf', '.cda', '.dsf',
|
||||
'.dff', '.kar', '.m4a', '.mp1', '.mp2', '.mp3',
|
||||
'.mid', '.mod', '.mka', '.mpc', '.nsf', '.ogg',
|
||||
'.pcm', '.rmi', '.s3m', '.snd', '.spx', '.tak',
|
||||
'.tta', '.vqf', '.wav', '.wma',
|
||||
'.aifc', '.aiff', '.alac', '.adif', '.adts',
|
||||
'.flac', '.midi', '.opus', '.sfalc']
|
||||
RMT_AUDIOEXT: list = Field(
|
||||
default_factory=lambda: ['.aac', '.ac3', '.amr', '.caf', '.cda', '.dsf',
|
||||
'.dff', '.kar', '.m4a', '.mp1', '.mp2', '.mp3',
|
||||
'.mid', '.mod', '.mka', '.mpc', '.nsf', '.ogg',
|
||||
'.pcm', '.rmi', '.s3m', '.snd', '.spx', '.tak',
|
||||
'.tta', '.vqf', '.wav', '.wma',
|
||||
'.aifc', '.aiff', '.alac', '.adif', '.adts',
|
||||
'.flac', '.midi', '.opus', '.sfalc']
|
||||
)
|
||||
# 下载器临时文件后缀
|
||||
DOWNLOAD_TMPEXT: list = ['.!qb', '.part']
|
||||
DOWNLOAD_TMPEXT: list = Field(default_factory=lambda: ['.!qb', '.part'])
|
||||
# 媒体服务器同步间隔(小时)
|
||||
MEDIASERVER_SYNC_INTERVAL: int = 6
|
||||
# 订阅模式
|
||||
@@ -189,7 +199,10 @@ class ConfigModel(BaseModel):
|
||||
# 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目
|
||||
MP_SERVER_HOST: str = "https://movie-pilot.org"
|
||||
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
||||
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins,https://github.com/thsrite/MoviePilot-Plugins,https://github.com/honue/MoviePilot-Plugins,https://github.com/InfinityPacer/MoviePilot-Plugins"
|
||||
PLUGIN_MARKET: str = ("https://github.com/jxxghp/MoviePilot-Plugins,"
|
||||
"https://github.com/thsrite/MoviePilot-Plugins,"
|
||||
"https://github.com/honue/MoviePilot-Plugins,"
|
||||
"https://github.com/InfinityPacer/MoviePilot-Plugins")
|
||||
# 插件安装数据共享
|
||||
PLUGIN_STATISTIC_SHARE: bool = True
|
||||
# 是否开启插件热加载
|
||||
@@ -206,14 +219,32 @@ class ConfigModel(BaseModel):
|
||||
BIG_MEMORY_MODE: bool = False
|
||||
# 全局图片缓存,将媒体图片缓存到本地
|
||||
GLOBAL_IMAGE_CACHE: bool = False
|
||||
# 是否启用编码探测的性能模式
|
||||
ENCODING_DETECTION_PERFORMANCE_MODE: bool = True
|
||||
# 编码探测的最低置信度阈值
|
||||
ENCODING_DETECTION_MIN_CONFIDENCE: float = 0.8
|
||||
# 允许的图片缓存域名
|
||||
SECURITY_IMAGE_DOMAINS: List[str] = ["image.tmdb.org", "static-mdb.v.geilijiasu.com", "doubanio.com", "lain.bgm.tv",
|
||||
"raw.githubusercontent.com", "github.com"]
|
||||
SECURITY_IMAGE_DOMAINS: List[str] = Field(
|
||||
default_factory=lambda: ["image.tmdb.org",
|
||||
"static-mdb.v.geilijiasu.com",
|
||||
"doubanio.com",
|
||||
"lain.bgm.tv",
|
||||
"raw.githubusercontent.com",
|
||||
"github.com"]
|
||||
)
|
||||
# 允许的图片文件后缀格式
|
||||
SECURITY_IMAGE_SUFFIXES: List[str] = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"]
|
||||
SECURITY_IMAGE_SUFFIXES: List[str] = Field(
|
||||
default_factory=lambda: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"]
|
||||
)
|
||||
# 重命名时支持的S0别名
|
||||
RENAME_FORMAT_S0_NAMES: List[str] = Field(
|
||||
default_factory=lambda: ["Specials", "SPs"]
|
||||
)
|
||||
# 启用分词搜索
|
||||
TOKENIZED_SEARCH: bool = False
|
||||
|
||||
|
||||
class Settings(BaseSettings, ConfigModel):
|
||||
class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"""
|
||||
系统配置类
|
||||
"""
|
||||
@@ -320,7 +351,7 @@ class Settings(BaseSettings, ConfigModel):
|
||||
return default, True
|
||||
|
||||
@validator('*', pre=True, always=True)
|
||||
def generic_type_validator(cls, value: Any, field):
|
||||
def generic_type_validator(cls, value: Any, field): # noqa
|
||||
"""
|
||||
通用校验器,尝试将配置值转换为期望的类型
|
||||
"""
|
||||
@@ -345,10 +376,9 @@ class Settings(BaseSettings, ConfigModel):
|
||||
logger.warning(message)
|
||||
|
||||
if field.name in os.environ:
|
||||
if is_converted:
|
||||
message = f"配置项 '{field.name}' 已在环境变量中设置,请手动更新以保持一致性"
|
||||
logger.warning(message)
|
||||
return False, message
|
||||
message = f"配置项 '{field.name}' 已在环境变量中设置,请手动更新以保持一致性"
|
||||
logger.warning(message)
|
||||
return False, message
|
||||
else:
|
||||
set_key(SystemUtils.get_env_path(), field.name, str(converted_value) if converted_value is not None else "")
|
||||
if is_converted:
|
||||
@@ -372,10 +402,12 @@ class Settings(BaseSettings, ConfigModel):
|
||||
field.default, key)
|
||||
# 如果没有抛出异常,则统一使用 converted_value 进行更新
|
||||
if needs_update or str(value) != str(converted_value):
|
||||
success, message = self.update_env_config(field, original_value, converted_value)
|
||||
success, message = self.update_env_config(field, value, converted_value)
|
||||
# 仅成功更新配置时,才更新内存
|
||||
if success:
|
||||
setattr(self, key, converted_value)
|
||||
if hasattr(log_settings, key):
|
||||
setattr(log_settings, key, converted_value)
|
||||
return success, message
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
@@ -386,8 +418,21 @@ class Settings(BaseSettings, ConfigModel):
|
||||
更新多个配置项
|
||||
"""
|
||||
results = {}
|
||||
log_updated, plugin_monitor_updated = False, False
|
||||
for k, v in env.items():
|
||||
results[k] = self.update_setting(k, v)
|
||||
if hasattr(log_settings, k):
|
||||
log_updated = True
|
||||
if k in ["PLUGIN_AUTO_RELOAD", "DEV"]:
|
||||
plugin_monitor_updated = True
|
||||
# 本次更新存在日志配置项更新,需要重新加载日志配置
|
||||
if log_updated:
|
||||
logger.update_loggers()
|
||||
# 本次更新存在插件监控配置项更新,需要重新加载插件监控
|
||||
if plugin_monitor_updated:
|
||||
# 解决顶层循环导入问题
|
||||
from app.core.plugin import PluginManager
|
||||
PluginManager().reload_monitor()
|
||||
return results
|
||||
|
||||
@property
|
||||
@@ -437,22 +482,34 @@ class Settings(BaseSettings, ConfigModel):
|
||||
|
||||
@property
|
||||
def CACHE_CONF(self):
|
||||
"""
|
||||
{
|
||||
"torrents": "缓存种子数量",
|
||||
"refresh": "订阅刷新处理数量",
|
||||
"tmdb": "TMDB请求缓存数量",
|
||||
"douban": "豆瓣请求缓存数量",
|
||||
"fanart": "Fanart请求缓存数量",
|
||||
"meta": "元数据缓存过期时间(秒)"
|
||||
}
|
||||
"""
|
||||
if self.BIG_MEMORY_MODE:
|
||||
return {
|
||||
"torrents": 200,
|
||||
"refresh": 100,
|
||||
"tmdb": 1024,
|
||||
"refresh": 50,
|
||||
"torrents": 100,
|
||||
"douban": 512,
|
||||
"bangumi": 512,
|
||||
"fanart": 512,
|
||||
"meta": (self.META_CACHE_EXPIRE or 168) * 3600
|
||||
"meta": (self.META_CACHE_EXPIRE or 24) * 3600
|
||||
}
|
||||
return {
|
||||
"torrents": 100,
|
||||
"refresh": 50,
|
||||
"tmdb": 256,
|
||||
"refresh": 30,
|
||||
"torrents": 50,
|
||||
"douban": 256,
|
||||
"bangumi": 256,
|
||||
"fanart": 128,
|
||||
"meta": (self.META_CACHE_EXPIRE or 72) * 3600
|
||||
"meta": (self.META_CACHE_EXPIRE or 2) * 3600
|
||||
}
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -123,11 +124,25 @@ class TorrentInfo:
|
||||
return ""
|
||||
return StringUtils.diff_time_str(self.freedate)
|
||||
|
||||
def pub_minutes(self) -> float:
|
||||
"""
|
||||
返回发布时间距离当前时间的分钟数
|
||||
"""
|
||||
if not self.pubdate:
|
||||
return 0
|
||||
try:
|
||||
pub_date = datetime.strptime(self.pubdate, "%Y-%m-%d %H:%M:%S")
|
||||
now_datetime = datetime.now()
|
||||
return (now_datetime - pub_date).total_seconds() // 60
|
||||
except Exception as e:
|
||||
print(f"种子发布时间获取失败: {e}")
|
||||
return 0
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
返回字典
|
||||
"""
|
||||
dicts = asdict(self)
|
||||
dicts = vars(self).copy()
|
||||
dicts["volume_factor"] = self.volume_factor
|
||||
dicts["freedate_diff"] = self.freedate_diff
|
||||
return dicts
|
||||
@@ -163,6 +178,8 @@ class MediaInfo:
|
||||
douban_id: str = None
|
||||
# Bangumi ID
|
||||
bangumi_id: int = None
|
||||
# 合集ID
|
||||
collection_id: int = None
|
||||
# 媒体原语种
|
||||
original_language: str = None
|
||||
# 媒体原发行标题
|
||||
@@ -382,6 +399,8 @@ class MediaInfo:
|
||||
if info.get("external_ids"):
|
||||
self.tvdb_id = info.get("external_ids", {}).get("tvdb_id")
|
||||
self.imdb_id = info.get("external_ids", {}).get("imdb_id")
|
||||
# 合集ID
|
||||
self.collection_id = info.get('collection_id')
|
||||
# 评分
|
||||
self.vote_average = round(float(info.get('vote_average')), 1) if info.get('vote_average') else 0
|
||||
# 描述
|
||||
@@ -725,7 +744,7 @@ class MediaInfo:
|
||||
"""
|
||||
返回字典
|
||||
"""
|
||||
dicts = asdict(self)
|
||||
dicts = vars(self).copy()
|
||||
dicts["type"] = self.type.value if self.type else None
|
||||
dicts["detail_link"] = self.detail_link
|
||||
dicts["title_year"] = self.title_year
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Callable, Dict, List, Optional, Union
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.log import logger
|
||||
from app.schemas.event import ChainEventData
|
||||
from app.schemas import ChainEventData
|
||||
from app.schemas.types import ChainEventType, EventType
|
||||
from app.utils.limit import ExponentialBackoffRateLimiter
|
||||
from app.utils.singleton import Singleton
|
||||
@@ -84,7 +84,6 @@ class EventManager(metaclass=Singleton):
|
||||
self.__disabled_handlers = set() # 禁用的事件处理器集合
|
||||
self.__disabled_classes = set() # 禁用的事件处理器类集合
|
||||
self.__lock = threading.Lock() # 线程锁
|
||||
self.__processing_events = {} # 用于记录当前正在处理的事件 {event_hash: event}
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
@@ -130,14 +129,6 @@ class EventManager(metaclass=Singleton):
|
||||
for handler in handlers.values()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __get_event_hash(event: Event) -> str:
|
||||
"""
|
||||
计算事件的唯一标识符(hash)
|
||||
"""
|
||||
data_string = str(event.event_type.value) + str(event.event_data)
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_DNS, data_string))
|
||||
|
||||
def send_event(self, etype: Union[EventType, ChainEventType], data: Optional[Union[Dict, ChainEventData]] = None,
|
||||
priority: int = DEFAULT_EVENT_PRIORITY) -> Optional[Event]:
|
||||
"""
|
||||
@@ -148,12 +139,6 @@ class EventManager(metaclass=Singleton):
|
||||
:return: 如果是链式事件,返回处理后的事件数据;否则返回 None
|
||||
"""
|
||||
event = Event(etype, data, priority)
|
||||
event_hash = self.__get_event_hash(event)
|
||||
with self.__lock:
|
||||
if event_hash in self.__processing_events:
|
||||
logger.debug(f"Duplicate event ignored: {event}")
|
||||
return None
|
||||
self.__processing_events[event_hash] = event
|
||||
if isinstance(etype, EventType):
|
||||
self.__trigger_broadcast_event(event)
|
||||
elif isinstance(etype, ChainEventType):
|
||||
@@ -248,23 +233,29 @@ class EventManager(metaclass=Singleton):
|
||||
可视化所有事件处理器,包括是否被禁用的状态
|
||||
:return: 处理器列表,包含事件类型、处理器标识符、优先级(如果有)和状态
|
||||
"""
|
||||
|
||||
def parse_handler_data(data):
|
||||
"""
|
||||
解析处理器数据,判断是否包含优先级
|
||||
:param data: 订阅者数据,可能是元组或单一值
|
||||
:return: (priority, handler),若没有优先级则返回 (None, handler)
|
||||
"""
|
||||
if isinstance(data, tuple) and len(data) == 2:
|
||||
return data
|
||||
return None, data
|
||||
|
||||
handler_info = []
|
||||
# 统一处理广播事件和链式事件
|
||||
for event_type, subscribers in {**self.__broadcast_subscribers, **self.__chain_subscribers}.items():
|
||||
for handler_data in subscribers:
|
||||
if isinstance(subscribers, dict):
|
||||
priority, handler = handler_data
|
||||
else:
|
||||
priority = None
|
||||
handler = handler_data
|
||||
# 获取处理器的唯一标识符
|
||||
handler_id = self.__get_handler_identifier(handler)
|
||||
for handler_identifier, handler_data in subscribers.items():
|
||||
# 解析优先级和处理器
|
||||
priority, handler = parse_handler_data(handler_data)
|
||||
# 检查处理器的启用状态
|
||||
status = "enabled" if self.__is_handler_enabled(handler) else "disabled"
|
||||
# 构建处理器信息字典
|
||||
handler_dict = {
|
||||
"event_type": event_type.value,
|
||||
"handler_identifier": handler_id,
|
||||
"handler_identifier": handler_identifier,
|
||||
"status": status
|
||||
}
|
||||
if priority is not None:
|
||||
@@ -302,7 +293,7 @@ class EventManager(metaclass=Singleton):
|
||||
|
||||
# 对于类实例(实现了 __call__ 方法)
|
||||
if not inspect.isfunction(handler) and hasattr(handler, "__call__"):
|
||||
handler_cls = handler.__class__
|
||||
handler_cls = handler.__class__ # noqa
|
||||
return cls.__get_handler_identifier(handler_cls)
|
||||
|
||||
# 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息
|
||||
@@ -335,14 +326,9 @@ class EventManager(metaclass=Singleton):
|
||||
"""
|
||||
触发链式事件,按顺序调用订阅的处理器,并记录处理耗时
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Triggering synchronous chain event: {event}")
|
||||
dispatch = self.__dispatch_chain_event(event)
|
||||
return event if dispatch else None
|
||||
finally:
|
||||
event_hash = self.__get_event_hash(event)
|
||||
with self.__lock:
|
||||
self.__processing_events.pop(event_hash, None)
|
||||
logger.debug(f"Triggering synchronous chain event: {event}")
|
||||
dispatch = self.__dispatch_chain_event(event)
|
||||
return event if dispatch else None
|
||||
|
||||
def __trigger_broadcast_event(self, event: Event):
|
||||
"""
|
||||
@@ -361,8 +347,17 @@ class EventManager(metaclass=Singleton):
|
||||
if not handlers:
|
||||
logger.debug(f"No handlers found for chain event: {event}")
|
||||
return False
|
||||
|
||||
# 过滤出启用的处理器
|
||||
enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()
|
||||
if self.__is_handler_enabled(handler)}
|
||||
|
||||
if not enabled_handlers:
|
||||
logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.")
|
||||
return False
|
||||
|
||||
self.__log_event_lifecycle(event, "Started")
|
||||
for handler_id, (priority, handler) in handlers.items():
|
||||
for handler_id, (priority, handler) in enabled_handlers.items():
|
||||
start_time = time.time()
|
||||
self.__safe_invoke_handler(handler, event)
|
||||
logger.debug(
|
||||
@@ -383,9 +378,6 @@ class EventManager(metaclass=Singleton):
|
||||
return
|
||||
for handler_id, handler in handlers.items():
|
||||
self.__executor.submit(self.__safe_invoke_handler, handler, event)
|
||||
event_hash = self.__get_event_hash(event)
|
||||
with self.__lock:
|
||||
self.__processing_events.pop(event_hash, None)
|
||||
|
||||
def __safe_invoke_handler(self, handler: Callable, event: Event):
|
||||
"""
|
||||
@@ -446,12 +438,15 @@ class EventManager(metaclass=Singleton):
|
||||
|
||||
# 如果类不在全局变量中,尝试动态导入模块并创建实例
|
||||
try:
|
||||
# 导入模块,除了插件,只有chain能响应事件
|
||||
if not class_name.endswith("Chain"):
|
||||
if class_name == "Command":
|
||||
module_name = "app.command"
|
||||
module = importlib.import_module(module_name)
|
||||
elif class_name.endswith("Chain"):
|
||||
module_name = f"app.chain.{class_name[:-5].lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
else:
|
||||
logger.debug(f"事件处理出错:无效的 Chain 类名: {class_name},类名必须以 'Chain' 结尾")
|
||||
return None
|
||||
module_name = f"app.chain.{class_name[:-5].lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
if hasattr(module, class_name):
|
||||
class_obj = getattr(module, class_name)()
|
||||
return class_obj
|
||||
@@ -510,13 +505,15 @@ class EventManager(metaclass=Singleton):
|
||||
}
|
||||
)
|
||||
|
||||
def register(self, etype: Union[EventType, ChainEventType, List[Union[EventType, ChainEventType]], type]):
|
||||
def register(self, etype: Union[EventType, ChainEventType, List[Union[EventType, ChainEventType]], type],
|
||||
priority: int = DEFAULT_EVENT_PRIORITY):
|
||||
"""
|
||||
事件注册装饰器,用于将函数注册为事件的处理器
|
||||
:param etype:
|
||||
- 单个事件类型成员 (如 EventType.MetadataScrape, ChainEventType.PluginAction)
|
||||
- 事件类型类 (EventType, ChainEventType)
|
||||
- 或事件类型成员的列表
|
||||
:param priority: 可选,链式事件的优先级,默认为 DEFAULT_EVENT_PRIORITY
|
||||
"""
|
||||
|
||||
def decorator(f: Callable):
|
||||
@@ -524,23 +521,18 @@ class EventManager(metaclass=Singleton):
|
||||
if isinstance(etype, list):
|
||||
# 传入的已经是列表,直接使用
|
||||
event_list = etype
|
||||
elif etype is EventType:
|
||||
# 订阅所有事件
|
||||
event_list = []
|
||||
for et in etype:
|
||||
event_list.append(et)
|
||||
else:
|
||||
# 不是列表则包裹成单一元素的列表
|
||||
event_list = [etype]
|
||||
|
||||
# 遍历列表,处理每个事件类型
|
||||
# 遍历列表,处理每个事件类型
|
||||
for event in event_list:
|
||||
if isinstance(event, (EventType, ChainEventType)):
|
||||
self.add_event_listener(event, f)
|
||||
self.add_event_listener(event, f, priority)
|
||||
elif isinstance(event, type) and issubclass(event, (EventType, ChainEventType)):
|
||||
# 如果是 EventType 或 ChainEventType 类,提取该类中的所有成员
|
||||
for et in event.__members__.values():
|
||||
self.add_event_listener(et, f)
|
||||
self.add_event_listener(et, f, priority)
|
||||
else:
|
||||
raise ValueError(f"无效的事件类型: {event}")
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import traceback
|
||||
from dataclasses import dataclass, asdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Union, Optional, List, Self
|
||||
|
||||
import cn2an
|
||||
import regex as re
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -589,9 +589,10 @@ class MetaBase(object):
|
||||
"""
|
||||
转为字典
|
||||
"""
|
||||
dicts = asdict(self)
|
||||
dicts = vars(self).copy()
|
||||
dicts["type"] = self.type.value if self.type else None
|
||||
dicts["season_episode"] = self.season_episode
|
||||
dicts["edition"] = self.edition
|
||||
dicts["name"] = self.name
|
||||
dicts["episode_list"] = self.episode_list
|
||||
return dicts
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import traceback
|
||||
from typing import Generator, Optional, Tuple, Any
|
||||
from typing import Generator, Optional, Tuple, Any, Union
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.helper.module import ModuleHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import EventType, ModuleType
|
||||
from app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
|
||||
OtherModulesType
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
@@ -19,6 +20,8 @@ class ModuleManager(metaclass=Singleton):
|
||||
_modules: dict = {}
|
||||
# 运行态模块列表
|
||||
_running_modules: dict = {}
|
||||
# 子模块类型集合
|
||||
SubType = Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]
|
||||
|
||||
def __init__(self):
|
||||
self.load_modules()
|
||||
@@ -135,6 +138,17 @@ class ModuleManager(metaclass=Singleton):
|
||||
and module.get_type() == module_type:
|
||||
yield module
|
||||
|
||||
def get_running_subtype_module(self, module_subtype: SubType) -> Generator:
|
||||
"""
|
||||
获取指定子类型的模块
|
||||
"""
|
||||
if not self._running_modules:
|
||||
return []
|
||||
for _, module in self._running_modules.items():
|
||||
if hasattr(module, 'get_subtype') \
|
||||
and module.get_subtype() == module_subtype:
|
||||
yield module
|
||||
|
||||
def get_module(self, module_id: str) -> Any:
|
||||
"""
|
||||
根据模块id获取模块
|
||||
|
||||
@@ -220,11 +220,23 @@ class PluginManager(metaclass=Singleton):
|
||||
self._running_plugins = {}
|
||||
logger.info("插件停止完成")
|
||||
|
||||
def reload_monitor(self):
|
||||
"""
|
||||
重新加载插件文件修改监测
|
||||
"""
|
||||
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
|
||||
if self._observer and self._observer.is_alive():
|
||||
logger.info("插件文件修改监测已经在运行中...")
|
||||
else:
|
||||
self.__start_monitor()
|
||||
else:
|
||||
self.stop_monitor()
|
||||
|
||||
def __start_monitor(self):
|
||||
"""
|
||||
开发者模式下监测插件文件修改
|
||||
启用监测插件文件修改监测
|
||||
"""
|
||||
logger.info("开发者模式下开始监测插件文件修改...")
|
||||
logger.info("开始监测插件文件修改...")
|
||||
monitor_handler = PluginMonitorHandler()
|
||||
self._observer = Observer()
|
||||
self._observer.schedule(monitor_handler, str(settings.ROOT_PATH / "app" / "plugins"), recursive=True)
|
||||
@@ -232,14 +244,16 @@ class PluginManager(metaclass=Singleton):
|
||||
|
||||
def stop_monitor(self):
|
||||
"""
|
||||
停止监测插件修改
|
||||
停止监测插件文件修改监测
|
||||
"""
|
||||
# 停止监测
|
||||
if self._observer:
|
||||
if self._observer and self._observer.is_alive():
|
||||
logger.info("正在停止插件文件修改监测...")
|
||||
self._observer.stop()
|
||||
self._observer.join()
|
||||
logger.info("插件文件修改监测停止完成")
|
||||
else:
|
||||
logger.info("未启用插件文件修改监测,无需停止")
|
||||
|
||||
@staticmethod
|
||||
def __stop_plugin(plugin: Any):
|
||||
@@ -526,7 +540,8 @@ class PluginManager(metaclass=Singleton):
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron、interval、date、CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
"kwargs": {} # 定时器参数,
|
||||
"func_kwargs": {} # 方法参数
|
||||
}]
|
||||
"""
|
||||
ret_services = []
|
||||
@@ -667,7 +682,7 @@ class PluginManager(metaclass=Singleton):
|
||||
# 相同 ID 的插件保留版本号最大的版本
|
||||
max_versions = {}
|
||||
for p in all_plugins:
|
||||
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, max_versions[p.id]) > 0:
|
||||
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, ">", max_versions[p.id]):
|
||||
max_versions[p.id] = p.plugin_version
|
||||
result = [p for p in all_plugins if p.plugin_version == max_versions[p.id]]
|
||||
logger.info(f"共获取到 {len(result)} 个线上插件")
|
||||
@@ -808,7 +823,7 @@ class PluginManager(metaclass=Singleton):
|
||||
plugin.has_update = False
|
||||
if plugin_static:
|
||||
installed_version = getattr(plugin_static, "plugin_version")
|
||||
if StringUtils.compare_version(installed_version, plugin_info.get("version")) < 0:
|
||||
if StringUtils.compare_version(installed_version, "<", plugin_info.get("version")):
|
||||
# 需要更新
|
||||
plugin.has_update = True
|
||||
# 运行状态
|
||||
|
||||
@@ -286,7 +286,7 @@ def decrypt(data: bytes, key: bytes) -> Optional[bytes]:
|
||||
return None
|
||||
|
||||
|
||||
def encrypt_message(message: str, key: bytes):
|
||||
def encrypt_message(message: str, key: bytes) -> str:
|
||||
"""
|
||||
使用给定的key对消息进行加密,并返回加密后的字符串
|
||||
"""
|
||||
@@ -295,14 +295,14 @@ def encrypt_message(message: str, key: bytes):
|
||||
return encrypted_message.decode()
|
||||
|
||||
|
||||
def hash_sha256(message):
|
||||
def hash_sha256(message: str) -> str:
|
||||
"""
|
||||
对字符串做hash运算
|
||||
"""
|
||||
return hashlib.sha256(message.encode()).hexdigest()
|
||||
|
||||
|
||||
def aes_decrypt(data, key):
|
||||
def aes_decrypt(data: str, key: str) -> str:
|
||||
"""
|
||||
AES解密
|
||||
"""
|
||||
@@ -322,7 +322,7 @@ def aes_decrypt(data, key):
|
||||
return result.decode('utf-8')
|
||||
|
||||
|
||||
def aes_encrypt(data, key):
|
||||
def aes_encrypt(data: str, key: str) -> str:
|
||||
"""
|
||||
AES加密
|
||||
"""
|
||||
@@ -338,7 +338,7 @@ def aes_encrypt(data, key):
|
||||
return base64.b64encode(cipher.iv + result).decode('utf-8')
|
||||
|
||||
|
||||
def nexusphp_encrypt(data_str: str, key):
|
||||
def nexusphp_encrypt(data_str: str, key: bytes) -> str:
|
||||
"""
|
||||
NexusPHP加密
|
||||
"""
|
||||
|
||||
@@ -13,7 +13,7 @@ connect_args = {
|
||||
# 启用 WAL 模式时的额外配置
|
||||
if settings.DB_WAL_ENABLE:
|
||||
connect_args["check_same_thread"] = False
|
||||
kwargs = {
|
||||
db_kwargs = {
|
||||
"url": f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
||||
"pool_pre_ping": settings.DB_POOL_PRE_PING,
|
||||
"echo": settings.DB_ECHO,
|
||||
@@ -23,13 +23,13 @@ kwargs = {
|
||||
}
|
||||
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
|
||||
if pool_class == QueuePool:
|
||||
kwargs.update({
|
||||
db_kwargs.update({
|
||||
"pool_size": settings.DB_POOL_SIZE,
|
||||
"pool_timeout": settings.DB_POOL_TIMEOUT,
|
||||
"max_overflow": settings.DB_MAX_OVERFLOW
|
||||
})
|
||||
# 创建数据库引擎
|
||||
Engine = create_engine(**kwargs)
|
||||
Engine = create_engine(**db_kwargs)
|
||||
# 根据配置设置日志模式
|
||||
journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
|
||||
with Engine.connect() as connection:
|
||||
@@ -198,7 +198,7 @@ class Base:
|
||||
@classmethod
|
||||
@db_query
|
||||
def get(cls, db: Session, rid: int) -> Self:
|
||||
return db.query(cls).filter(cls.id == rid).first()
|
||||
return db.query(cls).filter(and_(cls.id == rid)).first()
|
||||
|
||||
@db_update
|
||||
def update(self, db: Session, payload: dict):
|
||||
@@ -225,7 +225,7 @@ class Base:
|
||||
return list(result)
|
||||
|
||||
def to_dict(self):
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa
|
||||
|
||||
@declared_attr
|
||||
def __tablename__(self) -> str:
|
||||
|
||||
@@ -11,7 +11,7 @@ def init_db():
|
||||
初始化数据库
|
||||
"""
|
||||
# 全量建表
|
||||
Base.metadata.create_all(bind=Engine)
|
||||
Base.metadata.create_all(bind=Engine) # noqa
|
||||
|
||||
|
||||
def update_db():
|
||||
|
||||
@@ -57,7 +57,7 @@ class MessageOper(DbOper):
|
||||
|
||||
# 从kwargs中去掉Message中没有的字段
|
||||
for k in list(kwargs.keys()):
|
||||
if k not in Message.__table__.columns.keys():
|
||||
if k not in Message.__table__.columns.keys(): # noqa
|
||||
kwargs.pop(k)
|
||||
|
||||
Message(**kwargs).create(self._db)
|
||||
|
||||
@@ -29,6 +29,8 @@ class DownloadHistory(Base):
|
||||
episodes = Column(String)
|
||||
# 海报
|
||||
image = Column(String)
|
||||
# 下载器
|
||||
downloader = Column(String)
|
||||
# 下载任务Hash
|
||||
download_hash = Column(String, index=True)
|
||||
# 种子名称
|
||||
@@ -53,7 +55,9 @@ class DownloadHistory(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_hash(db: Session, download_hash: str):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).first()
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).order_by(
|
||||
DownloadHistory.date.desc()
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -166,10 +170,10 @@ class DownloadFiles(Base):
|
||||
下载文件记录
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
# 下载任务Hash
|
||||
download_hash = Column(String, index=True)
|
||||
# 下载器
|
||||
downloader = Column(String)
|
||||
# 下载任务Hash
|
||||
download_hash = Column(String, index=True)
|
||||
# 完整路径
|
||||
fullpath = Column(String, index=True)
|
||||
# 保存路径
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON, func
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, Base
|
||||
@@ -81,7 +81,7 @@ class SiteUserData(Base):
|
||||
func.max(SiteUserData.updated_day).label('latest_update_day')
|
||||
)
|
||||
.group_by(SiteUserData.domain)
|
||||
.filter(SiteUserData.err_msg == None)
|
||||
.filter(or_(SiteUserData.err_msg.is_(None), SiteUserData.err_msg == ""))
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class Subscribe(Base):
|
||||
lack_episode = Column(Integer)
|
||||
# 附加信息
|
||||
note = Column(JSON)
|
||||
# 状态:N-新建, R-订阅中
|
||||
# 状态:N-新建 R-订阅中 P-待定 S-暂停
|
||||
state = Column(String, nullable=False, index=True, default='N')
|
||||
# 最后更新时间
|
||||
last_update = Column(String)
|
||||
@@ -98,7 +98,13 @@ class Subscribe(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_state(db: Session, state: str):
|
||||
result = db.query(Subscribe).filter(Subscribe.state == state).all()
|
||||
# 如果 state 为空或 None,返回所有订阅
|
||||
if not state:
|
||||
result = db.query(Subscribe).all()
|
||||
else:
|
||||
# 如果传入的状态不为空,拆分成多个状态
|
||||
states = state.split(',')
|
||||
result = db.query(Subscribe).filter(Subscribe.state.in_(states)).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.db import db_query, db_update, Base
|
||||
|
||||
class TransferHistory(Base):
|
||||
"""
|
||||
转移历史记录
|
||||
整理记录
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
# 源路径
|
||||
@@ -43,6 +43,8 @@ class TransferHistory(Base):
|
||||
episodes = Column(String)
|
||||
# 海报
|
||||
image = Column(String)
|
||||
# 下载器
|
||||
downloader = Column(String)
|
||||
# 下载器hash
|
||||
download_hash = Column(String, index=True)
|
||||
# 转移成功状态
|
||||
|
||||
@@ -114,7 +114,8 @@ class SiteOper(DbOper):
|
||||
"domain": domain,
|
||||
"name": name,
|
||||
"updated_day": current_day,
|
||||
"updated_time": current_time
|
||||
"updated_time": current_time,
|
||||
"err_msg": payload.get("err_msg") or ""
|
||||
})
|
||||
# 按站点+天判断是否存在数据
|
||||
siteuserdatas = SiteUserData.get_by_domain(self._db, domain=domain, workdate=current_day)
|
||||
|
||||
@@ -83,7 +83,8 @@ class SubscribeOper(DbOper):
|
||||
更新订阅
|
||||
"""
|
||||
subscribe = self.get(sid)
|
||||
subscribe.update(self._db, payload)
|
||||
if subscribe:
|
||||
subscribe.update(self._db, payload)
|
||||
return subscribe
|
||||
|
||||
def list_by_tmdbid(self, tmdbid: int, season: int = None) -> List[Subscribe]:
|
||||
|
||||
@@ -120,7 +120,7 @@ class TransferHistoryOper(DbOper):
|
||||
|
||||
def add_success(self, fileitem: FileItem, mode: str, meta: MetaBase,
|
||||
mediainfo: MediaInfo, transferinfo: TransferInfo,
|
||||
download_hash: str = None):
|
||||
downloader: str = None, download_hash: str = None):
|
||||
"""
|
||||
新增转移成功历史记录
|
||||
"""
|
||||
@@ -143,13 +143,14 @@ class TransferHistoryOper(DbOper):
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
image=mediainfo.get_poster_image(),
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
status=1,
|
||||
files=transferinfo.file_list
|
||||
)
|
||||
|
||||
def add_fail(self, fileitem: FileItem, mode: str, meta: MetaBase, mediainfo: MediaInfo = None,
|
||||
transferinfo: TransferInfo = None, download_hash: str = None):
|
||||
transferinfo: TransferInfo = None, downloader: str = None, download_hash: str = None):
|
||||
"""
|
||||
新增转移失败历史记录
|
||||
"""
|
||||
@@ -173,6 +174,7 @@ class TransferHistoryOper(DbOper):
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
image=mediainfo.get_poster_image(),
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
status=0,
|
||||
errmsg=transferinfo.message or '未知错误',
|
||||
@@ -188,6 +190,7 @@ class TransferHistoryOper(DbOper):
|
||||
mode=mode,
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
status=0,
|
||||
errmsg="未识别到媒体信息"
|
||||
|
||||
@@ -16,7 +16,7 @@ class PlaywrightHelper:
|
||||
"""
|
||||
sync_stealth(page, pure=True)
|
||||
page.goto(url)
|
||||
return sync_cf_retry(page)
|
||||
return sync_cf_retry(page)[0]
|
||||
|
||||
def action(self, url: str,
|
||||
callback: Callable,
|
||||
|
||||
@@ -5,6 +5,7 @@ from app import schemas
|
||||
from app.core.context import MediaInfo
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class DirectoryHelper:
|
||||
@@ -48,16 +49,18 @@ class DirectoryHelper:
|
||||
"""
|
||||
return [d for d in self.get_library_dirs() if d.library_storage == "local"]
|
||||
|
||||
def get_dir(self, media: MediaInfo, storage: str = "local",
|
||||
src_path: Path = None, dest_path: Path = None, fileitem: schemas.FileItem = None
|
||||
def get_dir(self, media: MediaInfo, include_unsorted: bool = False,
|
||||
storage: str = None, src_path: Path = None,
|
||||
target_storage: str = None, dest_path: Path = None
|
||||
) -> Optional[schemas.TransferDirectoryConf]:
|
||||
"""
|
||||
根据媒体信息获取下载目录、媒体库目录配置
|
||||
:param media: 媒体信息
|
||||
:param storage: 存储类型
|
||||
:param include_unsorted: 包含不整理目录
|
||||
:param storage: 源存储类型
|
||||
:param target_storage: 目标存储类型
|
||||
:param src_path: 源目录,有值时直接匹配
|
||||
:param dest_path: 目标目录,有值时直接匹配
|
||||
:param fileitem: 文件项,使用文件路径匹配
|
||||
"""
|
||||
# 处理类型
|
||||
if not media:
|
||||
@@ -65,35 +68,46 @@ class DirectoryHelper:
|
||||
# 电影/电视剧
|
||||
media_type = media.type.value
|
||||
dirs = self.get_dirs()
|
||||
|
||||
# 如果存在源目录,并源目录为任一下载目录的子目录时,则进行源目录匹配,否则,允许源目录按同盘优先的逻辑匹配
|
||||
matching_dirs = [d for d in dirs if src_path.is_relative_to(d.download_path)] if src_path else []
|
||||
# 根据是否有匹配的源目录,决定要考虑的目录集合
|
||||
dirs_to_consider = matching_dirs if matching_dirs else dirs
|
||||
|
||||
# 已匹配的目录
|
||||
matched_dirs: List[schemas.TransferDirectoryConf] = []
|
||||
# 按照配置顺序查找
|
||||
for d in dirs:
|
||||
for d in dirs_to_consider:
|
||||
# 没有启用整理的目录
|
||||
if not d.monitor_type:
|
||||
if not d.monitor_type and not include_unsorted:
|
||||
continue
|
||||
# 存储类型不匹配
|
||||
# 源存储类型不匹配
|
||||
if storage and d.storage != storage:
|
||||
continue
|
||||
# 下载目录
|
||||
download_path = Path(d.download_path)
|
||||
# 媒体库目录
|
||||
library_path = Path(d.library_path)
|
||||
# 有源目录时,源目录不匹配下载目录
|
||||
if src_path and not src_path.is_relative_to(download_path):
|
||||
continue
|
||||
# 有文件项时,文件项不匹配下载目录
|
||||
if fileitem and not Path(fileitem.path).is_relative_to(download_path):
|
||||
# 目标存储类型不匹配
|
||||
if target_storage and d.library_storage != target_storage:
|
||||
continue
|
||||
# 有目标目录时,目标目录不匹配媒体库目录
|
||||
if dest_path and not dest_path.is_relative_to(library_path):
|
||||
if dest_path and dest_path != Path(d.library_path):
|
||||
continue
|
||||
# 目录类型为全部的,符合条件
|
||||
if not d.media_type:
|
||||
return d
|
||||
matched_dirs.append(d)
|
||||
continue
|
||||
# 目录类型相等,目录类别为全部,符合条件
|
||||
if d.media_type == media_type and not d.media_category:
|
||||
return d
|
||||
matched_dirs.append(d)
|
||||
continue
|
||||
# 目录类型相等,目录类别相等,符合条件
|
||||
if d.media_type == media_type and d.media_category == media.category:
|
||||
return d
|
||||
|
||||
matched_dirs.append(d)
|
||||
continue
|
||||
if matched_dirs:
|
||||
if src_path:
|
||||
# 优先源目录同盘
|
||||
for matched_dir in matched_dirs:
|
||||
matched_path = Path(matched_dir.download_path)
|
||||
if SystemUtils.is_same_disk(matched_path, src_path):
|
||||
return matched_dir
|
||||
return matched_dirs[0]
|
||||
return None
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import Tuple, Optional
|
||||
|
||||
import parse
|
||||
|
||||
from app.core.meta.metabase import MetaBase
|
||||
|
||||
|
||||
class FormatParser(object):
|
||||
_key = ""
|
||||
@@ -77,25 +79,38 @@ class FormatParser(object):
|
||||
return True
|
||||
return False
|
||||
|
||||
def split_episode(self, file_name: str) -> Tuple[Optional[int], Optional[int], Optional[str]]:
|
||||
def split_episode(self, file_name: str, file_meta: MetaBase) -> 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("-")
|
||||
start_ep = self.__offset.replace("EP", s)
|
||||
end_ep = self.__offset.replace("EP", e)
|
||||
if int(s) == int(e):
|
||||
if self._start_ep is not None:
|
||||
if self._start_ep == self._end_ep:
|
||||
# `details` 格式为 `X-X` 或者 `X`
|
||||
if isinstance(self._start_ep, str):
|
||||
# `details` 格式为 `X-X`
|
||||
s, e = self._start_ep.split("-")
|
||||
start_ep = self.__offset.replace("EP", s)
|
||||
end_ep = self.__offset.replace("EP", e)
|
||||
if int(s) == int(e):
|
||||
return int(eval(start_ep)), None, self.part
|
||||
return int(eval(start_ep)), int(eval(end_ep)), self.part
|
||||
else:
|
||||
# `details` 格式为 `X`
|
||||
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
||||
return int(eval(start_ep)), None, self.part
|
||||
return int(eval(start_ep)), int(eval(end_ep)), self.part
|
||||
else:
|
||||
# `details` 格式为 `X,X`
|
||||
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
||||
return int(eval(start_ep)), None, self.part
|
||||
end_ep = self.__offset.replace("EP", str(self._end_ep))
|
||||
return int(eval(start_ep)), int(eval(end_ep)), self.part
|
||||
if not self._format:
|
||||
return self._start_ep, self._end_ep, self.part
|
||||
# 未填入`集数定位` 且没有`指定集数` 仅处理`集数偏移`
|
||||
start_ep = eval(self.__offset.replace("EP", str(file_meta.begin_episode))) if file_meta.begin_episode else None
|
||||
end_ep = eval(self.__offset.replace("EP", str(file_meta.end_episode))) if file_meta.end_episode else None
|
||||
return int(start_ep) if start_ep else None, int(end_ep) if end_ep else None, self.part
|
||||
else:
|
||||
# 有`集数定位`
|
||||
s, e = self.__handle_single(file_name)
|
||||
start_ep = self.__offset.replace("EP", str(s)) if s else None
|
||||
end_ep = self.__offset.replace("EP", str(e)) if e else None
|
||||
|
||||
@@ -120,7 +120,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
"""
|
||||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||||
return {}
|
||||
res = RequestUtils(timeout=10).get_res(self._install_statistic)
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=10).get_res(self._install_statistic)
|
||||
if res and res.status_code == 200:
|
||||
return res.json()
|
||||
return {}
|
||||
@@ -134,7 +134,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
if not pid:
|
||||
return False
|
||||
install_reg_url = self._install_reg.format(pid=pid)
|
||||
res = RequestUtils(timeout=5).get_res(install_reg_url)
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5).get_res(install_reg_url)
|
||||
if res and res.status_code == 200:
|
||||
return True
|
||||
return False
|
||||
@@ -148,7 +148,8 @@ class PluginHelper(metaclass=Singleton):
|
||||
plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins)
|
||||
if not plugins:
|
||||
return False
|
||||
res = RequestUtils(content_type="application/json",
|
||||
res = RequestUtils(proxies=settings.PROXY,
|
||||
content_type="application/json",
|
||||
timeout=5).post(self._install_report,
|
||||
json={"plugins": [{"plugin_id": plugin} for plugin in plugins]})
|
||||
return True if res else False
|
||||
|
||||
@@ -70,7 +70,7 @@ class ResourceHelper(metaclass=Singleton):
|
||||
local_version = self.siteshelper.indexer_version
|
||||
else:
|
||||
continue
|
||||
if StringUtils.compare_version(version, local_version) > 0:
|
||||
if StringUtils.compare_version(version, ">", local_version):
|
||||
logger.info(f"{rname} 资源包有更新,最新版本:v{version}")
|
||||
else:
|
||||
continue
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class SubscribeHelper(metaclass=Singleton):
|
||||
@@ -30,8 +31,11 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
|
||||
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
|
||||
|
||||
_shares_cache = TTLCache(maxsize=20, ttl=1800)
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.share_user_id = SystemUtils.generate_user_unique_id()
|
||||
if settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
if not self.systemconfig.get(SystemConfigKey.SubscribeReport):
|
||||
if self.sub_report():
|
||||
@@ -44,7 +48,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return []
|
||||
res = RequestUtils(timeout=15).get_res(self._sub_statistic, params={
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_statistic, params={
|
||||
"stype": stype,
|
||||
"page": page,
|
||||
"count": count
|
||||
@@ -59,7 +63,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False
|
||||
res = RequestUtils(timeout=5, headers={
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).post_res(self._sub_reg, json=sub)
|
||||
if res and res.status_code == 200:
|
||||
@@ -72,7 +76,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False
|
||||
res = RequestUtils(timeout=5, headers={
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).post_res(self._sub_done, json=sub)
|
||||
if res and res.status_code == 200:
|
||||
@@ -104,7 +108,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
subscribes = SubscribeOper().list()
|
||||
if not subscribes:
|
||||
return True
|
||||
res = RequestUtils(content_type="application/json",
|
||||
res = RequestUtils(proxies=settings.PROXY, content_type="application/json",
|
||||
timeout=10).post(self._sub_report,
|
||||
json={
|
||||
"subscribes": [
|
||||
@@ -125,17 +129,38 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
return False, "订阅不存在"
|
||||
subscribe_dict = subscribe.to_dict()
|
||||
subscribe_dict.pop("id")
|
||||
res = RequestUtils(content_type="application/json",
|
||||
res = RequestUtils(proxies=settings.PROXY, content_type="application/json",
|
||||
timeout=10).post(self._sub_share,
|
||||
json={
|
||||
"share_title": share_title,
|
||||
"share_comment": share_comment,
|
||||
"share_user": share_user,
|
||||
"share_uid": self.share_user_id,
|
||||
**subscribe_dict
|
||||
})
|
||||
if res is None:
|
||||
return False, "连接MoviePilot服务器失败"
|
||||
if res.ok:
|
||||
# 清除 get_shares 的缓存,以便实时看到结果
|
||||
self._shares_cache.clear()
|
||||
return True, ""
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
|
||||
def share_delete(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
删除分享
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False, "当前没有开启订阅数据共享功能"
|
||||
res = RequestUtils(proxies=settings.PROXY,
|
||||
timeout=5).delete_res(f"{self._sub_share}/{share_id}",
|
||||
params={"share_uid": self.share_user_id})
|
||||
if res is None:
|
||||
return False, "连接MoviePilot服务器失败"
|
||||
if res.ok:
|
||||
# 清除 get_shares 的缓存,以便实时看到结果
|
||||
self._shares_cache.clear()
|
||||
return True, ""
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
@@ -146,7 +171,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False, "当前没有开启订阅数据共享功能"
|
||||
res = RequestUtils(timeout=5, headers={
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).get_res(self._sub_fork % share_id)
|
||||
if res is None:
|
||||
@@ -156,14 +181,14 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
|
||||
@cached(cache=TTLCache(maxsize=20, ttl=1800))
|
||||
@cached(cache=_shares_cache)
|
||||
def get_shares(self, name: str, page: int = 1, count: int = 30) -> List[dict]:
|
||||
"""
|
||||
获取订阅分享数据
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return []
|
||||
res = RequestUtils(timeout=15).get_res(self._sub_shares, params={
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_shares, params={
|
||||
"name": name,
|
||||
"page": page,
|
||||
"count": count
|
||||
|
||||
@@ -9,6 +9,7 @@ from torrentool.api import Torrent
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context, TorrentInfo, MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
@@ -64,10 +65,10 @@ class TorrentHelper(metaclass=Singleton):
|
||||
if not req.content:
|
||||
return None, None, "", [], "未下载到种子数据"
|
||||
# 解析内容格式
|
||||
if req.text and str(req.text).startswith("magnet:"):
|
||||
if req.content.startswith(b"magnet:"):
|
||||
# 磁力链接
|
||||
return None, req.text, "", [], f"获取到磁力链接"
|
||||
elif req.text and "下载种子文件" in req.text:
|
||||
if "下载种子文件".encode("utf-8") in req.content:
|
||||
# 首次下载提示页面
|
||||
skip_flag = False
|
||||
try:
|
||||
@@ -290,7 +291,7 @@ class TorrentHelper(metaclass=Singleton):
|
||||
if not file_path.suffix or file_path.suffix.lower() not in settings.RMT_MEDIAEXT:
|
||||
continue
|
||||
# 只使用文件名识别
|
||||
meta = MetaInfo(file_path.stem)
|
||||
meta = MetaInfo(file_path.name)
|
||||
if not meta.begin_episode:
|
||||
continue
|
||||
episodes = list(set(episodes).union(set(meta.episode_list)))
|
||||
@@ -445,3 +446,38 @@ class TorrentHelper(metaclass=Singleton):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def match_season_episodes(torrent: TorrentInfo, meta: MetaBase, season_episodes: Dict[int, list]) -> bool:
|
||||
"""
|
||||
判断种子是否匹配季集数
|
||||
:param torrent: 种子信息
|
||||
:param meta: 种子元数据
|
||||
:param season_episodes: 季集数 {season:[episodes]}
|
||||
"""
|
||||
# 匹配季
|
||||
seasons = season_episodes.keys()
|
||||
# 种子季
|
||||
torrent_seasons = meta.season_list
|
||||
if not torrent_seasons:
|
||||
# 按第一季处理
|
||||
torrent_seasons = [1]
|
||||
# 种子集
|
||||
torrent_episodes = meta.episode_list
|
||||
if not set(torrent_seasons).issubset(set(seasons)):
|
||||
# 种子季不在过滤季中
|
||||
logger.debug(
|
||||
f"种子 {torrent.site_name} - {torrent.title} 包含季 {torrent_seasons} 不是需要的季 {list(seasons)}")
|
||||
return False
|
||||
if not torrent_episodes:
|
||||
# 整季按匹配处理
|
||||
return True
|
||||
if len(torrent_seasons) == 1:
|
||||
need_episodes = season_episodes.get(torrent_seasons[0])
|
||||
if need_episodes \
|
||||
and not set(torrent_episodes).intersection(set(need_episodes)):
|
||||
# 单季集没有交集的不要
|
||||
logger.debug(f"种子 {torrent.site_name} - {torrent.title} "
|
||||
f"集 {torrent_episodes} 没有需要的集:{need_episodes}")
|
||||
return False
|
||||
return True
|
||||
|
||||
117
app/log.py
117
app/log.py
@@ -1,19 +1,24 @@
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
import click
|
||||
from pydantic import BaseSettings
|
||||
from pydantic import BaseSettings, BaseModel
|
||||
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class LogSettings(BaseSettings):
|
||||
class LogConfigModel(BaseModel):
|
||||
"""
|
||||
日志设置
|
||||
Pydantic 配置模型,描述所有配置项及其类型和默认值
|
||||
"""
|
||||
|
||||
class Config:
|
||||
extra = "ignore" # 忽略未定义的配置项
|
||||
|
||||
# 配置文件目录
|
||||
CONFIG_DIR: Optional[str] = None
|
||||
# 是否为调试模式
|
||||
@@ -29,6 +34,12 @@ class LogSettings(BaseSettings):
|
||||
# 文件日志格式
|
||||
LOG_FILE_FORMAT: str = "【%(levelname)s】%(asctime)s - %(message)s"
|
||||
|
||||
|
||||
class LogSettings(BaseSettings, LogConfigModel):
|
||||
"""
|
||||
日志设置类
|
||||
"""
|
||||
|
||||
@property
|
||||
def CONFIG_PATH(self):
|
||||
return SystemUtils.get_config_path(self.CONFIG_DIR)
|
||||
@@ -85,6 +96,8 @@ class LoggerManager:
|
||||
_loggers: Dict[str, Any] = {}
|
||||
# 默认日志文件名称
|
||||
_default_log_file = "moviepilot.log"
|
||||
# 线程锁
|
||||
_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def __get_caller():
|
||||
@@ -96,35 +109,54 @@ class LoggerManager:
|
||||
caller_name = None
|
||||
# 调用者插件名称
|
||||
plugin_name = None
|
||||
for i in inspect.stack()[3:]:
|
||||
filepath = Path(i.filename)
|
||||
|
||||
try:
|
||||
frame = sys._getframe(3) # noqa
|
||||
except (AttributeError, ValueError):
|
||||
# 如果无法获取帧,返回默认值
|
||||
return "log.py", None
|
||||
|
||||
while frame:
|
||||
filepath = Path(frame.f_code.co_filename)
|
||||
parts = filepath.parts
|
||||
# 设定调用者文件名称
|
||||
if not caller_name:
|
||||
# 设定调用者文件名称
|
||||
if parts[-1] == "__init__.py":
|
||||
if parts[-1] == "__init__.py" and len(parts) >= 2:
|
||||
caller_name = parts[-2]
|
||||
else:
|
||||
caller_name = parts[-1]
|
||||
# 设定调用者插件名称
|
||||
if "app" in parts:
|
||||
if not plugin_name and "plugins" in parts:
|
||||
# 设定调用者插件名称
|
||||
plugin_name = parts[parts.index("plugins") + 1]
|
||||
if plugin_name == "__init__.py":
|
||||
plugin_name = "plugin"
|
||||
break
|
||||
try:
|
||||
plugins_index = parts.index("plugins")
|
||||
if plugins_index + 1 < len(parts):
|
||||
plugin_candidate = parts[plugins_index + 1]
|
||||
if plugin_candidate == "__init__.py":
|
||||
plugin_name = "plugin"
|
||||
else:
|
||||
plugin_name = plugin_candidate
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if "main.py" in parts:
|
||||
# 已经到达程序的入口
|
||||
# 已经到达程序的入口,停止遍历
|
||||
break
|
||||
elif len(parts) != 1:
|
||||
# 已经超出程序范围
|
||||
# 已经超出程序范围,停止遍历
|
||||
break
|
||||
# 获取上一个帧
|
||||
try:
|
||||
frame = frame.f_back
|
||||
except AttributeError:
|
||||
break
|
||||
return caller_name or "log.py", plugin_name
|
||||
|
||||
@staticmethod
|
||||
def __setup_logger(log_file: str):
|
||||
"""
|
||||
设置日志
|
||||
log_file:日志文件相对路径
|
||||
初始化日志实例
|
||||
:param log_file:日志文件相对路径
|
||||
"""
|
||||
log_file_path = log_settings.LOG_PATH / log_file
|
||||
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -132,11 +164,8 @@ class LoggerManager:
|
||||
# 创建新实例
|
||||
_logger = logging.getLogger(log_file_path.stem)
|
||||
|
||||
if log_settings.DEBUG:
|
||||
_logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
loglevel = getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO)
|
||||
_logger.setLevel(loglevel)
|
||||
# 设置日志级别
|
||||
_logger.setLevel(LoggerManager.__get_log_level())
|
||||
|
||||
# 移除已有的 handler,避免重复添加
|
||||
for handler in _logger.handlers:
|
||||
@@ -162,6 +191,46 @@ class LoggerManager:
|
||||
|
||||
return _logger
|
||||
|
||||
def update_loggers(self):
|
||||
"""
|
||||
更新日志实例
|
||||
"""
|
||||
with LoggerManager._lock:
|
||||
for _logger in self._loggers.values():
|
||||
self.__update_logger_handlers(_logger)
|
||||
|
||||
@staticmethod
|
||||
def __update_logger_handlers(_logger: logging.Logger):
|
||||
"""
|
||||
更新 Logger 的 handler 配置
|
||||
:param _logger: 需要更新的 Logger 实例
|
||||
"""
|
||||
# 更新现有 handler
|
||||
for handler in _logger.handlers:
|
||||
try:
|
||||
if isinstance(handler, RotatingFileHandler):
|
||||
# 更新最大文件大小和备份数量
|
||||
handler.maxBytes = log_settings.LOG_MAX_FILE_SIZE_BYTES
|
||||
handler.backupCount = log_settings.LOG_BACKUP_COUNT
|
||||
# 更新日志文件输出格式
|
||||
file_formatter = CustomFormatter(log_settings.LOG_FILE_FORMAT)
|
||||
handler.setFormatter(file_formatter)
|
||||
elif isinstance(handler, logging.StreamHandler):
|
||||
# 更新控制台输出格式
|
||||
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
|
||||
handler.setFormatter(console_formatter)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update handler: {handler}. Error: {e}")
|
||||
# 更新日志级别
|
||||
_logger.setLevel(LoggerManager.__get_log_level())
|
||||
|
||||
@staticmethod
|
||||
def __get_log_level():
|
||||
"""
|
||||
获取当前日志级别
|
||||
"""
|
||||
return logging.DEBUG if log_settings.DEBUG else getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO)
|
||||
|
||||
def logger(self, method: str, msg: str, *args, **kwargs):
|
||||
"""
|
||||
获取模块的logger
|
||||
@@ -181,7 +250,7 @@ class LoggerManager:
|
||||
# 获取调用者的模块的logger
|
||||
_logger = self._loggers.get(logfile)
|
||||
if not _logger:
|
||||
_logger = self.__setup_logger(logfile)
|
||||
_logger = self.__setup_logger(log_file=logfile)
|
||||
self._loggers[logfile] = _logger
|
||||
# 调用logger的方法打印日志
|
||||
if hasattr(_logger, method):
|
||||
@@ -210,7 +279,7 @@ class LoggerManager:
|
||||
"""
|
||||
输出警告级别日志(兼容)
|
||||
"""
|
||||
self.logger("warning", msg, *args, **kwargs)
|
||||
self.warning(msg, *args, **kwargs)
|
||||
|
||||
def error(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -2,8 +2,9 @@ from abc import abstractmethod, ABCMeta
|
||||
from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable
|
||||
|
||||
from app.helper.service import ServiceConfigHelper
|
||||
from app.schemas import Notification, MessageChannel, NotificationConf, MediaServerConf, DownloaderConf
|
||||
from app.schemas.types import ModuleType
|
||||
from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf
|
||||
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
|
||||
OtherModulesType
|
||||
|
||||
|
||||
class _ModuleBase(metaclass=ABCMeta):
|
||||
@@ -43,6 +44,14 @@ class _ModuleBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_subtype() -> Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]:
|
||||
"""
|
||||
获取模块子类型(下载器、媒体服务器、消息通道、存储类型、其他杂项模块类型)
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_priority() -> int:
|
||||
|
||||
@@ -7,7 +7,7 @@ from app.core.meta import MetaBase
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.bangumi.bangumi import BangumiApi
|
||||
from app.schemas.types import ModuleType
|
||||
from app.schemas.types import ModuleType, MediaRecognizeType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
@@ -44,6 +44,13 @@ class BangumiModule(_ModuleBase):
|
||||
获取模块类型
|
||||
"""
|
||||
return ModuleType.MediaRecognize
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaRecognizeType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MediaRecognizeType.Bangumi
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
import requests
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
from app.core.config import settings
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
@@ -28,7 +29,7 @@ class BangumiApi(object):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=128)
|
||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["bangumi"], ttl=settings.CACHE_CONF["meta"]))
|
||||
def __invoke(cls, url, **kwargs):
|
||||
req_url = cls._base_url + url
|
||||
params = {}
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.modules.douban.apiv2 import DoubanApi
|
||||
from app.modules.douban.douban_cache import DoubanCache
|
||||
from app.modules.douban.scraper import DoubanScraper
|
||||
from app.schemas import MediaPerson, APIRateLimitException
|
||||
from app.schemas.types import MediaType, ModuleType
|
||||
from app.schemas.types import MediaType, ModuleType, MediaRecognizeType
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.limit import rate_limit_exponential
|
||||
@@ -59,6 +59,13 @@ class DoubanModule(_ModuleBase):
|
||||
"""
|
||||
return ModuleType.MediaRecognize
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaRecognizeType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MediaRecognizeType.Douban
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
|
||||
@@ -3,11 +3,11 @@ import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from random import choice
|
||||
from urllib import parse
|
||||
|
||||
import requests
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
from app.core.config import settings
|
||||
from app.utils.http import RequestUtils
|
||||
@@ -160,12 +160,12 @@ class DoubanApi(metaclass=Singleton):
|
||||
self._session = requests.Session()
|
||||
|
||||
@classmethod
|
||||
def __sign(cls, url: str, ts: int, method='GET') -> str:
|
||||
def __sign(cls, url: str, ts: str, method='GET') -> str:
|
||||
"""
|
||||
签名
|
||||
"""
|
||||
url_path = parse.urlparse(url).path
|
||||
raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), str(ts)])
|
||||
raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), ts])
|
||||
return base64.b64encode(
|
||||
hmac.new(
|
||||
cls._api_secret_key.encode(),
|
||||
@@ -174,7 +174,20 @@ class DoubanApi(metaclass=Singleton):
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
|
||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
|
||||
def __invoke_recommend(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
推荐/发现类API
|
||||
"""
|
||||
return self.__invoke(url, **kwargs)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
|
||||
def __invoke_search(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
搜索类API
|
||||
"""
|
||||
return self.__invoke(url, **kwargs)
|
||||
|
||||
def __invoke(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
GET请求
|
||||
@@ -203,7 +216,7 @@ class DoubanApi(metaclass=Singleton):
|
||||
return resp.json()
|
||||
return resp.json() if resp else {}
|
||||
|
||||
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
|
||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
|
||||
def __post(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
POST请求
|
||||
@@ -244,189 +257,189 @@ class DoubanApi(metaclass=Singleton):
|
||||
"""
|
||||
关键字搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_search(self._urls["search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_search(self._urls["movie_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_search(self._urls["tv_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def book_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
书籍搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["book_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_search(self._urls["book_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def group_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
小组搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["group_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_search(self._urls["group_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def person_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
人物搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["search_subject"], type="person", q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_search(self._urls["search_subject"], type="person", q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_showing(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
正在热映
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_showing"],
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["movie_showing"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_soon(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
即将上映
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_soon"],
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["movie_soon"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_hot_gaia(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
热门电影
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_hot_gaia"],
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["movie_hot_gaia"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_hot(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
热门剧集
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_hot"],
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["tv_hot"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_animation(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
动画
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_animation"],
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["tv_animation"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_variety_show(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
综艺
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_variety_show"],
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["tv_variety_show"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_rank_list(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视剧排行榜
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_rank_list"],
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["tv_rank_list"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def show_hot(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
综艺热门
|
||||
"""
|
||||
return self.__invoke(self._urls["show_hot"],
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["show_hot"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_detail(self, subject_id: str):
|
||||
"""
|
||||
电影详情
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_detail"] + subject_id)
|
||||
return self.__invoke_search(self._urls["movie_detail"] + subject_id)
|
||||
|
||||
def movie_celebrities(self, subject_id: str):
|
||||
"""
|
||||
电影演职员
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_celebrities"] % subject_id)
|
||||
return self.__invoke_search(self._urls["movie_celebrities"] % subject_id)
|
||||
|
||||
def tv_detail(self, subject_id: str):
|
||||
"""
|
||||
电视剧详情
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_detail"] + subject_id)
|
||||
return self.__invoke_search(self._urls["tv_detail"] + subject_id)
|
||||
|
||||
def tv_celebrities(self, subject_id: str):
|
||||
"""
|
||||
电视剧演职员
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_celebrities"] % subject_id)
|
||||
return self.__invoke_search(self._urls["tv_celebrities"] % subject_id)
|
||||
|
||||
def book_detail(self, subject_id: str):
|
||||
"""
|
||||
书籍详情
|
||||
"""
|
||||
return self.__invoke(self._urls["book_detail"] + subject_id)
|
||||
return self.__invoke_search(self._urls["book_detail"] + subject_id)
|
||||
|
||||
def movie_top250(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影TOP250
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_top250"],
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["movie_top250"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_recommend(self, tags='', sort='R', start: int = 0, count: int = 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)
|
||||
return self.__invoke_recommend(self._urls["movie_recommend"], tags=tags, sort=sort,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_recommend(self, tags='', sort='R', start: int = 0, count: int = 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)
|
||||
return self.__invoke_recommend(self._urls["tv_recommend"], tags=tags, sort=sort,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_chinese_best_weekly(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
华语口碑周榜
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_chinese_best_weekly"],
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["tv_chinese_best_weekly"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_global_best_weekly(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
全球口碑周榜
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_global_best_weekly"],
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["tv_global_best_weekly"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def doulist_detail(self, subject_id: str):
|
||||
"""
|
||||
豆列详情
|
||||
:param subject_id: 豆列id
|
||||
"""
|
||||
return self.__invoke(self._urls["doulist"] + subject_id)
|
||||
return self.__invoke_search(self._urls["doulist"] + subject_id)
|
||||
|
||||
def doulist_items(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
@@ -437,8 +450,8 @@ 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_search(self._urls["doulist_items"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_recommendations(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
@@ -449,8 +462,8 @@ class DoubanApi(metaclass=Singleton):
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_recommendations"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["movie_recommendations"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_recommendations(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
@@ -461,8 +474,8 @@ class DoubanApi(metaclass=Singleton):
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_recommendations"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_recommend(self._urls["tv_recommendations"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_photos(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
@@ -473,8 +486,8 @@ class DoubanApi(metaclass=Singleton):
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_photos"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_search(self._urls["movie_photos"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_photos(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
@@ -485,8 +498,8 @@ class DoubanApi(metaclass=Singleton):
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_photos"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_search(self._urls["tv_photos"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def person_detail(self, subject_id: int):
|
||||
"""
|
||||
@@ -494,7 +507,7 @@ class DoubanApi(metaclass=Singleton):
|
||||
:param subject_id: 人物 id
|
||||
:return:
|
||||
"""
|
||||
return self.__invoke(self._urls["person_detail"] + str(subject_id))
|
||||
return self.__invoke_search(self._urls["person_detail"] + str(subject_id))
|
||||
|
||||
def person_work(self, subject_id: int, start: int = 0, count: int = 20, sort_by: str = "time",
|
||||
collection_title: str = "影视",
|
||||
@@ -509,14 +522,16 @@ class DoubanApi(metaclass=Singleton):
|
||||
:param ts: 时间戳
|
||||
:return:
|
||||
"""
|
||||
return self.__invoke(self._urls["person_work"] % subject_id, sortby=sort_by, collection_title=collection_title,
|
||||
start=start, count=count, _ts=ts)
|
||||
return self.__invoke_search(self._urls["person_work"] % subject_id, sortby=sort_by,
|
||||
collection_title=collection_title,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
清空LRU缓存
|
||||
"""
|
||||
self.__invoke.cache_clear()
|
||||
# 尚未支持缓存清理
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
if self._session:
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.schemas.types import MediaType
|
||||
lock = RLock()
|
||||
|
||||
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
|
||||
EXPIRE_TIMESTAMP = settings.CACHE_CONF.get('meta')
|
||||
EXPIRE_TIMESTAMP = settings.CACHE_CONF["meta"]
|
||||
|
||||
|
||||
class DoubanCache(metaclass=Singleton):
|
||||
@@ -77,7 +77,7 @@ class DoubanCache(metaclass=Singleton):
|
||||
@return: 被删除的缓存内容
|
||||
"""
|
||||
with lock:
|
||||
return self._meta_data.pop(key, None)
|
||||
return self._meta_data.pop(key, {})
|
||||
|
||||
def delete_by_doubanid(self, doubanid: str) -> None:
|
||||
"""
|
||||
@@ -179,7 +179,7 @@ class DoubanCache(metaclass=Singleton):
|
||||
return
|
||||
|
||||
with open(self._meta_path, 'wb') as f:
|
||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL)
|
||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa
|
||||
|
||||
def _random_sample(self, new_meta_data: dict) -> bool:
|
||||
"""
|
||||
|
||||
@@ -28,7 +28,7 @@ class DoubanScraper:
|
||||
# 电视剧元数据文件
|
||||
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
||||
if doc:
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ from app.core.event import eventmanager
|
||||
from app.log import logger
|
||||
from app.modules import _MediaServerBase, _ModuleBase
|
||||
from app.modules.emby.emby import Emby
|
||||
from app.schemas.event import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType
|
||||
|
||||
|
||||
class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
@@ -30,6 +29,13 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
"""
|
||||
return ModuleType.MediaServer
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaServerType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MediaServerType.Emby
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
@@ -66,24 +72,34 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
logger.info(f"Emby服务器 {name} 连接断开,尝试重连 ...")
|
||||
server.reconnect()
|
||||
|
||||
def user_authenticate(self, credentials: AuthCredentials) -> Optional[AuthCredentials]:
|
||||
def user_authenticate(self, credentials: schemas.AuthCredentials, service_name: Optional[str] = None) \
|
||||
-> Optional[schemas.AuthCredentials]:
|
||||
"""
|
||||
使用Emby用户辅助完成用户认证
|
||||
:param credentials: 认证数据
|
||||
:param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务
|
||||
:return: 认证数据
|
||||
"""
|
||||
# Emby认证
|
||||
if not credentials or credentials.grant_type != "password":
|
||||
return None
|
||||
for name, server in self.get_instances().items():
|
||||
# 确定要认证的服务器列表
|
||||
if service_name:
|
||||
# 如果指定了服务名,获取该服务实例
|
||||
servers = [(service_name, server)] if (server := self.get_instance(service_name)) else []
|
||||
else:
|
||||
# 如果没有指定服务名,遍历所有服务
|
||||
servers = self.get_instances().items()
|
||||
# 遍历要认证的服务器
|
||||
for name, server in servers:
|
||||
# 触发认证拦截事件
|
||||
intercept_event = eventmanager.send_event(
|
||||
etype=ChainEventType.AuthIntercept,
|
||||
data=AuthInterceptCredentials(username=credentials.username, channel=self.get_name(),
|
||||
service=name, status="triggered")
|
||||
data=schemas.AuthInterceptCredentials(username=credentials.username, channel=self.get_name(),
|
||||
service=name, status="triggered")
|
||||
)
|
||||
if intercept_event and intercept_event.event_data:
|
||||
intercept_data: AuthInterceptCredentials = intercept_event.event_data
|
||||
intercept_data: schemas.AuthInterceptCredentials = intercept_event.event_data
|
||||
if intercept_data.cancel:
|
||||
continue
|
||||
token = server.authenticate(credentials.username, credentials.password)
|
||||
|
||||
@@ -391,7 +391,7 @@ class Emby:
|
||||
year: str = None,
|
||||
tmdb_id: int = None,
|
||||
season: int = None
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[Dict[int, list]]]]]:
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
|
||||
"""
|
||||
根据标题和年份和季,返回Emby中的剧集列表
|
||||
:param item_id: Emby中的ID
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
from app.core.context import MediaInfo, settings
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas.types import MediaType, ModuleType, OtherModulesType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.schemas.types import MediaType, ModuleType
|
||||
|
||||
|
||||
class FanartModule(_ModuleBase):
|
||||
@@ -342,6 +343,13 @@ class FanartModule(_ModuleBase):
|
||||
"""
|
||||
return ModuleType.Other
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> OtherModulesType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return OtherModulesType.Fanart
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
@@ -376,20 +384,30 @@ class FanartModule(_ModuleBase):
|
||||
continue
|
||||
if not isinstance(images, list):
|
||||
continue
|
||||
# 按欢迎程度倒排
|
||||
images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
# 取第一张图片
|
||||
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'))
|
||||
if image_name.startswith("season"):
|
||||
# 季图片,图片格式seasonxx-xxxx/season-specials-xxxx
|
||||
for image_obj in images:
|
||||
image_season = image_obj.get('season')
|
||||
if image_season is not None:
|
||||
# 包括poster,thumb,banner
|
||||
if image_season == '0':
|
||||
season_image = f"season-specials-{image_name[6:]}"
|
||||
else:
|
||||
season_image = f"season{str(image_season).rjust(2, '0')}-{image_name[6:]}"
|
||||
# 设置图片,没有图片才设置
|
||||
if not mediainfo.get_image(season_image):
|
||||
mediainfo.set_image(season_image, image_obj.get('url'))
|
||||
else:
|
||||
# 其他图片,按欢迎程度倒排
|
||||
images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
# 取第一张图片
|
||||
image_obj = images[0]
|
||||
# 设置图片,没有图片才设置
|
||||
if not mediainfo.get_image(image_name):
|
||||
mediainfo.set_image(image_name, image_obj.get('url'))
|
||||
|
||||
return mediainfo
|
||||
|
||||
@@ -404,7 +422,7 @@ class FanartModule(_ModuleBase):
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=settings.CACHE_CONF.get('fanart'))
|
||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["fanart"], ttl=settings.CACHE_CONF["meta"]))
|
||||
def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]:
|
||||
if media_type == MediaType.MOVIE:
|
||||
image_url = cls._movie_url % queryid
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import copy
|
||||
import re
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
@@ -8,6 +7,7 @@ from jinja2 import Template
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import eventmanager
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.helper.directory import DirectoryHelper
|
||||
@@ -16,8 +16,8 @@ from app.helper.module import ModuleHelper
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.filemanager.storages import StorageBase
|
||||
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage
|
||||
from app.schemas.types import MediaType, ModuleType
|
||||
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage, TransferRenameEventData
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType, OtherModulesType
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
lock = Lock()
|
||||
@@ -51,6 +51,13 @@ class FileManagerModule(_ModuleBase):
|
||||
"""
|
||||
return ModuleType.Other
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> OtherModulesType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return OtherModulesType.FileManager
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
@@ -102,7 +109,7 @@ class FileManagerModule(_ModuleBase):
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
def support_transtype(self, storage: str) -> Optional[Dict[str, str]]:
|
||||
def support_transtype(self, storage: str) -> Optional[dict]:
|
||||
"""
|
||||
支持的整理方式
|
||||
"""
|
||||
@@ -131,8 +138,6 @@ class FileManagerModule(_ModuleBase):
|
||||
)
|
||||
return str(path)
|
||||
|
||||
pass
|
||||
|
||||
def save_config(self, storage: str, conf: Dict) -> None:
|
||||
"""
|
||||
保存存储配置
|
||||
@@ -143,7 +148,7 @@ class FileManagerModule(_ModuleBase):
|
||||
return
|
||||
storage_oper.set_config(conf)
|
||||
|
||||
def generate_qrcode(self, storage: str) -> Optional[Dict[str, str]]:
|
||||
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
|
||||
"""
|
||||
生成二维码
|
||||
"""
|
||||
@@ -219,7 +224,8 @@ class FileManagerModule(_ModuleBase):
|
||||
and f".{t.extension.lower()}" in extensions):
|
||||
return True
|
||||
elif t.type == "dir":
|
||||
return __any_file(t)
|
||||
if __any_file(t):
|
||||
return True
|
||||
return False
|
||||
|
||||
# 返回结果
|
||||
@@ -322,6 +328,7 @@ class FileManagerModule(_ModuleBase):
|
||||
target_directory: TransferDirectoryConf = None,
|
||||
target_storage: str = None, target_path: Path = None,
|
||||
transfer_type: str = None, scrape: bool = None,
|
||||
library_type_folder: bool = None, library_category_folder: bool = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
|
||||
"""
|
||||
文件整理
|
||||
@@ -333,6 +340,8 @@ class FileManagerModule(_ModuleBase):
|
||||
:param target_path: 目标路径
|
||||
:param transfer_type: 转移模式
|
||||
:param scrape: 是否刮削元数据
|
||||
:param library_type_folder: 是否按媒体类型创建目录
|
||||
:param library_category_folder: 是否按媒体类别创建目录
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
@@ -349,37 +358,33 @@ class FileManagerModule(_ModuleBase):
|
||||
message=f"{target_path} 不是有效目录")
|
||||
# 获取目标路径
|
||||
if target_directory:
|
||||
# 拼装媒体库一、二级子目录
|
||||
target_path = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_directory)
|
||||
# 目标存储类型
|
||||
if not target_storage:
|
||||
target_storage = target_directory.library_storage
|
||||
# 整理方式
|
||||
if not transfer_type:
|
||||
transfer_type = target_directory.transfer_type
|
||||
if not transfer_type:
|
||||
logger.error(f"{target_directory.name} 未设置整理方式")
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message=f"{target_directory.name} 未设置整理方式")
|
||||
# 是否需要刮削
|
||||
if scrape is None:
|
||||
need_scrape = target_directory.scraping
|
||||
else:
|
||||
need_scrape = scrape
|
||||
# 是否需要重命名
|
||||
need_rename = target_directory.renaming
|
||||
# 是否需要通知
|
||||
need_notify = target_directory.notify
|
||||
# 覆盖模式
|
||||
overwrite_mode = target_directory.overwrite_mode
|
||||
# 是否需要刮削
|
||||
need_scrape = scrape or target_directory.scraping
|
||||
# 目标存储类型
|
||||
if not target_storage:
|
||||
target_storage = target_directory.library_storage
|
||||
# 拼装媒体库一、二级子目录
|
||||
target_path = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_directory,
|
||||
need_type_folder=library_type_folder,
|
||||
need_category_folder=library_category_folder)
|
||||
elif target_path:
|
||||
# 手动整理的场景,有自定义目标路径
|
||||
need_scrape = scrape or False
|
||||
need_rename = True
|
||||
need_notify = False
|
||||
overwrite_mode = "never"
|
||||
logger.warn(f"{target_path} 为自定义路径, 通知将不会发送")
|
||||
# 手动整理的场景,有自定义目标路径
|
||||
target_path = self.__get_dest_path(mediainfo=mediainfo, target_path=target_path,
|
||||
need_type_folder=library_type_folder,
|
||||
need_category_folder=library_category_folder)
|
||||
else:
|
||||
# 未找到有效的媒体库目录
|
||||
logger.error(
|
||||
@@ -387,9 +392,14 @@ class FileManagerModule(_ModuleBase):
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message="未找到有效的媒体库目录")
|
||||
|
||||
logger.info(f"获取整理目标路径:【{target_storage}】{target_path}")
|
||||
# 整理方式
|
||||
if not transfer_type:
|
||||
logger.error(f"{target_directory.name} 未设置整理方式")
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message=f"{target_directory.name} 未设置整理方式")
|
||||
# 整理
|
||||
logger.info(f"获取整理目标路径:【{target_storage}】{target_path}")
|
||||
return self.transfer_media(fileitem=fileitem,
|
||||
in_meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
@@ -463,9 +473,9 @@ class FileManagerModule(_ModuleBase):
|
||||
target_file.parent.mkdir(parents=True)
|
||||
# 本地到本地
|
||||
if transfer_type == "copy":
|
||||
state = source_oper.copy(fileitem, target_file)
|
||||
state = source_oper.copy(fileitem, target_file.parent, target_file.name)
|
||||
elif transfer_type == "move":
|
||||
state = source_oper.move(fileitem, target_file)
|
||||
state = source_oper.move(fileitem, target_file.parent, target_file.name)
|
||||
elif transfer_type == "link":
|
||||
state = source_oper.link(fileitem, target_file)
|
||||
elif transfer_type == "softlink":
|
||||
@@ -493,7 +503,7 @@ class FileManagerModule(_ModuleBase):
|
||||
else:
|
||||
return None, f"{fileitem.path} 上传 {target_storage} 失败"
|
||||
else:
|
||||
return None, f"{target_file.parent} {target_storage} 目录获取失败"
|
||||
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||
elif transfer_type == "move":
|
||||
# 移动
|
||||
# 根据目的路径获取文件夹
|
||||
@@ -508,7 +518,7 @@ class FileManagerModule(_ModuleBase):
|
||||
else:
|
||||
return None, f"{fileitem.path} 上传 {target_storage} 失败"
|
||||
else:
|
||||
return None, f"{target_file.parent} {target_storage} 目录获取失败"
|
||||
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||
elif fileitem.storage != "local" and target_storage == "local":
|
||||
# 网盘到本地
|
||||
if target_file.exists():
|
||||
@@ -532,25 +542,28 @@ class FileManagerModule(_ModuleBase):
|
||||
return None, f"{fileitem.path} {fileitem.storage} 下载失败"
|
||||
elif fileitem.storage == target_storage:
|
||||
# 同一网盘
|
||||
# 根据目的路径获取文件夹
|
||||
target_diritem = target_oper.get_folder(target_file.parent)
|
||||
if target_diritem:
|
||||
# 重命名文件
|
||||
if target_oper.rename(fileitem, target_file.name):
|
||||
# 移动文件到新目录
|
||||
if source_oper.move(fileitem, target_diritem):
|
||||
ret_fileitem = copy.deepcopy(fileitem)
|
||||
ret_fileitem.path = target_diritem.path + "/" + target_file.name
|
||||
ret_fileitem.name = target_file.name
|
||||
ret_fileitem.basename = target_file.stem
|
||||
ret_fileitem.parent_fileid = target_diritem.fileid
|
||||
return ret_fileitem, ""
|
||||
if transfer_type == "copy":
|
||||
# 复制文件到新目录
|
||||
target_fileitem = target_oper.get_folder(target_file.parent)
|
||||
if target_fileitem:
|
||||
if source_oper.move(fileitem, Path(target_fileitem.path), target_file.name):
|
||||
return target_oper.get_item(target_file), ""
|
||||
else:
|
||||
return None, f"{fileitem.path} {target_storage} 移动文件失败"
|
||||
return None, f"【{target_storage}】{fileitem.path} 复制文件失败"
|
||||
else:
|
||||
return None, f"{fileitem.path} {target_storage} 重命名文件失败"
|
||||
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||
elif transfer_type == "move":
|
||||
# 移动文件到新目录
|
||||
target_fileitem = target_oper.get_folder(target_file.parent)
|
||||
if target_fileitem:
|
||||
if source_oper.move(fileitem, Path(target_fileitem.path), target_file.name):
|
||||
return target_oper.get_item(target_file), ""
|
||||
else:
|
||||
return None, f"【{target_storage}】{fileitem.path} 移动文件失败"
|
||||
else:
|
||||
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||
else:
|
||||
return None, f"{target_file.parent} {target_storage} 目录获取失败"
|
||||
return None, f"不支持的整理方式:{transfer_type}"
|
||||
|
||||
return None, "未知错误"
|
||||
|
||||
@@ -592,12 +605,12 @@ class FileManagerModule(_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"|简体|简中|JPSC" \
|
||||
r"|简体|简中|JPSC|sc_jp" \
|
||||
r"|(?<![a-z0-9])gb(?![a-z0-9])"
|
||||
_zhtw_sub_re = r"([.\[(](((zh[-_])?(hk|tw|cht|tc))" \
|
||||
r"|(cht|eng)[-_&]?(cht|eng)" \
|
||||
r"|繁[体中]?)[.\])])" \
|
||||
r"|繁体中[文字]|中[文字]繁体|繁体|JPTC" \
|
||||
r"|繁体中[文字]|中[文字]繁体|繁体|JPTC|tc_jp" \
|
||||
r"|(?<![a-z0-9])big5(?![a-z0-9])"
|
||||
_eng_sub_re = r"[.\[(]eng[.\])]"
|
||||
|
||||
@@ -685,7 +698,7 @@ class FileManagerModule(_ModuleBase):
|
||||
return False, errmsg
|
||||
except Exception as error:
|
||||
logger.info(f"字幕 {new_file} 出错了,原因: {str(error)}")
|
||||
return False, ""
|
||||
return True, ""
|
||||
|
||||
def __transfer_audio_track_files(self, fileitem: FileItem, target_storage: str, target_file: Path,
|
||||
transfer_type: str) -> Tuple[bool, str]:
|
||||
@@ -709,7 +722,7 @@ class FileManagerModule(_ModuleBase):
|
||||
file_list: List[FileItem] = storage_oper.list(parent_item)
|
||||
# 匹配音轨文件
|
||||
pending_file_list: List[FileItem] = [file for file in file_list
|
||||
if Path(file.name).stem == org_path.name
|
||||
if Path(file.name).stem == org_path.stem
|
||||
and file.type == "file" and file.extension
|
||||
and f".{file.extension.lower()}" in settings.RMT_AUDIOEXT]
|
||||
if len(pending_file_list) == 0:
|
||||
@@ -815,7 +828,8 @@ class FileManagerModule(_ModuleBase):
|
||||
else:
|
||||
logger.info(f"正在删除已存在的文件:{target_file}")
|
||||
target_file.unlink()
|
||||
logger.info(f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file}")
|
||||
logger.info(f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file},"
|
||||
f"操作类型:{transfer_type}")
|
||||
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
target_file=target_file,
|
||||
@@ -831,26 +845,43 @@ class FileManagerModule(_ModuleBase):
|
||||
return None, errmsg
|
||||
|
||||
@staticmethod
|
||||
def __get_dest_dir(mediainfo: MediaInfo, target_dir: TransferDirectoryConf) -> Path:
|
||||
def __get_dest_path(mediainfo: MediaInfo, target_path: Path,
|
||||
need_type_folder: bool = False, need_category_folder: bool = False):
|
||||
"""
|
||||
获取目标路径
|
||||
"""
|
||||
if need_type_folder:
|
||||
target_path = target_path / mediainfo.type.value
|
||||
if need_category_folder and mediainfo.category:
|
||||
target_path = target_path / mediainfo.category
|
||||
return target_path
|
||||
|
||||
@staticmethod
|
||||
def __get_dest_dir(mediainfo: MediaInfo, target_dir: TransferDirectoryConf,
|
||||
need_type_folder: bool = None, need_category_folder: bool = None) -> Path:
|
||||
"""
|
||||
根据设置并装媒体库目录
|
||||
:param mediainfo: 媒体信息
|
||||
:target_dir: 媒体库根目录
|
||||
:typename_dir: 是否加上类型目录
|
||||
:need_type_folder: 是否需要按媒体类型创建目录
|
||||
:need_category_folder: 是否需要按媒体类别创建目录
|
||||
"""
|
||||
if not target_dir.media_type and target_dir.library_type_folder:
|
||||
if need_type_folder is None:
|
||||
need_type_folder = target_dir.library_type_folder
|
||||
if need_category_folder is None:
|
||||
need_category_folder = target_dir.library_category_folder
|
||||
if not target_dir.media_type and need_type_folder:
|
||||
# 一级自动分类
|
||||
library_dir = Path(target_dir.library_path) / mediainfo.type.value
|
||||
elif target_dir.media_type and target_dir.library_type_folder:
|
||||
elif target_dir.media_type and need_type_folder:
|
||||
# 一级手动分类
|
||||
library_dir = Path(target_dir.library_path) / target_dir.media_type
|
||||
else:
|
||||
library_dir = Path(target_dir.library_path)
|
||||
|
||||
if not target_dir.media_category and target_dir.library_category_folder and mediainfo.category:
|
||||
if not target_dir.media_category and need_category_folder and mediainfo.category:
|
||||
# 二级自动分类
|
||||
library_dir = library_dir / mediainfo.category
|
||||
elif target_dir.media_category and target_dir.library_category_folder:
|
||||
elif target_dir.media_category and need_category_folder:
|
||||
# 二级手动分类
|
||||
library_dir = library_dir / target_dir.media_category
|
||||
|
||||
@@ -889,6 +920,18 @@ class FileManagerModule(_ModuleBase):
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
|
||||
# 计算重命名中的文件夹层数
|
||||
rename_format_level = len(rename_format.split("/")) - 1
|
||||
|
||||
if rename_format_level < 1:
|
||||
# 重命名格式不合法
|
||||
logger.error(f"重命名格式不合法:{rename_format}")
|
||||
return TransferInfo(success=False,
|
||||
message=f"重命名格式不合法",
|
||||
fileitem=fileitem,
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
|
||||
# 判断是否为文件夹
|
||||
if fileitem.type == "dir":
|
||||
# 整理整个目录,一般为蓝光原盘
|
||||
@@ -969,9 +1012,15 @@ class FileManagerModule(_ModuleBase):
|
||||
# 目的操作对象
|
||||
target_oper: StorageBase = self.__get_storage_oper(target_storage)
|
||||
# 目标目录
|
||||
target_diritem = target_oper.get_folder(
|
||||
new_file.parent) if mediainfo.type == MediaType.MOVIE else target_oper.get_folder(
|
||||
new_file.parent.parent)
|
||||
target_diritem = target_oper.get_folder(new_file.parents[rename_format_level - 1])
|
||||
if not target_diritem:
|
||||
logger.error(f"目标目录 {new_file.parents[rename_format_level - 1]} 获取失败")
|
||||
return TransferInfo(success=False,
|
||||
message=f"目标目录 {new_file.parents[rename_format_level - 1]} 获取失败",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
# 目标文件
|
||||
target_item = target_oper.get_item(new_file)
|
||||
if target_item:
|
||||
@@ -1080,7 +1129,14 @@ class FileManagerModule(_ModuleBase):
|
||||
if episode.episode_number == meta.begin_episode:
|
||||
episode_title = episode.name
|
||||
break
|
||||
|
||||
# 获取集播出日期
|
||||
episode_date = None
|
||||
if meta.begin_episode and episodes_info:
|
||||
for episode in episodes_info:
|
||||
if episode.episode_number == meta.begin_episode:
|
||||
episode_date = episode.air_date
|
||||
break
|
||||
|
||||
return {
|
||||
# 标题
|
||||
"title": __convert_invalid_characters(mediainfo.title),
|
||||
@@ -1125,26 +1181,56 @@ class FileManagerModule(_ModuleBase):
|
||||
# 集号
|
||||
"episode": meta.episode_seqs,
|
||||
# 季集 SxxExx
|
||||
"season_episode": "%s%s" % (meta.season, meta.episodes),
|
||||
"season_episode": "%s%s" % (meta.season, meta.episode),
|
||||
# 段/节
|
||||
"part": meta.part,
|
||||
# 剧集标题
|
||||
"episode_title": __convert_invalid_characters(episode_title),
|
||||
# 剧集日期根据episodes_info值获取
|
||||
"episode_date": episode_date,
|
||||
# 文件后缀
|
||||
"fileExt": file_ext,
|
||||
# 自定义占位符
|
||||
"customization": meta.customization
|
||||
"customization": meta.customization,
|
||||
# 文件元数据
|
||||
"__meta__": meta,
|
||||
# 识别的媒体信息
|
||||
"__mediainfo__": mediainfo,
|
||||
# 当前季的全部集信息
|
||||
"__episodes_info__": episodes_info,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path:
|
||||
"""
|
||||
生成重命名后的完整路径
|
||||
生成重命名后的完整路径,支持智能重命名事件
|
||||
:param template_string: Jinja2 模板字符串
|
||||
:param rename_dict: 渲染上下文,用于替换模板中的变量
|
||||
:param path: 可选的基础路径,如果提供,将在其基础上拼接生成的路径
|
||||
:return: 生成的完整路径
|
||||
"""
|
||||
# 创建jinja2模板对象
|
||||
template = Template(template_string)
|
||||
# 渲染生成的字符串
|
||||
render_str = template.render(rename_dict)
|
||||
|
||||
logger.debug(f"Initial render string: {render_str}")
|
||||
# 发送智能重命名事件
|
||||
event_data = TransferRenameEventData(
|
||||
template_string=template_string,
|
||||
rename_dict=rename_dict,
|
||||
render_str=render_str,
|
||||
path=path
|
||||
)
|
||||
event = eventmanager.send_event(ChainEventType.TransferRename, event_data)
|
||||
# 检查事件返回的结果
|
||||
if event and event.event_data:
|
||||
event_data: TransferRenameEventData = event.event_data
|
||||
if event_data.updated and event_data.updated_str:
|
||||
logger.debug(f"Render string updated by event: "
|
||||
f"{render_str} -> {event_data.updated_str} (source: {event_data.source})")
|
||||
render_str = event_data.updated_str
|
||||
|
||||
# 目的路径
|
||||
if path:
|
||||
return path / render_str
|
||||
@@ -1170,17 +1256,19 @@ class FileManagerModule(_ModuleBase):
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
# 获取相对路径(重命名路径)
|
||||
rel_path = self.get_rename_path(
|
||||
# 计算重命名中的文件夹层数
|
||||
rename_format_level = len(rename_format.split("/")) - 1
|
||||
if rename_format_level < 1:
|
||||
continue
|
||||
# 获取路径(重命名路径)
|
||||
target_path = self.get_rename_path(
|
||||
path=dir_path,
|
||||
template_string=rename_format,
|
||||
rename_dict=self.__get_naming_dict(meta=MetaInfo(mediainfo.title),
|
||||
mediainfo=mediainfo)
|
||||
)
|
||||
# 取相对路径的第1层目录
|
||||
if rel_path.parts:
|
||||
media_path = dir_path / rel_path.parts[0]
|
||||
else:
|
||||
continue
|
||||
media_path = target_path.parents[rename_format_level - 1]
|
||||
# 检索媒体文件
|
||||
fileitem = storage_oper.get_item(media_path)
|
||||
if not fileitem:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Union, Dict
|
||||
from typing import Optional, List, Dict, Tuple
|
||||
|
||||
from app import schemas
|
||||
from app.helper.storage import StorageHelper
|
||||
@@ -16,7 +16,14 @@ class StorageBase(metaclass=ABCMeta):
|
||||
def __init__(self):
|
||||
self.storagehelper = StorageHelper()
|
||||
|
||||
def generate_qrcode(self, *args, **kwargs) -> Optional[Dict[str, str]]:
|
||||
@abstractmethod
|
||||
def init_storage(self):
|
||||
"""
|
||||
初始化
|
||||
"""
|
||||
pass
|
||||
|
||||
def generate_qrcode(self, *args, **kwargs) -> Optional[Tuple[dict, str]]:
|
||||
pass
|
||||
|
||||
def check_login(self, *args, **kwargs) -> Optional[Dict[str, str]]:
|
||||
@@ -40,6 +47,7 @@ class StorageBase(metaclass=ABCMeta):
|
||||
设置配置
|
||||
"""
|
||||
self.storagehelper.set_storage(self.schema.value, conf)
|
||||
self.init_storage()
|
||||
|
||||
def support_transtype(self) -> dict:
|
||||
"""
|
||||
@@ -71,6 +79,8 @@ class StorageBase(metaclass=ABCMeta):
|
||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 父目录
|
||||
:param name: 目录名
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -114,7 +124,6 @@ class StorageBase(metaclass=ABCMeta):
|
||||
下载文件,保存到本地,返回本地临时文件地址
|
||||
:param fileitem: 文件项
|
||||
:param path: 文件保存路径
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -136,16 +145,22 @@ class StorageBase(metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def copy(self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]) -> bool:
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def move(self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]) -> bool:
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -16,10 +16,11 @@ from app.schemas.types import StorageSchema
|
||||
from app.utils.http import RequestUtils
|
||||
from aligo import Aligo, BaseFile
|
||||
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class AliPan(StorageBase):
|
||||
class AliPan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
阿里云相关操作
|
||||
"""
|
||||
@@ -29,6 +30,7 @@ class AliPan(StorageBase):
|
||||
|
||||
# 支持的整理方式
|
||||
transtype = {
|
||||
"copy": "复制",
|
||||
"move": "移动",
|
||||
}
|
||||
|
||||
@@ -54,17 +56,27 @@ class AliPan(StorageBase):
|
||||
except FileNotFoundError:
|
||||
logger.debug('未发现 aria2c')
|
||||
self._has_aria2c = False
|
||||
self.init_storage()
|
||||
|
||||
self.__init_aligo()
|
||||
|
||||
def __init_aligo(self):
|
||||
def init_storage(self):
|
||||
"""
|
||||
初始化 aligo
|
||||
"""
|
||||
|
||||
def show_qrcode(qr_link: str):
|
||||
"""
|
||||
显示二维码
|
||||
"""
|
||||
logger.info(f"请用阿里云盘 App 扫码登录:{qr_link}")
|
||||
|
||||
refresh_token = self.__auth_params.get("refreshToken")
|
||||
if refresh_token:
|
||||
self.aligo = Aligo(refresh_token=refresh_token, use_aria2=self._has_aria2c,
|
||||
name="MoviePilot V2", level=logging.ERROR)
|
||||
try:
|
||||
self.aligo = Aligo(refresh_token=refresh_token, show=show_qrcode, use_aria2=self._has_aria2c, # noqa
|
||||
name="MoviePilot V2", level=logging.ERROR, re_login=False)
|
||||
except Exception as err:
|
||||
logger.error(f"初始化阿里云盘失败:{str(err)}")
|
||||
self.__clear_params()
|
||||
|
||||
@property
|
||||
def __auth_params(self):
|
||||
@@ -160,7 +172,7 @@ class AliPan(StorageBase):
|
||||
})
|
||||
self.__update_params(data)
|
||||
self.__update_drives()
|
||||
self.__init_aligo()
|
||||
self.init_storage()
|
||||
except Exception as e:
|
||||
return {}, f"bizExt 解码失败:{str(e)}"
|
||||
return data, ""
|
||||
@@ -180,12 +192,16 @@ class AliPan(StorageBase):
|
||||
"""
|
||||
获取用户信息(drive_id等)
|
||||
"""
|
||||
if not self.aligo:
|
||||
return {}
|
||||
return self.aligo.get_user()
|
||||
|
||||
def __update_drives(self):
|
||||
"""
|
||||
更新用户存储根目录
|
||||
"""
|
||||
if not self.aligo:
|
||||
return
|
||||
drivers = self.aligo.list_my_drives()
|
||||
for driver in drivers:
|
||||
if driver.category == "resource":
|
||||
@@ -240,28 +256,9 @@ class AliPan(StorageBase):
|
||||
return []
|
||||
# 根目录处理
|
||||
if not fileitem or not fileitem.drive_id:
|
||||
return [
|
||||
schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
fileid="root",
|
||||
drive_id=self.__auth_params.get("resourceDriveId"),
|
||||
parent_fileid="root",
|
||||
type="dir",
|
||||
path="/资源库/",
|
||||
name="资源库",
|
||||
basename="资源库"
|
||||
),
|
||||
schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
fileid="root",
|
||||
drive_id=self.__auth_params.get("backDriveId"),
|
||||
parent_fileid="root",
|
||||
type="dir",
|
||||
path="/备份盘/",
|
||||
name="备份盘",
|
||||
basename="备份盘"
|
||||
)
|
||||
]
|
||||
items = self.aligo.get_file_list()
|
||||
if items:
|
||||
return [self.__get_fileitem(item) for item in items]
|
||||
elif fileitem.type == "file":
|
||||
# 文件处理
|
||||
file = self.detail(fileitem)
|
||||
@@ -276,6 +273,8 @@ class AliPan(StorageBase):
|
||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 父目录
|
||||
:param name: 目录名
|
||||
"""
|
||||
if not self.aligo:
|
||||
return None
|
||||
@@ -283,21 +282,43 @@ class AliPan(StorageBase):
|
||||
if item:
|
||||
if isinstance(item, CreateFileResponse):
|
||||
item = self.aligo.get_file(file_id=item.file_id, drive_id=item.drive_id)
|
||||
return self.__get_fileitem(item)
|
||||
return self.__get_fileitem(item, parent=fileitem.path)
|
||||
return None
|
||||
|
||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
根据文件路程获取目录,不存在则创建
|
||||
"""
|
||||
if not self.aligo:
|
||||
|
||||
def __find_dir(_fileitem: schemas.FileItem, _name: str) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
查找下级目录中匹配名称的目录
|
||||
"""
|
||||
for sub_folder in self.list(_fileitem):
|
||||
if sub_folder.type != "dir":
|
||||
continue
|
||||
if sub_folder.name == _name:
|
||||
return sub_folder
|
||||
return None
|
||||
item = self.aligo.get_folder_by_path(path=str(path), create_folder=True)
|
||||
if item:
|
||||
if isinstance(item, CreateFileResponse):
|
||||
item = self.aligo.get_file(file_id=item.file_id, drive_id=item.drive_id)
|
||||
return self.__get_fileitem(item)
|
||||
return None
|
||||
|
||||
# 是否已存在
|
||||
folder = self.get_item(path)
|
||||
if folder:
|
||||
return folder
|
||||
# 逐级查找和创建目录
|
||||
fileitem = schemas.FileItem(path="/")
|
||||
for part in path.parts:
|
||||
if part == "/":
|
||||
continue
|
||||
dir_file = __find_dir(fileitem, part)
|
||||
if dir_file:
|
||||
fileitem = dir_file
|
||||
else:
|
||||
dir_file = self.create_folder(fileitem, part)
|
||||
if not dir_file:
|
||||
return None
|
||||
fileitem = dir_file
|
||||
return fileitem
|
||||
|
||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
@@ -307,7 +328,7 @@ class AliPan(StorageBase):
|
||||
return None
|
||||
item = self.aligo.get_file_by_path(path=str(path))
|
||||
if item:
|
||||
return self.__get_fileitem(item)
|
||||
return self.__get_fileitem(item, parent=str(path.parent))
|
||||
return None
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
@@ -328,7 +349,7 @@ class AliPan(StorageBase):
|
||||
return None
|
||||
item = self.aligo.get_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id)
|
||||
if item:
|
||||
return self.__get_fileitem(item)
|
||||
return self.__get_fileitem(item, parent=fileitem.path)
|
||||
return None
|
||||
|
||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||
@@ -356,6 +377,9 @@ class AliPan(StorageBase):
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: str = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件,并标记完成
|
||||
:param fileitem: 上传目录项
|
||||
:param path: 本地文件路径
|
||||
:param new_name: 上传后文件名
|
||||
"""
|
||||
if not self.aligo:
|
||||
return None
|
||||
@@ -366,22 +390,44 @@ class AliPan(StorageBase):
|
||||
if result:
|
||||
item = self.aligo.get_file(file_id=result.file_id, drive_id=result.drive_id)
|
||||
if item:
|
||||
return self.__get_fileitem(item)
|
||||
return self.__get_fileitem(item, parent=fileitem.path)
|
||||
return None
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, target: schemas.FileItem) -> bool:
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
if not self.aligo:
|
||||
return False
|
||||
target = self.get_folder(path)
|
||||
if not target:
|
||||
return False
|
||||
if self.aligo.move_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id,
|
||||
to_parent_file_id=target.fileid, to_drive_id=target.drive_id):
|
||||
to_parent_file_id=target.fileid, to_drive_id=target.drive_id,
|
||||
new_name=new_name):
|
||||
return True
|
||||
return False
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, target: schemas.FileItem) -> bool:
|
||||
pass
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
if not self.aligo:
|
||||
return False
|
||||
target = self.get_folder(path)
|
||||
if not target:
|
||||
return False
|
||||
if self.aligo.copy_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id,
|
||||
to_parent_file_id=target.fileid, to_drive_id=target.drive_id,
|
||||
new_name=new_name):
|
||||
return True
|
||||
return False
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
|
||||
@@ -2,10 +2,10 @@ import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, List, Dict, Union
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from requests import Response
|
||||
from cachetools import cached, TTLCache
|
||||
from requests import Response
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
@@ -13,10 +13,11 @@ from app.log import logger
|
||||
from app.modules.filemanager.storages import StorageBase
|
||||
from app.schemas.types import StorageSchema
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
|
||||
class Alist(StorageBase):
|
||||
class Alist(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
Alist相关操作
|
||||
api文档:https://alist.nn.ci/zh/guide/api
|
||||
@@ -34,6 +35,12 @@ class Alist(StorageBase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def init_storage(self):
|
||||
"""
|
||||
初始化
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def __get_base_url(self) -> str:
|
||||
"""
|
||||
@@ -226,6 +233,8 @@ class Alist(StorageBase):
|
||||
) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 父目录
|
||||
:param name: 目录名
|
||||
"""
|
||||
path = Path(fileitem.path) / name
|
||||
resp: Response = RequestUtils(
|
||||
@@ -264,14 +273,16 @@ class Alist(StorageBase):
|
||||
获取目录,如目录不存在则创建
|
||||
"""
|
||||
folder = self.get_item(path)
|
||||
if folder:
|
||||
return folder
|
||||
if not folder:
|
||||
folder = self.create_folder(self.get_parent(schemas.FileItem(
|
||||
folder = self.create_folder(schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path=path.as_posix() + "/",
|
||||
path=path.parent.as_posix(),
|
||||
name=path.name,
|
||||
basename=path.stem
|
||||
)), path.name)
|
||||
), path.name)
|
||||
return folder
|
||||
|
||||
def get_item(
|
||||
@@ -342,7 +353,7 @@ class Alist(StorageBase):
|
||||
|
||||
result = resp.json()
|
||||
if result["code"] != 200:
|
||||
logging.warning(f'获取文件 {path} 失败,错误信息:{result["message"]}')
|
||||
logging.debug(f'获取文件 {path} 失败,错误信息:{result["message"]}')
|
||||
return
|
||||
|
||||
return schemas.FileItem(
|
||||
@@ -370,7 +381,7 @@ class Alist(StorageBase):
|
||||
resp: Response = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/delete"),
|
||||
self.__get_api_url("/api/fs/remove"),
|
||||
json={
|
||||
"dir": Path(fileitem.path).parent.as_posix(),
|
||||
"names": [fileitem.name],
|
||||
@@ -542,15 +553,15 @@ class Alist(StorageBase):
|
||||
:param new_name: 上传后文件名
|
||||
:param task: 是否为任务,默认为False避免未完成上传时对文件进行操作
|
||||
"""
|
||||
encoded_path = UrlUtils.quote(fileitem.path)
|
||||
encoded_path = UrlUtils.quote((Path(fileitem.path) / path.name).as_posix())
|
||||
headers = self.__get_header_with_token()
|
||||
headers.setdefault("Content-Type", "multipart/form-data")
|
||||
headers.setdefault("Content-Type", "application/octet-stream")
|
||||
headers.setdefault("As-Task", str(task).lower())
|
||||
headers.setdefault("File-Path", encoded_path)
|
||||
with open(path, "rb") as f:
|
||||
resp: Response = RequestUtils(headers=headers).put_res(
|
||||
self.__get_api_url("/api/fs/form"),
|
||||
data={"file": f},
|
||||
self.__get_api_url("/api/fs/put"),
|
||||
data=f,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
@@ -558,7 +569,7 @@ class Alist(StorageBase):
|
||||
return
|
||||
|
||||
new_item = self.get_item(Path(fileitem.path) / path.name)
|
||||
if new_name and new_name != path.name:
|
||||
if new_item and new_name and new_name != path.name:
|
||||
if self.rename(new_item, new_name):
|
||||
return self.get_item(Path(new_item.path).with_name(new_name))
|
||||
|
||||
@@ -570,51 +581,21 @@ class Alist(StorageBase):
|
||||
"""
|
||||
return self.get_item(Path(fileitem.path))
|
||||
|
||||
@staticmethod
|
||||
def __get_copy_and_move_data(
|
||||
fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]
|
||||
) -> Tuple[str, str, List[str], bool]:
|
||||
"""
|
||||
获取复制或移动文件需要的数据
|
||||
|
||||
:param fileitem: 文件项
|
||||
:param target: 目标文件项或目标路径
|
||||
:return: 源目录,目标目录,文件名列表,是否有效
|
||||
"""
|
||||
name = Path(target).name
|
||||
if fileitem.name != name:
|
||||
return "", "", [], False
|
||||
|
||||
src_dir = Path(fileitem.path).parent.as_posix()
|
||||
if isinstance(target, schemas.FileItem):
|
||||
traget_dir = Path(target.path).parent.as_posix()
|
||||
else:
|
||||
traget_dir = target.parent.as_posix()
|
||||
|
||||
return src_dir, traget_dir, [name], True
|
||||
|
||||
def copy(
|
||||
self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]
|
||||
) -> bool:
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
|
||||
源文件名和目标文件名必须相同
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
src_dir, dst_dir, names, is_valid = self.__get_copy_and_move_data(
|
||||
fileitem, target
|
||||
)
|
||||
if not is_valid:
|
||||
return False
|
||||
|
||||
resp: Response = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/copy"),
|
||||
json={
|
||||
"src_dir": src_dir,
|
||||
"dst_dir": dst_dir,
|
||||
"names": names,
|
||||
"src_dir": Path(fileitem.path).parent.as_posix(),
|
||||
"dst_dir": path.as_posix(),
|
||||
"names": [fileitem.name],
|
||||
},
|
||||
)
|
||||
"""
|
||||
@@ -649,28 +630,31 @@ class Alist(StorageBase):
|
||||
f'复制文件 {fileitem.path} 失败,错误信息:{result["message"]}'
|
||||
)
|
||||
return False
|
||||
# 重命名
|
||||
if fileitem.name != new_name:
|
||||
self.rename(
|
||||
self.get_item(path / fileitem.name), new_name
|
||||
)
|
||||
return True
|
||||
|
||||
def move(
|
||||
self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]
|
||||
) -> bool:
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
src_dir, dst_dir, names, is_valid = self.__get_copy_and_move_data(
|
||||
fileitem, target
|
||||
)
|
||||
if not is_valid:
|
||||
return False
|
||||
|
||||
# 先重命名
|
||||
if fileitem.name != new_name:
|
||||
self.rename(fileitem, new_name)
|
||||
resp: Response = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/move"),
|
||||
json={
|
||||
"src_dir": src_dir,
|
||||
"dst_dir": dst_dir,
|
||||
"names": names,
|
||||
"src_dir": Path(fileitem.path).parent.as_posix(),
|
||||
"dst_dir": path.as_posix(),
|
||||
"names": [new_name],
|
||||
},
|
||||
)
|
||||
"""
|
||||
@@ -751,15 +735,7 @@ class Alist(StorageBase):
|
||||
|
||||
@staticmethod
|
||||
def __parse_timestamp(time_str: str) -> float:
|
||||
# try:
|
||||
# # 尝试解析带微秒的时间格式
|
||||
# dt = datetime.strptime(time_str[:26], '%Y-%m-%dT%H:%M:%S.%f')
|
||||
# except ValueError:
|
||||
# # 如果失败,尝试解析不带微秒的时间格式
|
||||
# dt = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
# 直接使用 ISO 8601 格式解析时间
|
||||
dt = datetime.fromisoformat(time_str)
|
||||
|
||||
# 返回时间戳
|
||||
return dt.timestamp()
|
||||
"""
|
||||
直接使用 ISO 8601 格式解析时间
|
||||
"""
|
||||
return datetime.fromisoformat(time_str).timestamp()
|
||||
|
||||
@@ -25,13 +25,19 @@ class LocalStorage(StorageBase):
|
||||
"softlink": "软链接"
|
||||
}
|
||||
|
||||
def init_storage(self):
|
||||
"""
|
||||
初始化
|
||||
"""
|
||||
pass
|
||||
|
||||
def check(self) -> bool:
|
||||
"""
|
||||
检查存储是否可用
|
||||
"""
|
||||
return True
|
||||
|
||||
def __get_fileitem(self, path: Path):
|
||||
def __get_fileitem(self, path: Path) -> schemas.FileItem:
|
||||
"""
|
||||
获取文件项
|
||||
"""
|
||||
@@ -46,7 +52,7 @@ class LocalStorage(StorageBase):
|
||||
modify_time=path.stat().st_mtime,
|
||||
)
|
||||
|
||||
def __get_diritem(self, path: Path):
|
||||
def __get_diritem(self, path: Path) -> schemas.FileItem:
|
||||
"""
|
||||
获取目录项
|
||||
"""
|
||||
@@ -109,6 +115,8 @@ class LocalStorage(StorageBase):
|
||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 父目录
|
||||
:param name: 目录名
|
||||
"""
|
||||
if not fileitem.path:
|
||||
return None
|
||||
@@ -186,6 +194,9 @@ class LocalStorage(StorageBase):
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: str = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
:param fileitem: 上传目录项
|
||||
:param path: 本地文件路径
|
||||
:param new_name: 上传后文件名
|
||||
"""
|
||||
dir_path = Path(fileitem.path)
|
||||
target_path = dir_path / (new_name or path.name)
|
||||
@@ -195,17 +206,6 @@ class LocalStorage(StorageBase):
|
||||
return None
|
||||
return self.get_item(target_path)
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
"""
|
||||
file_path = Path(fileitem.path)
|
||||
code, message = SystemUtils.copy(file_path, target_file)
|
||||
if code != 0:
|
||||
logger.error(f"复制文件失败:{message}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
硬链接文件
|
||||
@@ -228,12 +228,29 @@ class LocalStorage(StorageBase):
|
||||
return False
|
||||
return True
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, target: Path) -> bool:
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
复制文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
file_path = Path(fileitem.path)
|
||||
code, message = SystemUtils.move(file_path, target)
|
||||
code, message = SystemUtils.copy(file_path, path / new_name)
|
||||
if code != 0:
|
||||
logger.error(f"复制文件失败:{message}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
file_path = Path(fileitem.path)
|
||||
code, message = SystemUtils.move(file_path, path / new_name)
|
||||
if code != 0:
|
||||
logger.error(f"移动文件失败:{message}")
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -27,6 +26,12 @@ class Rclone(StorageBase):
|
||||
"copy": "复制"
|
||||
}
|
||||
|
||||
def init_storage(self):
|
||||
"""
|
||||
初始化
|
||||
"""
|
||||
pass
|
||||
|
||||
def set_config(self, conf: dict):
|
||||
"""
|
||||
设置配置
|
||||
@@ -51,21 +56,6 @@ class Rclone(StorageBase):
|
||||
else:
|
||||
return None
|
||||
|
||||
def __get_fileitem(self, path: Path):
|
||||
"""
|
||||
获取文件项
|
||||
"""
|
||||
return schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
type="file",
|
||||
path=str(path).replace("\\", "/"),
|
||||
name=path.name,
|
||||
basename=path.stem,
|
||||
extension=path.suffix[1:],
|
||||
size=path.stat().st_size,
|
||||
modify_time=path.stat().st_mtime,
|
||||
)
|
||||
|
||||
def __get_rcloneitem(self, item: dict, parent: str = "/") -> schemas.FileItem:
|
||||
"""
|
||||
获取rclone文件项
|
||||
@@ -133,20 +123,19 @@ class Rclone(StorageBase):
|
||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 父目录
|
||||
:param name: 目录名
|
||||
"""
|
||||
try:
|
||||
retcode = subprocess.run(
|
||||
[
|
||||
'rclone', 'mkdir',
|
||||
f'MP:{fileitem.path}/{name}'
|
||||
f'MP:{Path(fileitem.path) / name}'
|
||||
],
|
||||
startupinfo=self.__get_hidden_shell()
|
||||
).returncode
|
||||
if retcode == 0:
|
||||
ret_fileitem = copy.deepcopy(fileitem)
|
||||
ret_fileitem.path = f"{fileitem.path}/{name}/"
|
||||
ret_fileitem.name = name
|
||||
return ret_fileitem
|
||||
return self.get_item(Path(fileitem.path) / name)
|
||||
except Exception as err:
|
||||
logger.error(f"rclone创建目录失败:{err}")
|
||||
return None
|
||||
@@ -160,13 +149,17 @@ class Rclone(StorageBase):
|
||||
"""
|
||||
查找下级目录中匹配名称的目录
|
||||
"""
|
||||
for sub_file in self.list(_fileitem):
|
||||
if sub_file.type != "dir":
|
||||
for sub_folder in self.list(_fileitem):
|
||||
if sub_folder.type != "dir":
|
||||
continue
|
||||
if sub_file.name == _name:
|
||||
return sub_file
|
||||
if sub_folder.name == _name:
|
||||
return sub_folder
|
||||
return None
|
||||
|
||||
# 是否已存在
|
||||
folder = self.get_item(path)
|
||||
if folder:
|
||||
return folder
|
||||
# 逐级查找和创建目录
|
||||
fileitem = schemas.FileItem(path="/")
|
||||
for part in path.parts:
|
||||
@@ -191,16 +184,19 @@ class Rclone(StorageBase):
|
||||
ret = subprocess.run(
|
||||
[
|
||||
'rclone', 'lsjson',
|
||||
f'MP:{path}'
|
||||
f'MP:{path.parent}'
|
||||
],
|
||||
capture_output=True,
|
||||
startupinfo=self.__get_hidden_shell()
|
||||
)
|
||||
if ret.returncode == 0:
|
||||
items = json.loads(ret.stdout)
|
||||
return self.__get_rcloneitem(items[0])
|
||||
for item in items:
|
||||
if item.get("Name") == path.name:
|
||||
return self.__get_rcloneitem(item, parent=str(path.parent) + "/")
|
||||
return None
|
||||
except Exception as err:
|
||||
logger.error(f"rclone获取文件失败:{err}")
|
||||
logger.debug(f"rclone获取文件项失败:{err}")
|
||||
return None
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
@@ -230,7 +226,7 @@ class Rclone(StorageBase):
|
||||
[
|
||||
'rclone', 'moveto',
|
||||
f'MP:{fileitem.path}',
|
||||
f'MP:{Path(fileitem.path).parent}/{name}'
|
||||
f'MP:{Path(fileitem.path).parent / name}'
|
||||
],
|
||||
startupinfo=self.__get_hidden_shell()
|
||||
).returncode
|
||||
@@ -263,6 +259,9 @@ class Rclone(StorageBase):
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: str = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
:param fileitem: 上传目录项
|
||||
:param path: 本地文件路径
|
||||
:param new_name: 上传后文件名
|
||||
"""
|
||||
try:
|
||||
new_path = Path(fileitem.path) / (new_name or path.name)
|
||||
@@ -275,7 +274,7 @@ class Rclone(StorageBase):
|
||||
startupinfo=self.__get_hidden_shell()
|
||||
).returncode
|
||||
if retcode == 0:
|
||||
return self.__get_fileitem(new_path)
|
||||
return self.get_item(new_path)
|
||||
except Exception as err:
|
||||
logger.error(f"rclone上传文件失败:{err}")
|
||||
return None
|
||||
@@ -300,16 +299,19 @@ class Rclone(StorageBase):
|
||||
logger.error(f"rclone获取文件详情失败:{err}")
|
||||
return None
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, target: Path) -> bool:
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件,target_file格式:rclone:path
|
||||
移动文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
try:
|
||||
retcode = subprocess.run(
|
||||
[
|
||||
'rclone', 'moveto',
|
||||
f'MP:{fileitem.path}',
|
||||
f'MP:{target}'
|
||||
f'MP:{path / new_name}'
|
||||
],
|
||||
startupinfo=self.__get_hidden_shell()
|
||||
).returncode
|
||||
@@ -319,8 +321,27 @@ class Rclone(StorageBase):
|
||||
logger.error(f"rclone移动文件失败:{err}")
|
||||
return False
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
try:
|
||||
retcode = subprocess.run(
|
||||
[
|
||||
'rclone', 'copyto',
|
||||
f'MP:{fileitem.path}',
|
||||
f'MP:{path / new_name}'
|
||||
],
|
||||
startupinfo=self.__get_hidden_shell()
|
||||
).returncode
|
||||
if retcode == 0:
|
||||
return True
|
||||
except Exception as err:
|
||||
logger.error(f"rclone复制文件失败:{err}")
|
||||
return False
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
from p115 import P115Client, P115FileSystem, P115Path
|
||||
from p115 import P115Client, P115Path
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.modules.filemanager.storages import StorageBase
|
||||
from app.schemas.types import StorageSchema
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
@@ -25,24 +26,32 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"copy": "复制"
|
||||
}
|
||||
|
||||
# 115二维码登录地址
|
||||
qrcode_url = "https://qrcodeapi.115.com/api/1.0/web/1.0/token/"
|
||||
# 115登录状态检查
|
||||
login_check_url = "https://qrcodeapi.115.com/get/status/"
|
||||
# 115登录完成 alipaymini
|
||||
login_done_api = f"https://passportapi.115.com/app/1.0/alipaymini/1.0/login/qrcode/"
|
||||
|
||||
client: P115Client = None
|
||||
fs: P115FileSystem = None
|
||||
session_info: dict = None
|
||||
|
||||
def __init_cloud(self, force: bool = False) -> bool:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.init_storage()
|
||||
|
||||
def init_storage(self):
|
||||
"""
|
||||
初始化Cloud
|
||||
"""
|
||||
if not self.__credential:
|
||||
return
|
||||
try:
|
||||
if not self.client or not self.client.cookies or force:
|
||||
self.client = P115Client(self.__credential,
|
||||
check_for_relogin=True, app="alipaymini", console_qrcode=False)
|
||||
self.fs = P115FileSystem(self.client)
|
||||
self.client = P115Client(self.__credential, app="alipaymini",
|
||||
check_for_relogin=False, console_qrcode=False)
|
||||
except Exception as err:
|
||||
logger.error(f"115连接失败,请重新扫码登录:{str(err)}")
|
||||
logger.error(f"115连接失败,请重新登录:{str(err)}")
|
||||
self.__clear_credential()
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def __credential(self) -> Optional[str]:
|
||||
@@ -54,8 +63,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
return None
|
||||
if not conf.config:
|
||||
return None
|
||||
# 将dict转换为cookie字符串格式
|
||||
return "; ".join([f"{k}={v}" for k, v in conf.config.items()])
|
||||
return conf.config.get("cookie")
|
||||
|
||||
def __save_credential(self, credential: dict):
|
||||
"""
|
||||
@@ -73,21 +81,19 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
生成二维码
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
return None
|
||||
try:
|
||||
resp = self.client.login_qrcode_token()
|
||||
self.session_info = resp["data"]
|
||||
res = RequestUtils(timeout=10).get_res(self.qrcode_url)
|
||||
if res:
|
||||
self.session_info = res.json().get("data")
|
||||
qrcode_content = self.session_info.pop("qrcode")
|
||||
if not qrcode_content:
|
||||
logger.warn("115生成二维码失败:未获取到二维码数据!")
|
||||
return None
|
||||
return {}, ""
|
||||
return {
|
||||
"codeContent": qrcode_content
|
||||
}, ""
|
||||
except Exception as e:
|
||||
logger.warn(f"115生成二维码失败:{str(e)}")
|
||||
return {}, f"115生成二维码失败:{str(e)}"
|
||||
elif res is not None:
|
||||
return {}, f"115生成二维码失败:{res.status_code} - {res.reason}"
|
||||
return {}, f"115生成二维码失败:无法连接!"
|
||||
|
||||
def check_login(self) -> Optional[Tuple[dict, str]]:
|
||||
"""
|
||||
@@ -96,8 +102,11 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
if not self.session_info:
|
||||
return {}, "请先生成二维码!"
|
||||
try:
|
||||
resp = self.client.login_qrcode_scan_status(self.session_info)
|
||||
match resp["data"].get("status"):
|
||||
resp = RequestUtils(timeout=10).get_res(self.login_check_url, params=self.session_info)
|
||||
if not resp:
|
||||
return {}, "115登录确认失败:无法连接!"
|
||||
result = resp.json()
|
||||
match result["data"].get("status"):
|
||||
case 0:
|
||||
result = {
|
||||
"status": 0,
|
||||
@@ -110,12 +119,18 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
}
|
||||
case 2:
|
||||
# 确认完成,保存认证信息
|
||||
resp = self.client.login_qrcode_scan_result(uid=self.session_info.get("uid"),
|
||||
app="alipaymini")
|
||||
resp = RequestUtils(timeout=10).post_res(self.login_done_api,
|
||||
data={"account": self.session_info.get("uid")})
|
||||
if not resp:
|
||||
return {}, "115登录确认失败:无法连接!"
|
||||
if resp:
|
||||
# 保存认证信息
|
||||
self.__save_credential(resp["data"]["cookie"])
|
||||
self.__init_cloud(force=True)
|
||||
result = resp.json()
|
||||
cookie_dict = result["data"]["cookie"]
|
||||
cookie_str = "; ".join([f"{k}={v}" for k, v in cookie_dict.items()])
|
||||
cookie_dict.update({"cookie": cookie_str})
|
||||
self.__save_credential(cookie_dict)
|
||||
self.init_storage()
|
||||
result = {
|
||||
"status": 2,
|
||||
"tip": "登录成功!"
|
||||
@@ -143,10 +158,10 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
获取存储空间
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
usage = self.fs.space_summury()
|
||||
usage = self.client.fs.space_summury()
|
||||
if usage:
|
||||
return usage['rt_space_info']['all_total']['size'], usage['rt_space_info']['all_remain']['size']
|
||||
except Exception as e:
|
||||
@@ -163,12 +178,12 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
浏览文件
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
if not self.client:
|
||||
return []
|
||||
try:
|
||||
if fileitem.type == "file":
|
||||
return [fileitem]
|
||||
items: List[P115Path] = self.fs.list(fileitem.path)
|
||||
items: List[P115Path] = self.client.fs.list(fileitem.path)
|
||||
return [schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir" if item.is_dir() else "file",
|
||||
@@ -187,10 +202,10 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
创建目录
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
result = self.fs.makedirs(Path(fileitem.path) / name, exist_ok=True)
|
||||
result = self.client.fs.makedirs(Path(fileitem.path) / name, exist_ok=True)
|
||||
if result:
|
||||
return schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
@@ -208,10 +223,13 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
根据文件路程获取目录,不存在则创建
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
if not self.client:
|
||||
return None
|
||||
folder = self.get_item(path)
|
||||
if folder:
|
||||
return folder
|
||||
try:
|
||||
result = self.fs.makedirs(path, exist_ok=True)
|
||||
result = self.client.fs.makedirs(path, exist_ok=True)
|
||||
if result:
|
||||
return schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
@@ -229,11 +247,11 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
获取文件或目录,不存在返回None
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
try:
|
||||
item = self.fs.attr(path)
|
||||
item = self.client.fs.attr(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if item:
|
||||
@@ -255,11 +273,11 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
获取文件详情
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
try:
|
||||
item = self.fs.attr(fileitem.path)
|
||||
item = self.client.fs.attr(fileitem.path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if item:
|
||||
@@ -281,10 +299,10 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
删除文件
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
self.fs.remove(fileitem.path)
|
||||
self.client.fs.remove(fileitem.path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"115删除文件失败:{str(e)}")
|
||||
@@ -294,10 +312,10 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
重命名文件
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
self.fs.rename(fileitem.path, Path(fileitem.path).with_name(name))
|
||||
self.client.fs.rename(fileitem.path, Path(fileitem.path).with_name(name))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"115重命名文件失败:{str(e)}")
|
||||
@@ -307,11 +325,11 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
获取下载链接
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
if not self.client:
|
||||
return None
|
||||
local_file = (path or settings.TEMP_PATH) / fileitem.name
|
||||
try:
|
||||
task = self.fs.download(fileitem.path, file=local_file)
|
||||
task = self.client.fs.download(fileitem.path, file=local_file)
|
||||
if task:
|
||||
return local_file
|
||||
except Exception as e:
|
||||
@@ -321,13 +339,16 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: str = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
:param fileitem: 上传目录项
|
||||
:param path: 本地文件路径
|
||||
:param new_name: 上传后文件名
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
new_path = Path(fileitem.path) / (new_name or path.name)
|
||||
with open(path, "rb") as f:
|
||||
result = self.fs.upload(f, new_path)
|
||||
result = self.client.fs.upload(f, new_path)
|
||||
if result:
|
||||
return schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
@@ -343,32 +364,38 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
logger.error(f"115上传文件失败:{str(e)}")
|
||||
return None
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, target: schemas.FileItem) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
return False
|
||||
try:
|
||||
self.fs.move(fileitem.path, target.path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"115移动文件失败:{str(e)}")
|
||||
return False
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
if not self.__init_cloud():
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
self.fs.copy(fileitem.path, target_file)
|
||||
self.client.fs.copy(fileitem.path, path / new_name)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"115复制文件失败:{str(e)}")
|
||||
return False
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
self.client.fs.move(fileitem.path, path / new_name)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"115移动文件失败:{str(e)}")
|
||||
return False
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from app.helper.rule import RuleHelper
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.filter.RuleParser import RuleParser
|
||||
from app.schemas.types import ModuleType
|
||||
from app.schemas.types import ModuleType, OtherModulesType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@@ -167,6 +167,13 @@ class FilterModule(_ModuleBase):
|
||||
"""
|
||||
return ModuleType.Other
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> OtherModulesType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return OtherModulesType.Filter
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
@@ -185,13 +192,11 @@ class FilterModule(_ModuleBase):
|
||||
|
||||
def filter_torrents(self, rule_groups: List[str],
|
||||
torrent_list: List[TorrentInfo],
|
||||
season_episodes: Dict[int, list] = None,
|
||||
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
||||
"""
|
||||
过滤种子资源
|
||||
:param rule_groups: 过滤规则组名称列表
|
||||
:param torrent_list: 资源列表
|
||||
:param season_episodes: 季集数过滤 {season:[episodes]}
|
||||
:param mediainfo: 媒体信息
|
||||
:return: 过滤后的资源列表,添加资源优先级
|
||||
"""
|
||||
@@ -208,24 +213,18 @@ class FilterModule(_ModuleBase):
|
||||
torrent_list = self.__filter_torrents(
|
||||
rule_string=group.rule_string,
|
||||
rule_name=group.name,
|
||||
torrent_list=torrent_list,
|
||||
season_episodes=season_episodes
|
||||
)
|
||||
torrent_list=torrent_list
|
||||
)
|
||||
return torrent_list
|
||||
|
||||
def __filter_torrents(self, rule_string: str, rule_name: str,
|
||||
torrent_list: List[TorrentInfo],
|
||||
season_episodes: Dict[int, list]) -> List[TorrentInfo]:
|
||||
torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
|
||||
"""
|
||||
过滤种子
|
||||
"""
|
||||
# 返回种子列表
|
||||
ret_torrents = []
|
||||
for torrent in torrent_list:
|
||||
# 季集数过滤
|
||||
if season_episodes \
|
||||
and not self.__match_season_episodes(torrent, season_episodes):
|
||||
continue
|
||||
# 能命中优先级的才返回
|
||||
if not self.__get_order(torrent, rule_string):
|
||||
logger.debug(f"种子 {torrent.site_name} - {torrent.title} {torrent.description} "
|
||||
@@ -235,39 +234,6 @@ class FilterModule(_ModuleBase):
|
||||
|
||||
return ret_torrents
|
||||
|
||||
@staticmethod
|
||||
def __match_season_episodes(torrent: TorrentInfo, season_episodes: Dict[int, list]):
|
||||
"""
|
||||
判断种子是否匹配季集数
|
||||
"""
|
||||
# 匹配季
|
||||
seasons = season_episodes.keys()
|
||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
# 种子季
|
||||
torrent_seasons = meta.season_list
|
||||
if not torrent_seasons:
|
||||
# 按第一季处理
|
||||
torrent_seasons = [1]
|
||||
# 种子集
|
||||
torrent_episodes = meta.episode_list
|
||||
if not set(torrent_seasons).issubset(set(seasons)):
|
||||
# 种子季不在过滤季中
|
||||
logger.debug(
|
||||
f"种子 {torrent.site_name} - {torrent.title} 包含季 {torrent_seasons} 不是需要的季 {list(seasons)}")
|
||||
return False
|
||||
if not torrent_episodes:
|
||||
# 整季按匹配处理
|
||||
return True
|
||||
if len(torrent_seasons) == 1:
|
||||
need_episodes = season_episodes.get(torrent_seasons[0])
|
||||
if need_episodes \
|
||||
and not set(torrent_episodes).intersection(set(need_episodes)):
|
||||
# 单季集没有交集的不要
|
||||
logger.debug(f"种子 {torrent.site_name} - {torrent.title} "
|
||||
f"集 {torrent_episodes} 没有需要的集:{need_episodes}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def __get_order(self, torrent: TorrentInfo, rule_str: str) -> Optional[TorrentInfo]:
|
||||
"""
|
||||
获取种子匹配的规则优先级,值越大越优先,未匹配时返回None
|
||||
@@ -359,6 +325,8 @@ class FilterModule(_ModuleBase):
|
||||
seeders = self.rule_set[rule_name].get("seeders")
|
||||
# FREE规则
|
||||
downloadvolumefactor = self.rule_set[rule_name].get("downloadvolumefactor")
|
||||
# 发布时间规则
|
||||
pubdate: str = self.rule_set[rule_name].get("publish_time")
|
||||
if includes and not any(re.search(r"%s" % include, content, re.IGNORECASE) for include in includes):
|
||||
# 未发现任何包含项
|
||||
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 不包含任何项 {includes}")
|
||||
@@ -385,6 +353,22 @@ class FilterModule(_ModuleBase):
|
||||
logger.debug(
|
||||
f"种子 {torrent.site_name} - {torrent.title} FREE值 {torrent.downloadvolumefactor} 不是 {downloadvolumefactor}")
|
||||
return False
|
||||
if pubdate:
|
||||
# 种子发布时间
|
||||
pub_minutes = torrent.pub_minutes()
|
||||
# 发布时间规则
|
||||
pub_times = [float(t) for t in pubdate.split("-")]
|
||||
if len(pub_times) == 1:
|
||||
# 发布时间小于规则
|
||||
if pub_minutes < pub_times[0]:
|
||||
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 发布时间 {pub_minutes} 小于 {pub_times[0]}")
|
||||
return False
|
||||
else:
|
||||
# 区间
|
||||
if not (pub_times[0] <= pub_minutes <= pub_times[1]):
|
||||
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 发布时间 {pub_minutes} 不在 {pub_times[0]}-{pub_times[1]} 时间区间")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def __match_tmdb(self, tmdb: dict) -> bool:
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.modules.indexer.spider.tnode import TNodeSpider
|
||||
from app.modules.indexer.spider.torrentleech import TorrentLeech
|
||||
from app.modules.indexer.spider.yema import YemaSpider
|
||||
from app.schemas import SiteUserData
|
||||
from app.schemas.types import MediaType, ModuleType
|
||||
from app.schemas.types import MediaType, ModuleType, OtherModulesType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ class IndexerModule(_ModuleBase):
|
||||
"""
|
||||
return ModuleType.Indexer
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> OtherModulesType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return OtherModulesType.Indexer
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
@@ -275,6 +282,6 @@ class IndexerModule(_ModuleBase):
|
||||
leeching_size=site_obj.leeching_size,
|
||||
message_unread=site_obj.message_unread,
|
||||
message_unread_contents=site_obj.message_unread_contents or [],
|
||||
updated_at=datetime.now().strftime('%Y-%m-%d'),
|
||||
updated_day=datetime.now().strftime('%Y-%m-%d'),
|
||||
err_msg=site_obj.err_msg
|
||||
)
|
||||
|
||||
@@ -344,11 +344,9 @@ class SiteParserBase(metaclass=ABCMeta):
|
||||
logger.warn(
|
||||
f"{self._site_name} 检测到Cloudflare,请更新Cookie和UA")
|
||||
return ""
|
||||
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
|
||||
res.encoding = "utf-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
return res.text
|
||||
return RequestUtils.get_decoded_html_content(res,
|
||||
settings.ENCODING_DETECTION_PERFORMANCE_MODE,
|
||||
settings.ENCODING_DETECTION_MIN_CONFIDENCE)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class NexusPhpSiteUserInfo(SiteParserBase):
|
||||
message_text = message_labels[0].xpath("string(.)")
|
||||
|
||||
logger.debug(f"{self._site_name} 消息原始信息 {message_text}")
|
||||
message_unread_match = re.findall(r"[^Date](信息箱\s*|\(|你有\xa0)(\d+)", message_text)
|
||||
message_unread_match = re.findall(r"[^Date](信息箱\s*|\((?![^)]*:)|你有\xa0)(\d+)", message_text)
|
||||
|
||||
if message_unread_match and len(message_unread_match[-1]) == 2:
|
||||
self.message_unread = StringUtils.str_int(message_unread_match[-1][1])
|
||||
|
||||
@@ -5,7 +5,6 @@ import traceback
|
||||
from typing import List
|
||||
from urllib.parse import quote, urlencode, urlparse, parse_qs
|
||||
|
||||
import chardet
|
||||
from jinja2 import Template
|
||||
from pyquery import PyQuery
|
||||
from ruamel.yaml import CommentedMap
|
||||
@@ -250,27 +249,9 @@ class TorrentSpider:
|
||||
referer=self.referer,
|
||||
proxies=self.proxies
|
||||
).get_res(searchurl, allow_redirects=True)
|
||||
if ret is not None:
|
||||
# 使用chardet检测字符编码
|
||||
raw_data = ret.content
|
||||
if raw_data:
|
||||
try:
|
||||
result = chardet.detect(raw_data)
|
||||
encoding = result['encoding']
|
||||
# 解码为字符串
|
||||
page_source = raw_data.decode(encoding)
|
||||
except Exception as e:
|
||||
logger.debug(f"chardet解码失败:{str(e)}")
|
||||
# 探测utf-8解码
|
||||
if re.search(r"charset=\"?utf-8\"?", ret.text, re.IGNORECASE):
|
||||
ret.encoding = "utf-8"
|
||||
else:
|
||||
ret.encoding = ret.apparent_encoding
|
||||
page_source = ret.text
|
||||
else:
|
||||
page_source = ret.text
|
||||
else:
|
||||
page_source = ""
|
||||
page_source = RequestUtils.get_decoded_html_content(ret,
|
||||
settings.ENCODING_DETECTION_PERFORMANCE_MODE,
|
||||
settings.ENCODING_DETECTION_MIN_CONFIDENCE)
|
||||
|
||||
# 解析
|
||||
return self.parse(page_source)
|
||||
@@ -691,7 +672,7 @@ class TorrentSpider:
|
||||
elif method_name == "appendleft":
|
||||
text = f"{args}{text}"
|
||||
elif method_name == "querystring":
|
||||
parsed_url = urlparse(text)
|
||||
parsed_url = urlparse(str(text))
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
param_value = query_params.get(args)
|
||||
text = param_value[0] if param_value else ''
|
||||
|
||||
@@ -21,14 +21,30 @@ class YemaSpider:
|
||||
_cookie = None
|
||||
_ua = None
|
||||
_size = 40
|
||||
_searchurl = "%sapi/torrent/fetchCategoryOpenTorrentList"
|
||||
_searchurl = "%sapi/torrent/fetchOpenTorrentList"
|
||||
_downloadurl = "%sapi/torrent/download?id=%s"
|
||||
_pageurl = "%s#/torrent/detail/%s/"
|
||||
_timeout = 15
|
||||
|
||||
# 分类
|
||||
_movie_category = 4
|
||||
_tv_category = 5
|
||||
_movie_category = [4]
|
||||
_tv_category = [5, 13, 14, 17, 15, 6, 16]
|
||||
|
||||
# 标签 https://wiki.yemapt.org/developer/constants
|
||||
_labels = {
|
||||
"1": "禁转",
|
||||
"2": "首发",
|
||||
"3": "官方",
|
||||
"4": "自制",
|
||||
"5": "国语",
|
||||
"6": "中字",
|
||||
"7": "粤语",
|
||||
"8": "英字",
|
||||
"9": "HDR10",
|
||||
"10": "杜比视界",
|
||||
"11": "分集",
|
||||
"12": "完结",
|
||||
}
|
||||
|
||||
def __init__(self, indexer: CommentedMap):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
@@ -47,14 +63,7 @@ class YemaSpider:
|
||||
"""
|
||||
搜索
|
||||
"""
|
||||
if not mtype:
|
||||
categoryId = self._movie_category
|
||||
elif mtype == MediaType.TV:
|
||||
categoryId = self._tv_category
|
||||
else:
|
||||
categoryId = self._movie_category
|
||||
params = {
|
||||
"categoryId": categoryId,
|
||||
"pageParam": {
|
||||
"current": page + 1,
|
||||
"pageSize": self._size,
|
||||
@@ -62,6 +71,12 @@ class YemaSpider:
|
||||
},
|
||||
"sorter": {}
|
||||
}
|
||||
# 新接口可不传 categoryId 参数
|
||||
# if mtype == MediaType.MOVIE:
|
||||
# params.update({
|
||||
# "categoryId": self._movie_category,
|
||||
# })
|
||||
# pass
|
||||
if keyword:
|
||||
params.update({
|
||||
"keyword": keyword,
|
||||
@@ -82,17 +97,27 @@ class YemaSpider:
|
||||
results = res.json().get('data', []) or []
|
||||
for result in results:
|
||||
category_value = result.get('categoryId')
|
||||
if category_value == self._tv_category:
|
||||
if category_value in self._tv_category:
|
||||
category = MediaType.TV.value
|
||||
elif category_value == self._movie_category:
|
||||
elif category_value in self._movie_category:
|
||||
category = MediaType.MOVIE.value
|
||||
else:
|
||||
category = MediaType.UNKNOWN.value
|
||||
pass
|
||||
|
||||
torrentLabelIds = result.get('tagList', []) or []
|
||||
torrentLabels = []
|
||||
for labelId in torrentLabelIds:
|
||||
if self._labels.get(labelId) is not None:
|
||||
torrentLabels.append(self._labels.get(labelId))
|
||||
pass
|
||||
pass
|
||||
torrent = {
|
||||
'title': result.get('showName'),
|
||||
'description': result.get('shortDesc'),
|
||||
'enclosure': self.__get_download_url(result.get('id')),
|
||||
'pubdate': StringUtils.unify_datetime_str(result.get('gmtCreate')),
|
||||
# 使用上架时间,而不是用户发布时间,上架时间即其他用户可见时间
|
||||
'pubdate': StringUtils.unify_datetime_str(result.get('listingTime')),
|
||||
'size': result.get('fileSize'),
|
||||
'seeders': result.get('seedNum'),
|
||||
'peers': result.get('leechNum'),
|
||||
@@ -101,7 +126,7 @@ class YemaSpider:
|
||||
'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('uploadPromotion')),
|
||||
'freedate': StringUtils.unify_datetime_str(result.get('downloadPromotionEndTime')),
|
||||
'page_url': self._pageurl % (self._domain, result.get('id')),
|
||||
'labels': [],
|
||||
'labels': torrentLabels,
|
||||
'category': category
|
||||
}
|
||||
torrents.append(torrent)
|
||||
|
||||
@@ -6,8 +6,8 @@ from app.core.event import eventmanager
|
||||
from app.log import logger
|
||||
from app.modules import _MediaServerBase, _ModuleBase
|
||||
from app.modules.jellyfin.jellyfin import Jellyfin
|
||||
from app.schemas.event import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType
|
||||
from app.schemas import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType
|
||||
|
||||
|
||||
class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
|
||||
@@ -30,6 +30,13 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
|
||||
"""
|
||||
return ModuleType.MediaServer
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaServerType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MediaServerType.Jellyfin
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
@@ -66,16 +73,26 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
|
||||
return False, f"无法连接Jellyfin服务器:{name}"
|
||||
return True, ""
|
||||
|
||||
def user_authenticate(self, credentials: AuthCredentials) -> Optional[AuthCredentials]:
|
||||
def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \
|
||||
-> Optional[AuthCredentials]:
|
||||
"""
|
||||
使用Jellyfin用户辅助完成用户认证
|
||||
:param credentials: 认证数据
|
||||
:param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务
|
||||
:return: 认证数据
|
||||
"""
|
||||
# Jellyfin认证
|
||||
if not credentials or credentials.grant_type != "password":
|
||||
return None
|
||||
for name, server in self.get_instances().items():
|
||||
# 确定要认证的服务器列表
|
||||
if service_name:
|
||||
# 如果指定了服务名,获取该服务实例
|
||||
servers = [(service_name, server)] if (server := self.get_instance(service_name)) else []
|
||||
else:
|
||||
# 如果没有指定服务名,遍历所有服务
|
||||
servers = self.get_instances().items()
|
||||
# 遍历要认证的服务器
|
||||
for name, server in servers:
|
||||
# 触发认证拦截事件
|
||||
intercept_event = eventmanager.send_event(
|
||||
etype=ChainEventType.AuthIntercept,
|
||||
|
||||
@@ -6,8 +6,8 @@ from app.core.event import eventmanager
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase, _MediaServerBase
|
||||
from app.modules.plex.plex import Plex
|
||||
from app.schemas.event import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType
|
||||
from app.schemas import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType
|
||||
|
||||
|
||||
class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
|
||||
@@ -30,6 +30,13 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
|
||||
"""
|
||||
return ModuleType.MediaServer
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaServerType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MediaServerType.Plex
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
@@ -66,16 +73,26 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
|
||||
logger.info(f"Plex {name} 服务器连接断开,尝试重连 ...")
|
||||
server.reconnect()
|
||||
|
||||
def user_authenticate(self, credentials: AuthCredentials) -> Optional[AuthCredentials]:
|
||||
def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \
|
||||
-> Optional[AuthCredentials]:
|
||||
"""
|
||||
使用Plex用户辅助完成用户认证
|
||||
:param credentials: 认证数据
|
||||
:param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务
|
||||
:return: 认证数据
|
||||
"""
|
||||
# Plex认证
|
||||
if not credentials or credentials.grant_type != "password":
|
||||
return None
|
||||
for name, server in self.get_instances().items():
|
||||
# 确定要认证的服务器列表
|
||||
if service_name:
|
||||
# 如果指定了服务名,获取该服务实例
|
||||
servers = [(service_name, server)] if (server := self.get_instance(service_name)) else []
|
||||
else:
|
||||
# 如果没有指定服务名,遍历所有服务
|
||||
servers = self.get_instances().items()
|
||||
# 遍历要认证的服务器
|
||||
for name, server in servers:
|
||||
# 触发认证拦截事件
|
||||
intercept_event = eventmanager.send_event(
|
||||
etype=ChainEventType.AuthIntercept,
|
||||
|
||||
@@ -294,7 +294,7 @@ class Plex:
|
||||
return videos.key, season_episodes
|
||||
|
||||
def get_remote_image_by_id(self,
|
||||
item_id: str,
|
||||
item_id: str,
|
||||
image_type: str,
|
||||
depth: int = 0,
|
||||
plex_url: bool = True) -> Optional[str]:
|
||||
@@ -310,12 +310,16 @@ class Plex:
|
||||
return None
|
||||
try:
|
||||
image_url = None
|
||||
ekey = f"/library/metadata/{item_id}"
|
||||
ekey = item_id
|
||||
item = self._plex.fetchItem(ekey=ekey)
|
||||
if not item:
|
||||
return None
|
||||
# 如果配置了外网播放地址以及Token,则默认从Plex媒体服务器获取图片,否则返回有外网地址的图片资源
|
||||
if self._playhost and self._token and plex_url:
|
||||
# Plex外网播放地址这个框里目前可以填两种地址
|
||||
# 1. Plex的官方转发地址https://app.plex.tv, 2. 自己处理的端口转发地址
|
||||
# 如果使用的是1的官方转发地址,那么就不能走这个逻辑,因为官方转发地址无法获取到图片
|
||||
if (self._playhost and "app.plex.tv" not in self._playhost
|
||||
and self._token and plex_url):
|
||||
query = {"X-Plex-Token": self._token}
|
||||
if image_type == "Poster":
|
||||
if item.thumb:
|
||||
@@ -346,8 +350,8 @@ class Plex:
|
||||
image_url = image.key
|
||||
break
|
||||
# 如果最后还是找不到,则递归父级进行查找
|
||||
if not image_url and hasattr(item, "parentRatingKey"):
|
||||
return self.get_remote_image_by_id(item_id=item.parentRatingKey,
|
||||
if not image_url and hasattr(item, "parentKey"):
|
||||
return self.get_remote_image_by_id(item_id=item.parentKey,
|
||||
image_type=image_type,
|
||||
depth=depth + 1)
|
||||
return image_url
|
||||
@@ -665,7 +669,7 @@ class Plex:
|
||||
"S" + str(message.get('Metadata', {}).get('parentIndex')),
|
||||
"E" + str(message.get('Metadata', {}).get('index')),
|
||||
message.get('Metadata', {}).get('title'))
|
||||
eventItem.item_id = message.get('Metadata', {}).get('ratingKey')
|
||||
eventItem.item_id = message.get('Metadata', {}).get('key')
|
||||
eventItem.season_id = message.get('Metadata', {}).get('parentIndex')
|
||||
eventItem.episode_id = message.get('Metadata', {}).get('index')
|
||||
|
||||
@@ -680,7 +684,7 @@ class Plex:
|
||||
eventItem.item_name = "%s %s" % (
|
||||
message.get('Metadata', {}).get('title'),
|
||||
"(" + str(message.get('Metadata', {}).get('year')) + ")")
|
||||
eventItem.item_id = message.get('Metadata', {}).get('ratingKey')
|
||||
eventItem.item_id = message.get('Metadata', {}).get('key')
|
||||
if len(message.get('Metadata', {}).get('summary')) > 100:
|
||||
eventItem.overview = str(message.get('Metadata', {}).get('summary'))[:100] + "..."
|
||||
else:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Set, Tuple, Optional, Union, List
|
||||
from typing import Set, Tuple, Optional, Union, List, Dict
|
||||
|
||||
from qbittorrentapi import TorrentFilesList
|
||||
from torrentool.torrent import Torrent
|
||||
@@ -11,7 +11,7 @@ from app.log import logger
|
||||
from app.modules import _ModuleBase, _DownloaderBase
|
||||
from app.modules.qbittorrent.qbittorrent import Qbittorrent
|
||||
from app.schemas import TransferTorrent, DownloadingTorrent
|
||||
from app.schemas.types import TorrentStatus, ModuleType
|
||||
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
"""
|
||||
return ModuleType.Downloader
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> DownloaderType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return DownloaderType.Qbittorrent
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
@@ -72,7 +79,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None,
|
||||
downloader: str = None) -> Optional[Tuple[Optional[str], Optional[str], str]]:
|
||||
downloader: str = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
:param content: 种子文件地址或者磁力链接
|
||||
@@ -81,7 +88,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
:param episodes: 需要下载的集数
|
||||
:param category: 分类
|
||||
:param downloader: 下载器
|
||||
:return: 种子Hash,错误信息
|
||||
:return: 下载器名称、种子Hash、种子文件布局、错误原因
|
||||
"""
|
||||
|
||||
def __get_torrent_info() -> Tuple[str, int]:
|
||||
@@ -99,10 +106,10 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
return "", 0
|
||||
|
||||
if not content:
|
||||
return None, None, "下载内容为空"
|
||||
return None, None, None, "下载内容为空"
|
||||
if isinstance(content, Path) and not content.exists():
|
||||
logger.error(f"种子文件不存在:{content}")
|
||||
return None, None, f"种子文件不存在:{content}"
|
||||
return None, None, None, f"种子文件不存在:{content}"
|
||||
|
||||
# 获取下载器
|
||||
server: Qbittorrent = self.get_instance(downloader)
|
||||
@@ -124,17 +131,22 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
is_paused=is_paused,
|
||||
tag=tags,
|
||||
cookie=cookie,
|
||||
category=category
|
||||
category=category,
|
||||
ignore_category_check=False
|
||||
)
|
||||
|
||||
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
|
||||
torrent_layout = server.get_content_layout()
|
||||
|
||||
if not state:
|
||||
# 读取种子的名称
|
||||
torrent_name, torrent_size = __get_torrent_info()
|
||||
if not torrent_name:
|
||||
return None, None, f"添加种子任务失败:无法读取种子文件"
|
||||
return None, None, None, f"添加种子任务失败:无法读取种子文件"
|
||||
# 查询所有下载器的种子
|
||||
torrents, error = server.get_torrents()
|
||||
if error:
|
||||
return None, None, "无法连接qbittorrent下载器"
|
||||
return None, None, None, "无法连接qbittorrent下载器"
|
||||
if torrents:
|
||||
for torrent in torrents:
|
||||
# 名称与大小相等则认为是同一个种子
|
||||
@@ -148,19 +160,19 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
if settings.TORRENT_TAG and settings.TORRENT_TAG not in torrent_tags:
|
||||
logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}")
|
||||
server.set_torrents_tag(ids=torrent_hash, tags=[settings.TORRENT_TAG])
|
||||
return downloader or self.get_default_config_name(), torrent_hash, f"下载任务已存在"
|
||||
return None, None, f"添加种子任务失败:{content}"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在"
|
||||
return None, None, None, f"添加种子任务失败:{content}"
|
||||
else:
|
||||
# 获取种子Hash
|
||||
torrent_hash = server.get_torrent_id_by_tag(tags=tag)
|
||||
if not torrent_hash:
|
||||
return None, None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}"
|
||||
return None, None, None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}"
|
||||
else:
|
||||
if is_paused:
|
||||
# 种子文件
|
||||
torrent_files = server.get_files(torrent_hash)
|
||||
if not torrent_files:
|
||||
return downloader or self.get_default_config_name(), torrent_hash, "获取种子文件失败,下载任务可能在暂停状态"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "获取种子文件失败,下载任务可能在暂停状态"
|
||||
|
||||
# 不需要的文件ID
|
||||
file_ids = []
|
||||
@@ -185,11 +197,11 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
server.torrents_set_force_start(torrent_hash)
|
||||
else:
|
||||
server.start_torrents(torrent_hash)
|
||||
return downloader or self.get_default_config_name(), torrent_hash, f"添加下载成功,已选择集数:{sucess_epidised}"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"添加下载成功,已选择集数:{sucess_epidised}"
|
||||
else:
|
||||
if server.is_force_resume():
|
||||
server.torrents_set_force_start(torrent_hash)
|
||||
return downloader or self.get_default_config_name(), torrent_hash, "添加下载成功"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载成功"
|
||||
|
||||
def list_torrents(self, status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None,
|
||||
@@ -203,66 +215,75 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
:return: 下载器中符合状态的种子列表
|
||||
"""
|
||||
# 获取下载器
|
||||
server: Qbittorrent = self.get_instance(downloader)
|
||||
if not server:
|
||||
return None
|
||||
|
||||
if downloader:
|
||||
server: Qbittorrent = self.get_instance(downloader)
|
||||
if not server:
|
||||
return None
|
||||
servers = {downloader: server}
|
||||
else:
|
||||
servers: Dict[str, Qbittorrent] = self.get_instances()
|
||||
ret_torrents = []
|
||||
if hashs:
|
||||
# 按Hash获取
|
||||
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
content_path = torrent.get("content_path")
|
||||
if content_path:
|
||||
torrent_path = Path(content_path)
|
||||
else:
|
||||
torrent_path = Path(torrent.get('save_path')) / torrent.get('name')
|
||||
ret_torrents.append(TransferTorrent(
|
||||
title=torrent.get('name'),
|
||||
path=torrent_path,
|
||||
hash=torrent.get('hash'),
|
||||
size=torrent.get('total_size'),
|
||||
tags=torrent.get('tags')
|
||||
))
|
||||
for name, server in servers.items():
|
||||
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
content_path = torrent.get("content_path")
|
||||
if content_path:
|
||||
torrent_path = Path(content_path)
|
||||
else:
|
||||
torrent_path = Path(torrent.get('save_path')) / torrent.get('name')
|
||||
ret_torrents.append(TransferTorrent(
|
||||
downloader=name,
|
||||
title=torrent.get('name'),
|
||||
path=torrent_path,
|
||||
hash=torrent.get('hash'),
|
||||
size=torrent.get('total_size'),
|
||||
tags=torrent.get('tags')
|
||||
))
|
||||
elif status == TorrentStatus.TRANSFER:
|
||||
# 获取已完成且未整理的
|
||||
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
tags = torrent.get("tags") or []
|
||||
if "已整理" in tags:
|
||||
continue
|
||||
# 内容路径
|
||||
content_path = torrent.get("content_path")
|
||||
if content_path:
|
||||
torrent_path = Path(content_path)
|
||||
else:
|
||||
torrent_path = torrent.get('save_path') / torrent.get('name')
|
||||
ret_torrents.append(TransferTorrent(
|
||||
title=torrent.get('name'),
|
||||
path=torrent_path,
|
||||
hash=torrent.get('hash'),
|
||||
tags=torrent.get('tags')
|
||||
))
|
||||
for name, server in servers.items():
|
||||
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
tags = torrent.get("tags") or []
|
||||
if "已整理" in tags:
|
||||
continue
|
||||
# 内容路径
|
||||
content_path = torrent.get("content_path")
|
||||
if content_path:
|
||||
torrent_path = Path(content_path)
|
||||
else:
|
||||
torrent_path = torrent.get('save_path') / torrent.get('name')
|
||||
ret_torrents.append(TransferTorrent(
|
||||
downloader=name,
|
||||
title=torrent.get('name'),
|
||||
path=torrent_path,
|
||||
hash=torrent.get('hash'),
|
||||
tags=torrent.get('tags')
|
||||
))
|
||||
elif status == TorrentStatus.DOWNLOADING:
|
||||
# 获取正在下载的任务
|
||||
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
meta = MetaInfo(torrent.get('name'))
|
||||
ret_torrents.append(DownloadingTorrent(
|
||||
hash=torrent.get('hash'),
|
||||
title=torrent.get('name'),
|
||||
name=meta.name,
|
||||
year=meta.year,
|
||||
season_episode=meta.season_episode,
|
||||
progress=torrent.get('progress') * 100,
|
||||
size=torrent.get('total_size'),
|
||||
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
|
||||
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
|
||||
upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
|
||||
left_time=StringUtils.str_secends(
|
||||
(torrent.get('total_size') - torrent.get('completed')) / torrent.get('dlspeed')) if torrent.get(
|
||||
'dlspeed') > 0 else ''
|
||||
))
|
||||
for name, server in servers.items():
|
||||
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
meta = MetaInfo(torrent.get('name'))
|
||||
ret_torrents.append(DownloadingTorrent(
|
||||
downloader=name,
|
||||
hash=torrent.get('hash'),
|
||||
title=torrent.get('name'),
|
||||
name=meta.name,
|
||||
year=meta.year,
|
||||
season_episode=meta.season_episode,
|
||||
progress=torrent.get('progress') * 100,
|
||||
size=torrent.get('total_size'),
|
||||
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
|
||||
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
|
||||
upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
|
||||
left_time=StringUtils.str_secends(
|
||||
(torrent.get('total_size') - torrent.get('completed')) / torrent.get('dlspeed')) if torrent.get(
|
||||
'dlspeed') > 0 else ''
|
||||
))
|
||||
else:
|
||||
return None
|
||||
return ret_torrents
|
||||
|
||||
@@ -251,6 +251,7 @@ class Qbittorrent:
|
||||
:param category: 种子分类
|
||||
:param download_dir: 下载路径
|
||||
:param cookie: 站点Cookie用于辅助下载种子
|
||||
:param kwargs: 可选参数,如 ignore_category_check 以及 QB相关参数
|
||||
:return: bool
|
||||
"""
|
||||
if not self.qbc or not content:
|
||||
@@ -276,13 +277,16 @@ class Qbittorrent:
|
||||
else:
|
||||
tags = None
|
||||
|
||||
# 分类自动管理
|
||||
if category and self._category:
|
||||
is_auto = True
|
||||
# 如果忽略分类检查,则直接使用传入的分类值,否则,仅在分类存在且启用了自动管理时才传递参数
|
||||
ignore_category_check = kwargs.pop("ignore_category_check", True)
|
||||
if ignore_category_check:
|
||||
is_auto = self._category
|
||||
else:
|
||||
is_auto = False
|
||||
category = None
|
||||
|
||||
if category and self._category:
|
||||
is_auto = True
|
||||
else:
|
||||
is_auto = False
|
||||
category = None
|
||||
try:
|
||||
# 添加下载
|
||||
qbc_ret = self.qbc.torrents_add(urls=urls,
|
||||
@@ -444,3 +448,14 @@ class Qbittorrent:
|
||||
except Exception as err:
|
||||
logger.error(f"修改tracker出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def get_content_layout(self) -> Optional[str]:
|
||||
"""
|
||||
获取内容布局
|
||||
"""
|
||||
if not self.qbc:
|
||||
return None
|
||||
# 获取下载器全局设置
|
||||
application = self.qbc.application.preferences
|
||||
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
|
||||
return application.get("torrent_content_layout", "Original")
|
||||
|
||||
@@ -31,6 +31,13 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||
"""
|
||||
return ModuleType.Notification
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MessageChannel:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MessageChannel.Slack
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.core.context import Context
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas.types import ModuleType
|
||||
from app.schemas.types import ModuleType, OtherModulesType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
@@ -40,6 +40,13 @@ class SubtitleModule(_ModuleBase):
|
||||
"""
|
||||
return ModuleType.Other
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> OtherModulesType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return OtherModulesType.Subtitle
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
|
||||
@@ -29,6 +29,13 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
|
||||
"""
|
||||
return ModuleType.Notification
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MessageChannel:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MessageChannel.SynologyChat
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
|
||||
@@ -7,8 +7,7 @@ from app.core.event import eventmanager
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase, _MessageBase
|
||||
from app.modules.telegram.telegram import Telegram
|
||||
from app.schemas import MessageChannel, CommingMessage, Notification
|
||||
from app.schemas.event import CommandRegisterEventData
|
||||
from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData
|
||||
from app.schemas.types import ModuleType, ChainEventType
|
||||
from app.utils.structures import DictUtils
|
||||
|
||||
@@ -34,6 +33,13 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
"""
|
||||
return ModuleType.Notification
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MessageChannel:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MessageChannel.Telegram
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.modules.themoviedb.scraper import TmdbScraper
|
||||
from app.modules.themoviedb.tmdb_cache import TmdbCache
|
||||
from app.modules.themoviedb.tmdbapi import TmdbApi
|
||||
from app.schemas import MediaPerson
|
||||
from app.schemas.types import MediaType, MediaImageType, ModuleType
|
||||
from app.schemas.types import MediaType, MediaImageType, ModuleType, MediaRecognizeType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@ class TheMovieDbModule(_ModuleBase):
|
||||
"""
|
||||
return ModuleType.MediaRecognize
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaRecognizeType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MediaRecognizeType.TMDB
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
@@ -304,6 +311,27 @@ class TheMovieDbModule(_ModuleBase):
|
||||
return [MediaPerson(source='themoviedb', **person) for person in results]
|
||||
return []
|
||||
|
||||
def search_collections(self, name: str) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索集合信息
|
||||
"""
|
||||
if not name:
|
||||
return []
|
||||
results = self.tmdb.search_collections(name)
|
||||
if results:
|
||||
return [MediaInfo(tmdb_info=info) for info in results]
|
||||
return []
|
||||
|
||||
def tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据合集ID查询集合
|
||||
:param collection_id: 合集ID
|
||||
"""
|
||||
results = self.tmdb.get_collection(collection_id)
|
||||
if results:
|
||||
return [MediaInfo(tmdb_info=info) for info in results]
|
||||
return []
|
||||
|
||||
def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||
season: int = None, episode: int = None) -> Optional[str]:
|
||||
"""
|
||||
|
||||
@@ -30,9 +30,9 @@ class TmdbScraper:
|
||||
# 电影元数据文件
|
||||
doc = self.__gen_movie_nfo_file(mediainfo=mediainfo)
|
||||
else:
|
||||
if season:
|
||||
if season is not None:
|
||||
# 查询季信息
|
||||
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, meta.begin_season)
|
||||
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, season)
|
||||
if episode:
|
||||
# 集元数据文件
|
||||
episodeinfo = self.__get_episode_detail(seasoninfo, meta.begin_episode)
|
||||
@@ -45,7 +45,7 @@ class TmdbScraper:
|
||||
# 电视剧元数据文件
|
||||
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
||||
if doc:
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
||||
|
||||
return None
|
||||
|
||||
@@ -57,7 +57,7 @@ class TmdbScraper:
|
||||
:param episode: 集号
|
||||
"""
|
||||
images = {}
|
||||
if season:
|
||||
if season is not None:
|
||||
# 只需要集的图片
|
||||
if episode:
|
||||
# 集的图片
|
||||
@@ -102,8 +102,13 @@ class TmdbScraper:
|
||||
ext = Path(seasoninfo.get('poster_path')).suffix
|
||||
# URL
|
||||
url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{seasoninfo.get('poster_path')}"
|
||||
image_name = f"season{sea_seq}-poster{ext}"
|
||||
# S0海报格式不同
|
||||
if season == 0:
|
||||
image_name = f"season-specials-poster{ext}"
|
||||
else:
|
||||
image_name = f"season{sea_seq}-poster{ext}"
|
||||
return image_name, url
|
||||
return "", ""
|
||||
|
||||
@staticmethod
|
||||
def __get_episode_detail(seasoninfo: dict, episode: int) -> dict:
|
||||
@@ -228,7 +233,7 @@ class TmdbScraper:
|
||||
xoutline = DomUtils.add_node(doc, root, "outline")
|
||||
xoutline.appendChild(doc.createCDATASection(seasoninfo.get("overview") or ""))
|
||||
# 标题
|
||||
DomUtils.add_node(doc, root, "title", "季 %s" % season)
|
||||
DomUtils.add_node(doc, root, "title", seasoninfo.get("name") or "季 %s" % season)
|
||||
# 发行日期
|
||||
DomUtils.add_node(doc, root, "premiered", seasoninfo.get("air_date") or "")
|
||||
DomUtils.add_node(doc, root, "releasedate", seasoninfo.get("air_date") or "")
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.schemas.types import MediaType
|
||||
lock = RLock()
|
||||
|
||||
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
|
||||
EXPIRE_TIMESTAMP = settings.CACHE_CONF.get('meta')
|
||||
EXPIRE_TIMESTAMP = settings.CACHE_CONF["meta"]
|
||||
|
||||
|
||||
class TmdbCache(metaclass=Singleton):
|
||||
@@ -75,7 +75,7 @@ class TmdbCache(metaclass=Singleton):
|
||||
@return: 被删除的缓存内容
|
||||
"""
|
||||
with lock:
|
||||
return self._meta_data.pop(key, None)
|
||||
return self._meta_data.pop(key, {})
|
||||
|
||||
def delete_by_tmdbid(self, tmdbid: int) -> None:
|
||||
"""
|
||||
@@ -138,14 +138,14 @@ class TmdbCache(metaclass=Singleton):
|
||||
if cache_year:
|
||||
cache_year = cache_year[:4]
|
||||
self._meta_data[self.__get_key(meta)] = {
|
||||
"id": info.get("id"),
|
||||
"type": info.get("media_type"),
|
||||
"year": cache_year,
|
||||
"title": cache_title,
|
||||
"poster_path": info.get("poster_path"),
|
||||
"backdrop_path": info.get("backdrop_path"),
|
||||
CACHE_EXPIRE_TIMESTAMP_STR: int(time.time()) + EXPIRE_TIMESTAMP
|
||||
}
|
||||
"id": info.get("id"),
|
||||
"type": info.get("media_type"),
|
||||
"year": cache_year,
|
||||
"title": cache_title,
|
||||
"poster_path": info.get("poster_path"),
|
||||
"backdrop_path": info.get("backdrop_path"),
|
||||
CACHE_EXPIRE_TIMESTAMP_STR: int(time.time()) + EXPIRE_TIMESTAMP
|
||||
}
|
||||
elif info is not None:
|
||||
# None时不缓存,此时代表网络错误,允许重复请求
|
||||
self._meta_data[self.__get_key(meta)] = {'id': 0}
|
||||
@@ -164,7 +164,7 @@ class TmdbCache(metaclass=Singleton):
|
||||
return
|
||||
|
||||
with open(self._meta_path, 'wb') as f:
|
||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL)
|
||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # type: ignore
|
||||
|
||||
def _random_sample(self, new_meta_data: dict) -> bool:
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import traceback
|
||||
from functools import lru_cache
|
||||
from typing import Optional, List
|
||||
from urllib.parse import quote
|
||||
|
||||
import zhconv
|
||||
from cachetools import TTLCache, cached
|
||||
from lxml import etree
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -11,7 +11,7 @@ from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
from .tmdbv3api import TMDb, Search, Movie, TV, Season, Episode, Discover, Trending, Person
|
||||
from .tmdbv3api import TMDb, Search, Movie, TV, Season, Episode, Discover, Trending, Person, Collection
|
||||
from .tmdbv3api.exceptions import TMDbException
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ class TmdbApi:
|
||||
self.tmdb.domain = settings.TMDB_API_DOMAIN
|
||||
# 开启缓存
|
||||
self.tmdb.cache = True
|
||||
# 缓存大小
|
||||
self.tmdb.REQUEST_CACHE_MAXSIZE = settings.CACHE_CONF.get('tmdb')
|
||||
# APIKEY
|
||||
self.tmdb.api_key = settings.TMDB_API_KEY
|
||||
# 语种
|
||||
@@ -46,6 +44,7 @@ class TmdbApi:
|
||||
self.discover = Discover()
|
||||
self.trending = Trending()
|
||||
self.person = Person()
|
||||
self.collection = Collection()
|
||||
|
||||
def search_multiis(self, title: str) -> List[dict]:
|
||||
"""
|
||||
@@ -103,6 +102,32 @@ class TmdbApi:
|
||||
return []
|
||||
return self.search.people(term=name) or []
|
||||
|
||||
def search_collections(self, name: str) -> List[dict]:
|
||||
"""
|
||||
查询模糊匹配的所有合集TMDB信息
|
||||
"""
|
||||
if not name:
|
||||
return []
|
||||
collections = self.search.collections(term=name) or []
|
||||
for collection in collections:
|
||||
collection['media_type'] = MediaType.COLLECTION
|
||||
collection['collection_id'] = collection.get("id")
|
||||
return collections
|
||||
|
||||
def get_collection(self, collection_id: int) -> List[dict]:
|
||||
"""
|
||||
根据合集ID查询合集详情
|
||||
"""
|
||||
if not collection_id:
|
||||
return []
|
||||
try:
|
||||
return self.collection.details(collection_id=collection_id)
|
||||
except TMDbException as err:
|
||||
logger.error(f"连接TMDB出错:{str(err)}")
|
||||
except Exception as e:
|
||||
logger.error(f"连接TMDB出错:{str(e)}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def __compare_names(file_name: str, tmdb_names: list) -> bool:
|
||||
"""
|
||||
@@ -163,7 +188,7 @@ class TmdbApi:
|
||||
season_number: int = None) -> Optional[dict]:
|
||||
"""
|
||||
搜索tmdb中的媒体信息,匹配返回一条尽可能正确的信息
|
||||
:param name: 剑索的名称
|
||||
:param name: 检索的名称
|
||||
:param mtype: 类型:电影、电视剧
|
||||
:param year: 年份,如要是季集需要是首播年份(first_air_date)
|
||||
:param season_year: 当前季集年份
|
||||
@@ -466,7 +491,7 @@ class TmdbApi:
|
||||
|
||||
return ret_info
|
||||
|
||||
@lru_cache(maxsize=settings.CACHE_CONF.get('tmdb'))
|
||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"]))
|
||||
def match_web(self, name: str, mtype: MediaType) -> Optional[dict]:
|
||||
"""
|
||||
搜索TMDB网站,直接抓取结果,结果只有一条时才返回
|
||||
@@ -1292,7 +1317,7 @@ class TmdbApi:
|
||||
for group_episode in group_episodes:
|
||||
order = group_episode.get('order')
|
||||
episodes = group_episode.get('episodes')
|
||||
if not episodes or not order:
|
||||
if not episodes:
|
||||
continue
|
||||
# 当前季第一季时间
|
||||
first_date = episodes[0].get("air_date")
|
||||
|
||||
@@ -4,11 +4,12 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
import requests
|
||||
import requests.exceptions
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
from app.core.config import settings
|
||||
from app.utils.http import RequestUtils
|
||||
from .exceptions import TMDbException
|
||||
|
||||
@@ -24,7 +25,6 @@ class TMDb(object):
|
||||
TMDB_CACHE_ENABLED = "TMDB_CACHE_ENABLED"
|
||||
TMDB_PROXIES = "TMDB_PROXIES"
|
||||
TMDB_DOMAIN = "TMDB_DOMAIN"
|
||||
REQUEST_CACHE_MAXSIZE = None
|
||||
|
||||
_req = None
|
||||
_session = None
|
||||
@@ -137,7 +137,7 @@ class TMDb(object):
|
||||
def cache(self, cache):
|
||||
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
|
||||
|
||||
@lru_cache(maxsize=REQUEST_CACHE_MAXSIZE)
|
||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"]))
|
||||
def cached_request(self, method, url, data, json,
|
||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,7 @@ from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.thetvdb import tvdbapi
|
||||
from app.schemas.types import ModuleType
|
||||
from app.schemas.types import ModuleType, MediaRecognizeType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@ class TheTvDbModule(_ModuleBase):
|
||||
"""
|
||||
return ModuleType.MediaRecognize
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaRecognizeType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MediaRecognizeType.TVDB
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
"""
|
||||
Simple-to-use Python interface to The TVDB's API (thetvdb.com)
|
||||
"""
|
||||
@@ -6,19 +5,20 @@
|
||||
__author__ = "dbr/Ben"
|
||||
__version__ = "3.1.0"
|
||||
|
||||
import sys
|
||||
import getpass
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import getpass
|
||||
import tempfile
|
||||
import warnings
|
||||
import logging
|
||||
import hashlib
|
||||
from typing import Optional, Union
|
||||
|
||||
import requests
|
||||
import requests_cache
|
||||
from requests_cache.backends.base import _to_bytes, _DEFAULT_HEADERS
|
||||
from requests_cache.backends.base import _to_bytes, _DEFAULT_HEADERS # noqa
|
||||
|
||||
IS_PY2 = sys.version_info[0] == 2
|
||||
|
||||
@@ -176,7 +176,8 @@ class ConsoleUI(BaseUI):
|
||||
"""Interactively allows the user to select a show from a console based UI
|
||||
"""
|
||||
|
||||
def _displaySeries(self, allSeries, limit=6):
|
||||
@staticmethod
|
||||
def _displaySeries(allSeries, limit: Optional[int] = 6):
|
||||
"""Helper function, lists series with corresponding ID
|
||||
"""
|
||||
if limit is not None:
|
||||
@@ -267,6 +268,7 @@ class ShowContainer(dict):
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._stack = []
|
||||
self._lastgc = time.time()
|
||||
|
||||
@@ -336,42 +338,6 @@ class Show(dict):
|
||||
|
||||
Search terms are converted to lower case (unicode) strings.
|
||||
|
||||
# Examples
|
||||
|
||||
These examples assume t is an instance of Tvdb():
|
||||
|
||||
>>> t = Tvdb()
|
||||
>>>
|
||||
|
||||
To search for all episodes of Scrubs with a bit of data
|
||||
containing "my first day":
|
||||
|
||||
>>> t['Scrubs'].search("my first day")
|
||||
[<Episode 01x01 - u'My First Day'>]
|
||||
>>>
|
||||
|
||||
Search for "My Name Is Earl" episode named "Faked His Own Death":
|
||||
|
||||
>>> t['My Name Is Earl'].search('Faked My Own Death', key='episodeName')
|
||||
[<Episode 01x04 - u'Faked My Own Death'>]
|
||||
>>>
|
||||
|
||||
To search Scrubs for all episodes with "mentor" in the episode name:
|
||||
|
||||
>>> t['scrubs'].search('mentor', key='episodeName')
|
||||
[<Episode 01x02 - u'My Mentor'>, <Episode 03x15 - u'My Tormented Mentor'>]
|
||||
>>>
|
||||
|
||||
# Using search results
|
||||
|
||||
>>> results = t['Scrubs'].search("my first")
|
||||
>>> print results[0]['episodeName']
|
||||
My First Day
|
||||
>>> for x in results: print x['episodeName']
|
||||
My First Day
|
||||
My First Step
|
||||
My First Kill
|
||||
>>>
|
||||
"""
|
||||
results = []
|
||||
for cur_season in self.values():
|
||||
@@ -386,6 +352,7 @@ class Season(dict):
|
||||
def __init__(self, show=None):
|
||||
"""The show attribute points to the parent show
|
||||
"""
|
||||
super().__init__()
|
||||
self.show = show
|
||||
|
||||
def __repr__(self):
|
||||
@@ -420,6 +387,7 @@ class Episode(dict):
|
||||
def __init__(self, season=None):
|
||||
"""The season attribute points to the parent season
|
||||
"""
|
||||
super().__init__()
|
||||
self.season = season
|
||||
|
||||
def __repr__(self):
|
||||
@@ -540,7 +508,7 @@ class Tvdb:
|
||||
self,
|
||||
interactive=False,
|
||||
select_first=False,
|
||||
cache=True,
|
||||
cache: Union[str, bool, requests.Session] = True,
|
||||
banners=False,
|
||||
actors=False,
|
||||
custom_ui=None,
|
||||
@@ -690,7 +658,7 @@ class Tvdb:
|
||||
LOG.debug("Using specified requests.Session")
|
||||
self.session = cache
|
||||
try:
|
||||
self.session.get
|
||||
self.session.get # noqa
|
||||
except AttributeError:
|
||||
raise ValueError(
|
||||
(
|
||||
@@ -776,7 +744,7 @@ class Tvdb:
|
||||
cache_key = self.session.cache.create_key(
|
||||
fake_session_for_key.prepare_request(requests.Request('GET', url))
|
||||
)
|
||||
except Exception:
|
||||
except Exception: # noqa
|
||||
# FIXME: Can this just check for hasattr(self.session, "cache") instead?
|
||||
pass
|
||||
|
||||
@@ -956,6 +924,7 @@ class Tvdb:
|
||||
banners_resp = self._getetsrc(self.config['url_seriesBanner'] % sid)
|
||||
banners = {}
|
||||
for cur_banner in banners_resp.keys():
|
||||
btype = None
|
||||
banners_info = self._getetsrc(self.config['url_seriesBannerInfo'] % (sid, cur_banner))
|
||||
for banner_info in banners_info:
|
||||
bid = banner_info.get('id')
|
||||
@@ -981,32 +950,14 @@ class Tvdb:
|
||||
LOG.debug("Transforming %s to %s" % (k, new_key))
|
||||
new_url = self.config['url_artworkPrefix'] % v
|
||||
banners[btype][btype2][bid][new_key] = new_url
|
||||
|
||||
banners[btype]['raw'] = banners_info
|
||||
self._setShowData(sid, "_banners", banners)
|
||||
if btype:
|
||||
banners[btype]['raw'] = banners_info
|
||||
self._setShowData(sid, "_banners", banners)
|
||||
|
||||
def _parseActors(self, sid):
|
||||
"""Parsers actors XML, from
|
||||
http://thetvdb.com/api/[APIKEY]/series/[SERIES ID]/actors.xml
|
||||
|
||||
Actors are retrieved using t['show name]['_actors'], for example:
|
||||
|
||||
>>> t = Tvdb(actors = True)
|
||||
>>> actors = t['scrubs']['_actors']
|
||||
>>> type(actors)
|
||||
<class 'tvdb_api.Actors'>
|
||||
>>> type(actors[0])
|
||||
<class 'tvdb_api.Actor'>
|
||||
>>> actors[0]
|
||||
<Actor u'John C. McGinley'>
|
||||
>>> sorted(actors[0].keys())
|
||||
[u'id', u'image', u'imageAdded', u'imageAuthor', u'lastUpdated', u'name', u'role',
|
||||
u'seriesId', u'sortOrder']
|
||||
>>> actors[0]['name']
|
||||
u'John C. McGinley'
|
||||
>>> actors[0]['image']
|
||||
u'http://thetvdb.com/banners/actors/43638.jpg'
|
||||
|
||||
Any key starting with an underscore has been processed (not the raw
|
||||
data from the XML)
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Set, Tuple, Optional, Union, List
|
||||
from typing import Set, Tuple, Optional, Union, List, Dict
|
||||
|
||||
from torrentool.torrent import Torrent
|
||||
from transmission_rpc import File
|
||||
@@ -11,7 +11,7 @@ from app.log import logger
|
||||
from app.modules import _ModuleBase, _DownloaderBase
|
||||
from app.modules.transmission.transmission import Transmission
|
||||
from app.schemas import TransferTorrent, DownloadingTorrent
|
||||
from app.schemas.types import TorrentStatus, ModuleType
|
||||
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
"""
|
||||
return ModuleType.Downloader
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> DownloaderType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return DownloaderType.Transmission
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
@@ -73,7 +80,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None,
|
||||
downloader: str = None) -> Optional[Tuple[Optional[str], Optional[str], str]]:
|
||||
downloader: str = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
:param content: 种子文件地址或者磁力链接
|
||||
@@ -82,7 +89,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
:param episodes: 需要下载的集数
|
||||
:param category: 分类,TR中未使用
|
||||
:param downloader: 下载器
|
||||
:return: 下载器名称、种子Hash、错误原因
|
||||
:return: 下载器名称、种子Hash、种子文件布局、错误原因
|
||||
"""
|
||||
|
||||
def __get_torrent_info() -> Tuple[str, int]:
|
||||
@@ -100,9 +107,9 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
return "", 0
|
||||
|
||||
if not content:
|
||||
return None, None, "下载内容为空"
|
||||
return None, None, None, "下载内容为空"
|
||||
if isinstance(content, Path) and not content.exists():
|
||||
return None, None, f"种子文件不存在:{content}"
|
||||
return None, None, None, f"种子文件不存在:{content}"
|
||||
|
||||
# 获取下载器
|
||||
server: Transmission = self.get_instance(downloader)
|
||||
@@ -124,15 +131,18 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
labels=labels,
|
||||
cookie=cookie
|
||||
)
|
||||
# TR 始终使用原始种子布局, 返回"Original"
|
||||
torrent_layout = "Original"
|
||||
|
||||
if not torrent:
|
||||
# 读取种子的名称
|
||||
torrent_name, torrent_size = __get_torrent_info()
|
||||
if not torrent_name:
|
||||
return None, None, f"添加种子任务失败:无法读取种子文件"
|
||||
return None, None, None, f"添加种子任务失败:无法读取种子文件"
|
||||
# 查询所有下载器的种子
|
||||
torrents, error = server.get_torrents()
|
||||
if error:
|
||||
return None, None, "无法连接transmission下载器"
|
||||
return None, None, None, "无法连接transmission下载器"
|
||||
if torrents:
|
||||
for torrent in torrents:
|
||||
# 名称与大小相等则认为是同一个种子
|
||||
@@ -151,15 +161,15 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
if settings.TORRENT_TAG and settings.TORRENT_TAG not in labels:
|
||||
labels.append(settings.TORRENT_TAG)
|
||||
server.set_torrent_tag(ids=torrent_hash, tags=labels)
|
||||
return downloader or self.get_default_config_name(), torrent_hash, f"下载任务已存在"
|
||||
return None, None, f"添加种子任务失败:{content}"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在"
|
||||
return None, None, None, f"添加种子任务失败:{content}"
|
||||
else:
|
||||
torrent_hash = torrent.hashString
|
||||
if is_paused:
|
||||
# 选择文件
|
||||
torrent_files = server.get_files(torrent_hash)
|
||||
if not torrent_files:
|
||||
return downloader or self.get_default_config_name(), torrent_hash, "获取种子文件失败,下载任务可能在暂停状态"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "获取种子文件失败,下载任务可能在暂停状态"
|
||||
# 需要的文件信息
|
||||
file_ids = []
|
||||
unwanted_file_ids = []
|
||||
@@ -180,9 +190,9 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
server.set_unwanted_files(torrent_hash, unwanted_file_ids)
|
||||
# 开始任务
|
||||
server.start_torrents(torrent_hash)
|
||||
return downloader or self.get_default_config_name(), torrent_hash, "添加下载任务成功"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载任务成功"
|
||||
else:
|
||||
return downloader or self.get_default_config_name(), torrent_hash, "添加下载任务成功"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载任务成功"
|
||||
|
||||
def list_torrents(self, status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None,
|
||||
@@ -196,60 +206,70 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
:return: 下载器中符合状态的种子列表
|
||||
"""
|
||||
# 获取下载器
|
||||
server: Transmission = self.get_instance(downloader)
|
||||
if not server:
|
||||
return None
|
||||
if downloader:
|
||||
server: Transmission = self.get_instance(downloader)
|
||||
if not server:
|
||||
return None
|
||||
servers = {downloader: server}
|
||||
else:
|
||||
servers: Dict[str, Transmission] = self.get_instances()
|
||||
ret_torrents = []
|
||||
if hashs:
|
||||
# 按Hash获取
|
||||
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
ret_torrents.append(TransferTorrent(
|
||||
title=torrent.name,
|
||||
path=Path(torrent.download_dir) / torrent.name,
|
||||
hash=torrent.hashString,
|
||||
size=torrent.total_size,
|
||||
tags=",".join(torrent.labels or [])
|
||||
))
|
||||
for name, server in servers.items():
|
||||
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
ret_torrents.append(TransferTorrent(
|
||||
downloader=name,
|
||||
title=torrent.name,
|
||||
path=Path(torrent.download_dir) / torrent.name,
|
||||
hash=torrent.hashString,
|
||||
size=torrent.total_size,
|
||||
tags=",".join(torrent.labels or [])
|
||||
))
|
||||
elif status == TorrentStatus.TRANSFER:
|
||||
# 获取已完成且未整理的
|
||||
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
# 含"已整理"tag的不处理
|
||||
if "已整理" in torrent.labels or []:
|
||||
continue
|
||||
# 下载路径
|
||||
path = torrent.download_dir
|
||||
# 无法获取下载路径的不处理
|
||||
if not path:
|
||||
logger.debug(f"未获取到 {torrent.name} 下载保存路径")
|
||||
continue
|
||||
ret_torrents.append(TransferTorrent(
|
||||
title=torrent.name,
|
||||
path=Path(torrent.download_dir) / torrent.name,
|
||||
hash=torrent.hashString,
|
||||
tags=",".join(torrent.labels or [])
|
||||
))
|
||||
for name, server in servers.items():
|
||||
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
# 含"已整理"tag的不处理
|
||||
if "已整理" in torrent.labels or []:
|
||||
continue
|
||||
# 下载路径
|
||||
path = torrent.download_dir
|
||||
# 无法获取下载路径的不处理
|
||||
if not path:
|
||||
logger.debug(f"未获取到 {torrent.name} 下载保存路径")
|
||||
continue
|
||||
ret_torrents.append(TransferTorrent(
|
||||
downloader=name,
|
||||
title=torrent.name,
|
||||
path=Path(torrent.download_dir) / torrent.name,
|
||||
hash=torrent.hashString,
|
||||
tags=",".join(torrent.labels or [])
|
||||
))
|
||||
elif status == TorrentStatus.DOWNLOADING:
|
||||
# 获取正在下载的任务
|
||||
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
meta = MetaInfo(torrent.name)
|
||||
dlspeed = torrent.rate_download if hasattr(torrent, "rate_download") else torrent.rateDownload
|
||||
upspeed = torrent.rate_upload if hasattr(torrent, "rate_upload") else torrent.rateUpload
|
||||
ret_torrents.append(DownloadingTorrent(
|
||||
hash=torrent.hashString,
|
||||
title=torrent.name,
|
||||
name=meta.name,
|
||||
year=meta.year,
|
||||
season_episode=meta.season_episode,
|
||||
progress=torrent.progress,
|
||||
size=torrent.total_size,
|
||||
state="paused" if torrent.status == "stopped" else "downloading",
|
||||
dlspeed=StringUtils.str_filesize(dlspeed),
|
||||
upspeed=StringUtils.str_filesize(upspeed),
|
||||
left_time=StringUtils.str_secends(torrent.left_until_done / dlspeed) if dlspeed > 0 else ''
|
||||
))
|
||||
for name, server in servers.items():
|
||||
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
meta = MetaInfo(torrent.name)
|
||||
dlspeed = torrent.rate_download if hasattr(torrent, "rate_download") else torrent.rateDownload
|
||||
upspeed = torrent.rate_upload if hasattr(torrent, "rate_upload") else torrent.rateUpload
|
||||
ret_torrents.append(DownloadingTorrent(
|
||||
downloader=name,
|
||||
hash=torrent.hashString,
|
||||
title=torrent.name,
|
||||
name=meta.name,
|
||||
year=meta.year,
|
||||
season_episode=meta.season_episode,
|
||||
progress=torrent.progress,
|
||||
size=torrent.total_size,
|
||||
state="paused" if torrent.status == "stopped" else "downloading",
|
||||
dlspeed=StringUtils.str_filesize(dlspeed),
|
||||
upspeed=StringUtils.str_filesize(upspeed),
|
||||
left_time=StringUtils.str_secends(torrent.left_until_done / dlspeed) if dlspeed > 0 else ''
|
||||
))
|
||||
else:
|
||||
return None
|
||||
return ret_torrents
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from typing import Optional, Union, Tuple, List
|
||||
from typing import Optional, Union, Tuple, List, Literal
|
||||
|
||||
import transmission_rpc
|
||||
from transmission_rpc import Client, Torrent, File
|
||||
from transmission_rpc.session import SessionStats, Session
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
|
||||
class Transmission:
|
||||
_protocol: Literal["http", "https"] = "http"
|
||||
_host: str = None
|
||||
_port: int = None
|
||||
_username: str = None
|
||||
@@ -28,9 +29,14 @@ class Transmission:
|
||||
若不设置参数,则创建配置文件设置的下载器
|
||||
"""
|
||||
if host and port:
|
||||
self._host, self._port = host, port
|
||||
self._protocol, self._host, self._port = kwargs.get("protocol", self._protocol), host, port
|
||||
elif host:
|
||||
self._host, self._port = StringUtils.get_domain_address(address=host, prefix=False)
|
||||
result = UrlUtils.parse_url_params(url=host)
|
||||
if result:
|
||||
self._protocol, self._host, self._port, path = result
|
||||
else:
|
||||
logger.error("Transmission配置不正确!")
|
||||
return
|
||||
else:
|
||||
logger.error("Transmission配置不完整!")
|
||||
return
|
||||
@@ -46,8 +52,9 @@ class Transmission:
|
||||
"""
|
||||
try:
|
||||
# 登录
|
||||
logger.info(f"正在连接 transmission:{self._host}:{self._port}")
|
||||
trt = transmission_rpc.Client(host=self._host,
|
||||
logger.info(f"正在连接 transmission:{self._protocol}://{self._host}:{self._port}")
|
||||
trt = transmission_rpc.Client(protocol=self._protocol,
|
||||
host=self._host,
|
||||
port=self._port,
|
||||
username=self._username,
|
||||
password=self._password,
|
||||
@@ -126,7 +133,7 @@ class Transmission:
|
||||
return None
|
||||
try:
|
||||
torrents, error = self.get_torrents(ids=ids,
|
||||
status=["downloading", "download_pending", "stopped"],
|
||||
status=["downloading", "download_pending"],
|
||||
tags=tags)
|
||||
return None if error else torrents or []
|
||||
except Exception as err:
|
||||
|
||||
@@ -30,6 +30,13 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
|
||||
"""
|
||||
return ModuleType.Notification
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MessageChannel:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MessageChannel.VoceChat
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
|
||||
@@ -30,6 +30,13 @@ class WebPushModule(_ModuleBase, _MessageBase):
|
||||
"""
|
||||
return ModuleType.Notification
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MessageChannel:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MessageChannel.WebPush
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user